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 { useLocation } from "wouter";
|
||||
import { matchesInvoicePeriod } from "@shared/invoicePeriod";
|
||||
import { requiresBapBeforeOriginalPdfExport } from "@shared/bapExportGuard";
|
||||
|
||||
type SortField = "invoiceDate" | "createdAt";
|
||||
type SortDir = "asc" | "desc";
|
||||
@@ -359,9 +360,13 @@ export default function Invoices({ scope = "all" }: { scope?: InvoiceScope }) {
|
||||
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" : ""}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
PDF ({selectedIds.length})
|
||||
|
||||
@@ -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 <Badge className="bg-amber-100 text-amber-800 hover:bg-amber-100">Exporté avant BAP</Badge>;
|
||||
}
|
||||
switch (status) {
|
||||
case "exported":
|
||||
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 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() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported")}</TableCell>
|
||||
<TableCell>{getExportStatusBadge(invoice.exportStatus || "not_exported", invoice)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<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 { 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";
|
||||
|
||||
@@ -1408,6 +1409,19 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
const copiedFiles: string[] = [];
|
||||
@@ -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() || "";
|
||||
|
||||
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
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user