fix: relancer les sources vides après purge
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
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 { 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");
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
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