fix: rétablir la suppression de facture en recette

This commit is contained in:
Manus
2026-10-02 17:05:14 +00:00
parent aebdafd026
commit c5f0c9eed6
6 changed files with 92 additions and 8 deletions

View File

@@ -0,0 +1,11 @@
-- Rattrapage additif : certains environnements historiques possèdent le code
-- de blacklist sans avoir exécuté la migration 0035 correspondante.
CREATE TABLE IF NOT EXISTS `deletedInvoices` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`invoiceNumber` varchar(100) NOT NULL,
`totalAmount` varchar(50),
`supplierName` varchar(255),
`deletedAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)
);

View File

@@ -295,6 +295,13 @@
"when": 1790785209695,
"tag": "0041_acoustic_microchip",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1790960584104,
"tag": "0042_deleted_invoices_blacklist",
"breakpoints": true
}
]
}
}

View File

@@ -291,12 +291,26 @@ export async function deleteInvoice(id: number) {
// Enregistrer dans la blacklist avant suppression
const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).limit(1);
if (invoice[0] && invoice[0].invoiceNumber) {
await db.insert(deletedInvoices).values({
userId: invoice[0].userId,
invoiceNumber: invoice[0].invoiceNumber,
totalAmount: invoice[0].totalAmount ?? undefined,
supplierName: invoice[0].supplierName ?? undefined,
}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });
const blacklistEntry = await db.select({ id: deletedInvoices.id })
.from(deletedInvoices)
.where(and(
eq(deletedInvoices.userId, invoice[0].userId),
eq(deletedInvoices.invoiceNumber, invoice[0].invoiceNumber),
))
.limit(1);
if (blacklistEntry[0]) {
await db.update(deletedInvoices)
.set({ deletedAt: new Date() })
.where(eq(deletedInvoices.id, blacklistEntry[0].id));
} else {
await db.insert(deletedInvoices).values({
userId: invoice[0].userId,
invoiceNumber: invoice[0].invoiceNumber,
totalAmount: invoice[0].totalAmount ?? undefined,
supplierName: invoice[0].supplierName ?? undefined,
});
}
}
await db.delete(invoices).where(eq(invoices.id, id));
}

View File

@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { INVOICE_DELETE_ERROR, getSafeInvoiceDeletionError } from "./invoiceDeletionError";
const migrationPath = resolve(process.cwd(), "drizzle/0042_deleted_invoices_blacklist.sql");
const dbPath = resolve(process.cwd(), "server/db.ts");
describe("suppression de facture", () => {
it("ne renvoie jamais les détails SQL au navigateur", () => {
const error = new Error("Failed query: insert into `deletedInvoices` values (?, ?, ?)");
expect(getSafeInvoiceDeletionError(error)).toBe(INVOICE_DELETE_ERROR);
});
it("crée la blacklist de manière additive et idempotente", () => {
const migration = readFileSync(migrationPath, "utf8");
expect(migration).toContain("CREATE TABLE IF NOT EXISTS `deletedInvoices`");
expect(migration).toContain("CONSTRAINT `deletedInvoices_id` PRIMARY KEY(`id`)");
});
it("met à jour une entrée existante sans exiger de contrainte unique historique", () => {
const source = readFileSync(dbPath, "utf8");
expect(source).toContain("const blacklistEntry = await db.select");
expect(source).toContain("await db.update(deletedInvoices)");
expect(source).not.toContain("}).onDuplicateKeyUpdate({ set: { deletedAt: new Date() } });");
});
});

View File

@@ -0,0 +1,10 @@
/** Message utilisateur sans détails de requête ni données de facture. */
export const INVOICE_DELETE_ERROR = "La facture ne peut pas être supprimée pour le moment. Réessayez dans quelques instants.";
/**
* Les erreurs de persistance peuvent contenir une requête SQL et des paramètres.
* Ils restent réservés aux journaux serveur et ne doivent jamais atteindre le navigateur.
*/
export function getSafeInvoiceDeletionError(_error: unknown): string {
return INVOICE_DELETE_ERROR;
}

View File

@@ -110,6 +110,7 @@ import { resolveBapExportDestination } from "./exportDestinationResolver";
import { createEmailImportAccountController, startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck, triggerManualEmailCheck, testImapConnection } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { getSafeWebImportSourceError, logWebImportSourceSaveFailure } from "./webImportSourcesError";
import { getSafeInvoiceDeletionError } from "./invoiceDeletionError";
import { getManualWebImportAvailability, runManualWebImport } from "./manualWebImport";
import { TRPCError } from "@trpc/server";
import { processFreeproExcel } from "./freeproService";
@@ -509,7 +510,18 @@ export const appRouter = router({
throw new TRPCError({ code: "NOT_FOUND" });
}
await deleteInvoice(input.id);
try {
await deleteInvoice(input.id);
} catch (error) {
console.error("[InvoiceDelete] Archive or deletion failed", {
invoiceId: input.id,
error: error instanceof Error ? error.message : String(error),
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: getSafeInvoiceDeletionError(error),
});
}
return { success: true };
}),