fix: relancer les sources vides après purge
This commit is contained in:
@@ -77,7 +77,7 @@ export default function Upload() {
|
||||
});
|
||||
}, [sourceFiles]);
|
||||
|
||||
const uploadFile = async (file: File): Promise<number | null> => {
|
||||
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) => {
|
||||
|
||||
13
server/db.ts
13
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<SourceFile | undefi
|
||||
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>) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
22
server/sourceFileReimport.test.ts
Normal file
22
server/sourceFileReimport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
13
shared/sourceFileReimport.ts
Normal file
13
shared/sourceFileReimport.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user