fix: require BAP before original PDF exports
This commit is contained in:
@@ -40,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
|
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
|
||||||
|
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
|
||||||
|
|
||||||
type SortField = "invoiceDate" | "createdAt";
|
type SortField = "invoiceDate" | "createdAt";
|
||||||
type SortDir = "asc" | "desc";
|
type SortDir = "asc" | "desc";
|
||||||
@@ -358,10 +359,14 @@ export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
|
|||||||
const isEligibleForExport = (invoice: any) => {
|
const isEligibleForExport = (invoice: any) => {
|
||||||
return (invoice.qualityScore || 0) === 100;
|
return (invoice.qualityScore || 0) === 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEligibleForPdfExport = (invoice: any) => {
|
||||||
|
return isEligibleForExport(invoice) && !requiresBapBeforeOriginalPdfExport(invoice);
|
||||||
|
};
|
||||||
|
|
||||||
const hasOnlyEligibleSelected = selectedIds.length > 0 && selectedIds.every(id => {
|
const hasOnlyEligibleSelected = selectedIds.length > 0 && selectedIds.every(id => {
|
||||||
const invoice = invoices?.find(inv => inv.id === id);
|
const invoice = invoices?.find(inv => inv.id === id);
|
||||||
return invoice && isEligibleForExport(invoice);
|
return invoice && isEligibleForPdfExport(invoice);
|
||||||
});
|
});
|
||||||
|
|
||||||
const sortedInvoices = [...(filteredInvoices || [])].sort((a, b) => {
|
const sortedInvoices = [...(filteredInvoices || [])].sort((a, b) => {
|
||||||
@@ -403,7 +408,7 @@ export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
|
|||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={selectedIds.length === 0 || !hasOnlyEligibleSelected || exportMutation.isPending}
|
disabled={selectedIds.length === 0 || !hasOnlyEligibleSelected || exportMutation.isPending}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
title={!hasOnlyEligibleSelected && selectedIds.length > 0 ? "Seules les factures avec un score de 100% peuvent être exportées" : ""}
|
title={!hasOnlyEligibleSelected && selectedIds.length > 0 ? "Les factures hors abonnement doivent être validées BAP avant l'export PDF" : ""}
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
PDF ({selectedIds.length})
|
PDF ({selectedIds.length})
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
||||||
|
import { wasExportedBeforeBap } from "@shared/bapExportGuard";
|
||||||
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw, FolderDown, ChevronDown, ChevronRight, ChevronsUpDown, CalendarDays, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
import { Search, FileText, Download, FileSpreadsheet, Trash2, Edit, Trash, CheckCircle, CheckCircle2, ShieldCheck, RefreshCw, FolderDown, ChevronDown, ChevronRight, ChevronsUpDown, CalendarDays, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
@@ -410,7 +411,6 @@ export default function InvoicesBAP() {
|
|||||||
const isEligibleForBAPValidation = (invoice: any) => {
|
const isEligibleForBAPValidation = (invoice: any) => {
|
||||||
return (
|
return (
|
||||||
meetsBapQualityThreshold(invoice.qualityScore) &&
|
meetsBapQualityThreshold(invoice.qualityScore) &&
|
||||||
invoice.exportStatus !== "exported" &&
|
|
||||||
invoice.isSubscription === 0 &&
|
invoice.isSubscription === 0 &&
|
||||||
invoice.serviceConcerne &&
|
invoice.serviceConcerne &&
|
||||||
invoice.typeAchat &&
|
invoice.typeAchat &&
|
||||||
@@ -547,7 +547,10 @@ export default function InvoicesBAP() {
|
|||||||
exportExcelMutation.mutate({ invoiceIds: selectedIds });
|
exportExcelMutation.mutate({ invoiceIds: selectedIds });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExportStatusBadge = (status: string) => {
|
const getExportStatusBadge = (status: string, invoice?: any) => {
|
||||||
|
if (invoice && wasExportedBeforeBap(invoice)) {
|
||||||
|
return <Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">Exporté avant BAP</Badge>;
|
||||||
|
}
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "exported":
|
case "exported":
|
||||||
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Exporté</Badge>;
|
return <Badge className="bg-green-100 text-green-800 hover:bg-green-100">Exporté</Badge>;
|
||||||
@@ -586,7 +589,6 @@ export default function InvoicesBAP() {
|
|||||||
const getBAPValidationTooltip = (invoice: any): string => {
|
const getBAPValidationTooltip = (invoice: any): string => {
|
||||||
const reasons: string[] = [];
|
const reasons: string[] = [];
|
||||||
if (!meetsBapQualityThreshold(invoice.qualityScore)) reasons.push(`Score < ${BAP_MIN_QUALITY_SCORE}%`);
|
if (!meetsBapQualityThreshold(invoice.qualityScore)) reasons.push(`Score < ${BAP_MIN_QUALITY_SCORE}%`);
|
||||||
if (invoice.exportStatus === "exported") reasons.push("Déjà exportée");
|
|
||||||
if (invoice.isSubscription !== 0) reasons.push("Marquée comme abonnement");
|
if (invoice.isSubscription !== 0) reasons.push("Marquée comme abonnement");
|
||||||
if (!invoice.serviceConcerne) reasons.push("Service manquant");
|
if (!invoice.serviceConcerne) reasons.push("Service manquant");
|
||||||
if (!invoice.typeAchat) reasons.push("Type d'achat manquant");
|
if (!invoice.typeAchat) reasons.push("Type d'achat manquant");
|
||||||
@@ -1042,7 +1044,7 @@ export default function InvoicesBAP() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
|
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported", invoice)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
40
server/bapExportGuard.test.ts
Normal file
40
server/bapExportGuard.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { requiresBapBeforeOriginalPdfExport } from "../shared/bapExportGuard";
|
||||||
|
|
||||||
|
describe("garde-fou d’export avant BAP", () => {
|
||||||
|
it("bloque l’export PDF original d’une facture hors abonnement non validée BAP", () => {
|
||||||
|
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 0, bapValidated: 0 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("autorise les abonnements et les factures déjà validées BAP", () => {
|
||||||
|
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 1, bapValidated: 0 })).toBe(false);
|
||||||
|
expect(requiresBapBeforeOriginalPdfExport({ isSubscription: 0, bapValidated: 1 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applique le garde-fou aux deux exports PDF métier côté serveur", () => {
|
||||||
|
const routerSource = readFileSync(join(process.cwd(), "server", "routers.ts"), "utf8");
|
||||||
|
const exportPdfSection = routerSource.slice(
|
||||||
|
routerSource.indexOf("exportToPdf: protectedProcedure"),
|
||||||
|
routerSource.indexOf("exportInvoices: protectedProcedure"),
|
||||||
|
);
|
||||||
|
const exportInvoicesSection = routerSource.slice(
|
||||||
|
routerSource.indexOf("exportInvoices: protectedProcedure"),
|
||||||
|
routerSource.indexOf("// ============= IMPORT LOGS ROUTES"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exportPdfSection).toContain("requiresBapBeforeOriginalPdfExport");
|
||||||
|
expect(exportInvoicesSection).toContain("requiresBapBeforeOriginalPdfExport");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clarifie les exports historiques et autorise leur régularisation BAP dans l’interface", () => {
|
||||||
|
const invoicesPage = readFileSync(join(process.cwd(), "client", "src", "pages", "Invoices.tsx"), "utf8");
|
||||||
|
const bapPage = readFileSync(join(process.cwd(), "client", "src", "pages", "InvoicesBAP.tsx"), "utf8");
|
||||||
|
|
||||||
|
expect(invoicesPage).toContain("requiresBapBeforeOriginalPdfExport");
|
||||||
|
expect(bapPage).toContain("Exporté avant BAP");
|
||||||
|
expect(bapPage).toContain("wasExportedBeforeBap");
|
||||||
|
expect(bapPage).not.toContain('invoice.exportStatus !== "exported"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { COOKIE_NAME } from "@shared/const";
|
import { COOKIE_NAME } from "@shared/const";
|
||||||
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility";
|
||||||
|
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
|
||||||
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
|
import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality";
|
||||||
import { webPortalUrlSchema } from "./webPortalUrl";
|
import { webPortalUrlSchema } from "./webPortalUrl";
|
||||||
|
|
||||||
@@ -1407,6 +1408,19 @@ export const appRouter = router({
|
|||||||
message: "Toutes les factures doivent avoir un score de qualité de 100% pour être exportées"
|
message: "Toutes les factures doivent avoir un score de qualité de 100% pour être exportées"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un PDF hors abonnement doit suivre le circuit BAP. Sans ce contrôle
|
||||||
|
// serveur, l'export manuel contournait la validation BAP puis rendait
|
||||||
|
// l'état métier incohérent dans la liste des factures.
|
||||||
|
const invoicesAwaitingBap = invoices.filter(
|
||||||
|
(invoice) => invoice && requiresBapBeforeOriginalPdfExport(invoice),
|
||||||
|
);
|
||||||
|
if (invoicesAwaitingBap.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "La validation BAP est requise avant l'export PDF des factures hors abonnement.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// La destination est résolue facture par facture : une sélection peut
|
// La destination est résolue facture par facture : une sélection peut
|
||||||
// contenir plusieurs abonnements avec des règles de priorité distinctes.
|
// contenir plusieurs abonnements avec des règles de priorité distinctes.
|
||||||
@@ -1649,6 +1663,19 @@ export const appRouter = router({
|
|||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "SFTP not configured" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedInvoices = await Promise.all(input.invoiceIds.map(id => getInvoiceById(id)));
|
||||||
|
const invoicesAwaitingBap = selectedInvoices.filter(
|
||||||
|
(invoice) => invoice
|
||||||
|
&& (ctx.user.role === "admin" || invoice.userId === ctx.user.id)
|
||||||
|
&& requiresBapBeforeOriginalPdfExport(invoice),
|
||||||
|
);
|
||||||
|
if (invoicesAwaitingBap.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "La validation BAP est requise avant l'export SFTP des factures hors abonnement.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Load user settings to check recipient filter
|
// Load user settings to check recipient filter
|
||||||
const userSettings = await getUserSettings(ctx.user.id);
|
const userSettings = await getUserSettings(ctx.user.id);
|
||||||
const recipientFilter = (userSettings as any)?.sftpRecipientFilter?.trim() || "";
|
const recipientFilter = (userSettings as any)?.sftpRecipientFilter?.trim() || "";
|
||||||
|
|||||||
26
shared/bapExportGuard.ts
Normal file
26
shared/bapExportGuard.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Une facture hors abonnement doit être validée BAP avant l'export de son PDF
|
||||||
|
* d'origine. Les abonnements ne suivent pas le circuit BAP.
|
||||||
|
*/
|
||||||
|
export function requiresBapBeforeOriginalPdfExport(input: {
|
||||||
|
isSubscription?: number | boolean | null;
|
||||||
|
bapValidated?: number | boolean | null;
|
||||||
|
}): boolean {
|
||||||
|
const isSubscription = input.isSubscription === 1 || input.isSubscription === true;
|
||||||
|
const isBapValidated = input.bapValidated === 1 || input.bapValidated === true;
|
||||||
|
|
||||||
|
return !isSubscription && !isBapValidated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signale un export historique réalisé avant la validation BAP. Ce cas est
|
||||||
|
* conservé pour l'audit, mais l'interface doit rendre la régularisation BAP
|
||||||
|
* possible et explicite.
|
||||||
|
*/
|
||||||
|
export function wasExportedBeforeBap(input: {
|
||||||
|
exportStatus?: string | null;
|
||||||
|
isSubscription?: number | boolean | null;
|
||||||
|
bapValidated?: number | boolean | null;
|
||||||
|
}): boolean {
|
||||||
|
return input.exportStatus === "exported" && requiresBapBeforeOriginalPdfExport(input);
|
||||||
|
}
|
||||||
51
todo.md
51
todo.md
@@ -1,5 +1,56 @@
|
|||||||
# Invoice Analyzer - TODO
|
# Invoice Analyzer - TODO
|
||||||
|
|
||||||
|
## Cohérence export / BAP — production — 09/10/2026
|
||||||
|
- [x] Tracer les états : Amazon et Inmac ont été exportées par le flux PDF générique le 30/09, sans BAP validé ni historique BAP
|
||||||
|
- [x] Bloquer tout nouvel export PDF hors abonnement avant validation BAP et rendre l’état historique explicite dans l’interface
|
||||||
|
- [ ] Valider la correction en recette puis promouvoir en production sans altérer les PDF ni l’historique existant
|
||||||
|
|
||||||
|
## Incident import LIFEN — production — 09/10/2026
|
||||||
|
- [x] Tracer l’import : PDF stocké, échec lors de l’invocation Référentiel IA (HTTP 400) avant la réponse du modèle
|
||||||
|
- [x] Identifier la cause : le contrat Référentiel IA exige `engineSlug`, absent de l’invocation ; ajouter le routage et le test de non-régression
|
||||||
|
- [x] Déployer le routage corrigé en recette et production, contrôler les conteneurs, les routes et les volumes PDF
|
||||||
|
- [x] Permettre le retraitement production d’une source en erreur sans facture, actuellement bloquée à tort comme doublon
|
||||||
|
- [x] Retraiter la facture LIFEN via le flux applicatif : 1 facture créée, score 100, statut terminé ; santé et volume PDF validés
|
||||||
|
|
||||||
|
## Incident Santino rapide — recette — 02/10/2026
|
||||||
|
- [x] Tracer la réponse de Santino rapide : génération tronquée pendant `extractedText`, sans fermeture JSON
|
||||||
|
- [x] Confirmer la limite de sortie : SANTINO Rapide interrompt à 512 tokens (`finishReason: length`)
|
||||||
|
- [x] Réduire la sortie IA au JSON métier et conserver le texte PDF localement
|
||||||
|
- [x] Déployer et valider une extraction réelle par SANTINO Rapide en recette
|
||||||
|
|
||||||
|
## Réimport après suppression — recette — 02/10/2026
|
||||||
|
- [x] Retirer le blocage de réimport des factures supprimées dans tous les flux
|
||||||
|
- [x] Valider qu’une facture supprimée peut être réimportée sans être déclarée doublon
|
||||||
|
|
||||||
|
## Incident affichage après import — recette — 02/10/2026
|
||||||
|
- [x] Contrôler la facture importée, les filtres actifs et l’identité de session : suppression suivie d’une liste noire résiduelle
|
||||||
|
- [x] Restaurer la facture demandée : 1 facture créée, score 100, statut terminé
|
||||||
|
- [ ] Déployer l’explication explicite des imports sans facture créée et vérifier la liste recette
|
||||||
|
|
||||||
|
## Correction score de qualité à l’import — recette — 02/10/2026
|
||||||
|
- [x] Auditer les flux d’extraction IA et aligner upload, e-mail, dossier et connecteur web sur le recalcul déterministe
|
||||||
|
- [x] Ajouter des tests d’exécution du calcul partagé utilisé par chaque flux d’import
|
||||||
|
- [x] Valider le score de la facture J6LNGDAL0021 et déployer en recette
|
||||||
|
- [x] Ajouter des tests d’exécution nommés pour chaque flux et rejouer l’import manuel en recette sans SQL de score
|
||||||
|
|
||||||
|
## Incident import manuel de PDF — recette — 02/10/2026
|
||||||
|
- [x] Tracer l’import du PDF soumis et identifier le point de blocage
|
||||||
|
- [x] Corriger le traitement des caractères de contrôle dans la réponse IA
|
||||||
|
- [x] Valider le retraitement complet du PDF déjà soumis en recette
|
||||||
|
- [x] Valider un upload manuel isolé avec une copie de test, puis supprimer toutes ses traces
|
||||||
|
|
||||||
|
## Régression signalée d’import manuel — recette — 02/10/2026
|
||||||
|
- [x] Tracer le dernier import après purge : les sources PDF et leur empreinte sont restées en base
|
||||||
|
- [x] Corriger la réanalyse des sources du même utilisateur restées vides après purge
|
||||||
|
- [x] Retirer l’entrée de liste noire résiduelle correspondant au PDF explicitement réimporté
|
||||||
|
- [x] Valider la réanalyse avec le PDF concerné en recette
|
||||||
|
|
||||||
|
## Correction suppression de facture — recette — 02/10/2026
|
||||||
|
- [x] Reproduire l’échec d’archivage dans `deletedInvoices` et identifier l’écart schéma/code
|
||||||
|
- [x] Corriger la persistance d’archivage et supprimer les détails SQL de l’interface
|
||||||
|
- [x] Tester le flux tRPC réel avec une facture de test, puis vérifier son archivage et son absence persistante
|
||||||
|
- [x] Déployer uniquement en recette sans toucher aux volumes PDF
|
||||||
|
|
||||||
## Choix du moteur IA — Paramètres — 30/09/2026
|
## Choix du moteur IA — Paramètres — 30/09/2026
|
||||||
- [x] Analyser la configuration IA locale et le contrat du Référentiel IA
|
- [x] Analyser la configuration IA locale et le contrat du Référentiel IA
|
||||||
- [x] Ajouter le choix entre moteur local et moteur sélectionné dans le Référentiel IA
|
- [x] Ajouter le choix entre moteur local et moteur sélectionné dans le Référentiel IA
|
||||||
|
|||||||
Reference in New Issue
Block a user