diff --git a/client/src/pages/Invoices.tsx b/client/src/pages/Invoices.tsx index db06cda..a919bad 100644 --- a/client/src/pages/Invoices.tsx +++ b/client/src/pages/Invoices.tsx @@ -40,6 +40,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { toast } from "sonner"; import { useLocation } from "wouter"; import { matchesInvoicePeriod } from "@shared/invoicePeriod"; +import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard"; type SortField = "invoiceDate" | "createdAt"; type SortDir = "asc" | "desc"; @@ -358,10 +359,14 @@ export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) { const isEligibleForExport = (invoice: any) => { return (invoice.qualityScore || 0) === 100; }; + + const isEligibleForPdfExport = (invoice: any) => { + return isEligibleForExport(invoice) && !requiresBapBeforeOriginalPdfExport(invoice); + }; const hasOnlyEligibleSelected = selectedIds.length > 0 && selectedIds.every(id => { const invoice = invoices?.find(inv => inv.id === id); - return invoice && isEligibleForExport(invoice); + return invoice && isEligibleForPdfExport(invoice); }); const sortedInvoices = [...(filteredInvoices || [])].sort((a, b) => { @@ -403,7 +408,7 @@ export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) { onClick={handleExport} disabled={selectedIds.length === 0 || !hasOnlyEligibleSelected || exportMutation.isPending} 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" : ""} > PDF ({selectedIds.length}) diff --git a/client/src/pages/InvoicesBAP.tsx b/client/src/pages/InvoicesBAP.tsx index 2a61f5e..06c2bb1 100644 --- a/client/src/pages/InvoicesBAP.tsx +++ b/client/src/pages/InvoicesBAP.tsx @@ -33,6 +33,7 @@ import { } from "@/components/ui/table"; import { trpc } from "@/lib/trpc"; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -410,7 +411,6 @@ export default function InvoicesBAP() { const isEligibleForBAPValidation = (invoice: any) => { return ( meetsBapQualityThreshold(invoice.qualityScore) && - invoice.exportStatus !== "exported" && invoice.isSubscription === 0 && invoice.serviceConcerne && invoice.typeAchat && @@ -547,7 +547,10 @@ export default function InvoicesBAP() { exportExcelMutation.mutate({ invoiceIds: selectedIds }); }; - const getExportStatusBadge = (status: string) => { + const getExportStatusBadge = (status: string, invoice?: any) => { + if (invoice && wasExportedBeforeBap(invoice)) { + return Exporté avant BAP; + } switch (status) { case "exported": return Exporté; @@ -586,7 +589,6 @@ export default function InvoicesBAP() { const getBAPValidationTooltip = (invoice: any): string => { const reasons: string[] = []; 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.serviceConcerne) reasons.push("Service manquant"); if (!invoice.typeAchat) reasons.push("Type d'achat manquant"); @@ -1042,7 +1044,7 @@ export default function InvoicesBAP() { )} - {getExportStatusBadge(invoice.exportStatus || "not_exported")} + {getExportStatusBadge(invoice.exportStatus || "not_exported", invoice)} { + 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"'); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 2139cd9..a4ac4e0 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { COOKIE_NAME } from "@shared/const"; import { BAP_MIN_QUALITY_SCORE, meetsBapQualityThreshold } from "@shared/bapEligibility"; +import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard"; import { calculateInvoiceQualityScore, withCalculatedInvoiceQuality } from "@shared/invoiceQuality"; 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" }); } + + // 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 // 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" }); } + 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 const userSettings = await getUserSettings(ctx.user.id); const recipientFilter = (userSettings as any)?.sftpRecipientFilter?.trim() || ""; diff --git a/shared/bapExportGuard.ts b/shared/bapExportGuard.ts new file mode 100644 index 0000000..315542d --- /dev/null +++ b/shared/bapExportGuard.ts @@ -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); +} diff --git a/todo.md b/todo.md index a5dc358..222efd4 100644 --- a/todo.md +++ b/todo.md @@ -1,5 +1,56 @@ # 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 - [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