fix: relancer les sources vides après purge

This commit is contained in:
Manus
2026-10-02 20:46:21 +00:00
parent b09dccb078
commit 70f867d62b
5 changed files with 118 additions and 36 deletions

View File

@@ -77,7 +77,7 @@ export default function Upload() {
}); });
}, [sourceFiles]); }, [sourceFiles]);
const uploadFile = async (file: File): Promise<number | null> => { const uploadFile = async (file: File): Promise<{ sourceFileId: number; reprocessed: boolean } | null> => {
return new Promise((resolve) => { return new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async () => { reader.onload = async () => {
@@ -87,7 +87,7 @@ export default function Upload() {
fileName: file.name, fileName: file.name,
fileData: base64!, fileData: base64!,
}); });
resolve(data.sourceFileId); resolve(data);
} catch (err: any) { } catch (err: any) {
toast.error(`Erreur pour ${file.name} : ${err.message || "Erreur inconnue"}`); toast.error(`Erreur pour ${file.name} : ${err.message || "Erreur inconnue"}`);
resolve(null); resolve(null);
@@ -110,18 +110,27 @@ export default function Upload() {
setIsUploading(true); setIsUploading(true);
toast.info(`Envoi de ${pdfFiles.length} fichier(s)...`); toast.info(`Envoi de ${pdfFiles.length} fichier(s)...`);
let acceptedFiles = 0;
for (const file of pdfFiles) { for (const file of pdfFiles) {
const sourceFileId = await uploadFile(file); const upload = await uploadFile(file);
if (sourceFileId !== null) { if (upload !== null) {
acceptedFiles++;
setUploadedFiles((prev) => [ setUploadedFiles((prev) => [
...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); 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) => { const handleDragOver = useCallback((e: React.DragEvent) => {

View File

@@ -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 { drizzle } from "drizzle-orm/mysql2";
import { import {
InsertUser, InsertUser,
@@ -231,6 +231,17 @@ export async function getSourceFileById(id: number): Promise<SourceFile | undefi
return result[0]; return result[0];
} }
/** Nombre de factures encore rattachées à une source PDF. */
export async function countInvoicesBySourceFileId(sourceFileId: number): Promise<number> {
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<SourceFile>) { export async function updateSourceFile(id: number, data: Partial<SourceFile>) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");

View File

@@ -30,6 +30,7 @@ import {
createSourceFile, createSourceFile,
getSourceFileByContentHash, getSourceFileByContentHash,
getSourceFileById, getSourceFileById,
countInvoicesBySourceFileId,
updateSourceFile, updateSourceFile,
getUserSettings, getUserSettings,
upsertUserSettings, upsertUserSettings,
@@ -102,7 +103,8 @@ import fsSync from "fs";
import pathSync from "path"; import pathSync from "path";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { calculateFileSha256 } from "./fileFingerprint"; 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 { testReferentialIaConnection } from "./referentialIa";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche"; 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. // Il reste fiable même si le nom du fichier ou le compte utilisateur diffère.
const contentHash = calculateFileSha256(fileBuffer); const contentHash = calculateFileSha256(fileBuffer);
const existingSource = await getSourceFileByContentHash(contentHash); const existingSource = await getSourceFileByContentHash(contentHash);
let sourceFile;
let sourceFileKey: string;
let sourceFileUrl: string;
let reprocessed = false;
if (existingSource) { if (existingSource) {
throw new TRPCError({ const invoiceCount = await countInvoicesBySourceFileId(existingSource.id);
code: "CONFLICT", if (!shouldReprocessEmptySource({
message: `Ce PDF a déjà été importé (${existingSource.fileName}).`, 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) // Start extraction process (async - don't wait)
(async () => { (async () => {
try { try {
@@ -439,7 +466,7 @@ export const appRouter = router({
} }
})(); })();
return { sourceFileId: sourceFile.id }; return { sourceFileId: sourceFile.id, reprocessed };
}), }),
list: protectedProcedure.query(async ({ ctx }) => { list: protectedProcedure.query(async ({ ctx }) => {

View File

@@ -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);
});
});

View File

@@ -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;
}