diff --git a/client/src/pages/Upload.tsx b/client/src/pages/Upload.tsx index d33f70a..4be9ee1 100644 --- a/client/src/pages/Upload.tsx +++ b/client/src/pages/Upload.tsx @@ -77,7 +77,7 @@ export default function Upload() { }); }, [sourceFiles]); - const uploadFile = async (file: File): Promise => { + const uploadFile = async (file: File): Promise<{ sourceFileId: number; reprocessed: boolean } | null> => { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = async () => { @@ -87,7 +87,7 @@ export default function Upload() { fileName: file.name, fileData: base64!, }); - resolve(data.sourceFileId); + resolve(data); } catch (err: any) { toast.error(`Erreur pour ${file.name} : ${err.message || "Erreur inconnue"}`); resolve(null); @@ -110,18 +110,27 @@ export default function Upload() { setIsUploading(true); toast.info(`Envoi de ${pdfFiles.length} fichier(s)...`); + let acceptedFiles = 0; for (const file of pdfFiles) { - const sourceFileId = await uploadFile(file); - if (sourceFileId !== null) { + const upload = await uploadFile(file); + if (upload !== null) { + acceptedFiles++; setUploadedFiles((prev) => [ ...prev, - { name: file.name, sourceFileId, status: "processing" }, + { name: file.name, sourceFileId: upload.sourceFileId, status: "processing" }, ]); + if (upload.reprocessed) { + toast.info(`${file.name} : source existante retrouvée, nouvelle analyse lancée.`); + } } } setIsUploading(false); - toast.success(`${pdfFiles.length} fichier(s) envoyé(s) — traitement en cours`); + if (acceptedFiles > 0) { + toast.success(`${acceptedFiles} fichier(s) envoyé(s) — traitement en cours`); + } else { + toast.warning("Aucun fichier n’a été lancé : consultez le message d’erreur affiché."); + } }; const handleDragOver = useCallback((e: React.DragEvent) => { diff --git a/server/db.ts b/server/db.ts index 799b4d5..4047bb7 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,4 +1,4 @@ -import { eq, and, desc, sql, inArray, getTableColumns } from "drizzle-orm"; +import { eq, and, desc, sql, inArray, getTableColumns, count } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import { InsertUser, @@ -231,6 +231,17 @@ export async function getSourceFileById(id: number): Promise { + const db = await getDb(); + if (!db) return 0; + const result = await db + .select({ value: count() }) + .from(invoices) + .where(eq(invoices.sourceFileId, sourceFileId)); + return Number(result[0]?.value ?? 0); +} + export async function updateSourceFile(id: number, data: Partial) { const db = await getDb(); if (!db) throw new Error("Database not available"); diff --git a/server/routers.ts b/server/routers.ts index cc6f359..67850c7 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -30,6 +30,7 @@ import { createSourceFile, getSourceFileByContentHash, getSourceFileById, + countInvoicesBySourceFileId, updateSourceFile, getUserSettings, upsertUserSettings, @@ -102,7 +103,8 @@ import fsSync from "fs"; import pathSync from "path"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { calculateFileSha256 } from "./fileFingerprint"; -import { localStoragePut, generateStorageKey } from "./localStorage"; +import { localStoragePut, localStorageExists, generateStorageKey } from "./localStorage"; +import { shouldReprocessEmptySource } from "@shared/sourceFileReimport"; import { testReferentialIaConnection } from "./referentialIa"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; import { drawBapCartouche } from "./bapCartouche"; @@ -206,37 +208,62 @@ export const appRouter = router({ // Il reste fiable même si le nom du fichier ou le compte utilisateur diffère. const contentHash = calculateFileSha256(fileBuffer); const existingSource = await getSourceFileByContentHash(contentHash); + let sourceFile; + let sourceFileKey: string; + let sourceFileUrl: string; + let reprocessed = false; + if (existingSource) { - throw new TRPCError({ - code: "CONFLICT", - message: `Ce PDF a déjà été importé (${existingSource.fileName}).`, + const invoiceCount = await countInvoicesBySourceFileId(existingSource.id); + if (!shouldReprocessEmptySource({ + sourceUserId: existingSource.userId, + requestingUserId: userId, + invoiceCount, + })) { + throw new TRPCError({ + code: "CONFLICT", + message: `Ce PDF a déjà été importé (${existingSource.fileName}).`, + }); + } + + // Une purge peut supprimer les factures tout en conservant la source et son empreinte. + // On relance alors l'analyse depuis la source existante plutôt que de contourner l'anti-doublon. + sourceFile = existingSource; + sourceFileKey = existingSource.fileKey; + sourceFileUrl = existingSource.fileUrl; + reprocessed = true; + if (!await localStorageExists(sourceFileKey)) { + await localStoragePut(sourceFileKey, fileBuffer, "application/pdf"); + } + await updateSourceFile(sourceFile.id, { + processingStatus: "processing", + totalInvoicesDetected: 0, + processingProgress: "Nouvelle analyse après purge...", + }); + } else { + // Store source file + sourceFileKey = generateStorageKey(userId, input.fileName); + console.log(`[Upload] Generated storage key: ${sourceFileKey}`); + + try { + const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf"); + sourceFileUrl = result.url; + console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`); + } catch (error) { + console.error(`[Upload] FAILED to store file:`, error); + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" }); + } + + sourceFile = await createSourceFile({ + userId, + fileName: input.fileName, + fileKey: sourceFileKey, + fileUrl: sourceFileUrl, + contentHash, + processingStatus: "processing", }); } - // Store source file - const sourceFileKey = generateStorageKey(userId, input.fileName); - console.log(`[Upload] Generated storage key: ${sourceFileKey}`); - - let sourceFileUrl: string; - try { - const result = await localStoragePut(sourceFileKey, fileBuffer, "application/pdf"); - sourceFileUrl = result.url; - console.log(`[Upload] File stored successfully at: ${sourceFileUrl}`); - } catch (error) { - console.error(`[Upload] FAILED to store file:`, error); - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to store PDF file" }); - } - - // Create source file record - const sourceFile = await createSourceFile({ - userId, - fileName: input.fileName, - fileKey: sourceFileKey, - fileUrl: sourceFileUrl, - contentHash, - processingStatus: "processing", - }); - // Start extraction process (async - don't wait) (async () => { try { @@ -439,7 +466,7 @@ export const appRouter = router({ } })(); - return { sourceFileId: sourceFile.id }; + return { sourceFileId: sourceFile.id, reprocessed }; }), list: protectedProcedure.query(async ({ ctx }) => { diff --git a/server/sourceFileReimport.test.ts b/server/sourceFileReimport.test.ts new file mode 100644 index 0000000..de70886 --- /dev/null +++ b/server/sourceFileReimport.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { shouldReprocessEmptySource } from "@shared/sourceFileReimport"; + +describe("shouldReprocessEmptySource", () => { + it("réutilise une source du même utilisateur lorsqu'une purge a supprimé toutes ses factures", () => { + expect( + shouldReprocessEmptySource({ sourceUserId: 2, requestingUserId: 2, invoiceCount: 0 }), + ).toBe(true); + }); + + it("conserve la protection contre les doublons quand la source contient déjà une facture", () => { + expect( + shouldReprocessEmptySource({ sourceUserId: 2, requestingUserId: 2, invoiceCount: 1 }), + ).toBe(false); + }); + + it("ne réutilise jamais une source appartenant à un autre utilisateur", () => { + expect( + shouldReprocessEmptySource({ sourceUserId: 3, requestingUserId: 2, invoiceCount: 0 }), + ).toBe(false); + }); +}); diff --git a/shared/sourceFileReimport.ts b/shared/sourceFileReimport.ts new file mode 100644 index 0000000..64877e9 --- /dev/null +++ b/shared/sourceFileReimport.ts @@ -0,0 +1,13 @@ +/** + * Autorise une nouvelle analyse uniquement lorsqu'une source existante du même + * utilisateur ne possède plus aucune facture, par exemple après une purge. + * Une source contenant déjà au moins une facture reste protégée contre les + * doublons, y compris entre comptes distincts. + */ +export function shouldReprocessEmptySource(input: { + sourceUserId: number; + requestingUserId: number; + invoiceCount: number; +}): boolean { + return input.sourceUserId === input.requestingUserId && input.invoiceCount === 0; +}