fix: relancer les sources vides après purge
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user