Checkpoint: Améliorations Factures BAP : suppression colonne Abonnement, mode d'export configurable (navigateur/dossier), génération PDF annoté avec zone blanche (CAPEX/OPEX | BAP | Destinataire + signature du service) lors du clic BAP, historique BAP dans le menu Traçabilité, table bapHistory en DB.

This commit is contained in:
Manus
2026-04-12 05:57:02 -04:00
parent edf7cc704d
commit 5f7c8c7c7f
12 changed files with 2261 additions and 39 deletions

View File

@@ -63,6 +63,9 @@ import {
getServiceSignaturesByUser,
upsertServiceSignature,
deleteServiceSignature,
createBapHistoryEntry,
getBapHistoryByUser,
deleteBapHistoryEntry,
} from "./db";
import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generateToken } from "./auth";
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
@@ -386,28 +389,22 @@ export const appRouter = router({
return { success: true };
}),
validateBAP: protectedProcedure
validateBAP: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const invoice = await getInvoiceById(input.id);
if (!invoice || invoice.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// Vérifier les critères de validation BAP
const score = invoice.qualityScore || 0;
const isNotExported = invoice.exportStatus !== "exported";
const isNotSubscription = invoice.isSubscription === 0;
const hasService = !!invoice.serviceConcerne;
const hasTypeAchat = !!invoice.typeAchat;
const hasVentilation = !!invoice.ventilationComptable;
if (score < 100) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Le score de qualité doit être à 100% pour valider" });
}
if (!isNotExported) {
throw new TRPCError({ code: "BAD_REQUEST", message: "La facture a déjà été exportée" });
}
if (!isNotSubscription) {
throw new TRPCError({ code: "BAD_REQUEST", message: "La facture est marquée comme abonnement" });
}
@@ -415,24 +412,216 @@ export const appRouter = router({
throw new TRPCError({ code: "BAD_REQUEST", message: "Les champs Service, Type d'achat et Ventilation doivent être remplis" });
}
// ── Génération du PDF annoté ──────────────────────────────────────
const fs = await import('fs/promises');
const path = await import('path');
const { PDFDocument, rgb, StandardFonts } = await import('pdf-lib');
const { storagePut } = await import('./storage');
const importSettings = await getImportSettingsByUser(ctx.user.id);
const bapExportMode = importSettings?.bapExportMode || 'browser';
const exportFolder = importSettings?.exportFolder || null;
const STORAGE_BASE_PATH = process.env.STORAGE_BASE_PATH || path.join(process.cwd(), 'storage');
let pdfUrl: string | null = null;
let exportPath: string | null = null;
let signatureName: string | null = null;
try {
if (!invoice.fileKey) throw new Error('Fichier PDF source introuvable');
const sourcePath = path.join(STORAGE_BASE_PATH, invoice.fileKey);
const pdfBytes = await fs.readFile(sourcePath);
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width, height } = lastPage.getSize();
// ── Zone blanche BAP (bas de page, hauteur 140pt) ────────────────
const zoneHeight = 140;
const zoneX = 30;
const zoneY = 10;
const zoneW = width - 60;
// Fond blanc
lastPage.drawRectangle({
x: zoneX,
y: zoneY,
width: zoneW,
height: zoneHeight,
color: rgb(1, 1, 1),
borderColor: rgb(0.7, 0.7, 0.7),
borderWidth: 0.5,
});
const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Ligne 1 : CAPEX/OPEX | BAP | Destinataire
const typeAchatText = (invoice.typeAchat || 'N/A').toUpperCase();
const recipientRaw = (invoice as any).recipientName || '';
const destinataireText = recipientRaw ? recipientRaw : 'TOUS';
const line1 = `${typeAchatText} | BON À PAYER | ${destinataireText}`;
const line1Size = 11;
const line1W = font.widthOfTextAtSize(line1, line1Size);
lastPage.drawText(line1, {
x: zoneX + (zoneW - line1W) / 2,
y: zoneY + zoneHeight - 22,
size: line1Size,
font,
color: rgb(0.1, 0.1, 0.5),
});
// Séparateur
lastPage.drawLine({
start: { x: zoneX + 10, y: zoneY + zoneHeight - 30 },
end: { x: zoneX + zoneW - 10, y: zoneY + zoneHeight - 30 },
thickness: 0.5,
color: rgb(0.7, 0.7, 0.7),
});
// Ligne 2 : Service + Ventilation
const line2 = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`;
lastPage.drawText(line2, {
x: zoneX + 10,
y: zoneY + zoneHeight - 48,
size: 9,
font: fontNormal,
color: rgb(0.2, 0.2, 0.2),
});
// Ligne 3 : Date de validation
const now = new Date();
const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
lastPage.drawText(`Validé le ${dateStr} à ${timeStr}`, {
x: zoneX + 10,
y: zoneY + zoneHeight - 64,
size: 8,
font: fontNormal,
color: rgb(0.4, 0.4, 0.4),
});
// ── Signature du service ──────────────────────────────────────────
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const serviceName = invoice.serviceConcerne || '';
const assoc = serviceAssociations.find(
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
);
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
const sigImageBytes = await fs.readFile(sigImagePath);
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
let embeddedSig;
if (mimeType === 'image/png') {
embeddedSig = await pdfDoc.embedPng(sigImageBytes);
} else {
embeddedSig = await pdfDoc.embedJpg(sigImageBytes);
}
const sigWidth = 100;
const sigHeight = 45;
lastPage.drawImage(embeddedSig, {
x: zoneX + zoneW - sigWidth - 10,
y: zoneY + 20,
width: sigWidth,
height: sigHeight,
});
lastPage.drawText(signatureName, {
x: zoneX + zoneW - sigWidth - 10,
y: zoneY + 12,
size: 8,
font: fontNormal,
color: rgb(0.3, 0.3, 0.3),
});
} catch (_) { /* ignore signature errors */ }
}
}
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
if (bapExportMode === 'folder' && exportFolder) {
// Mode dossier : enregistrer sur le disque
await fs.mkdir(exportFolder, { recursive: true });
exportPath = path.join(exportFolder, bapFilename);
await fs.writeFile(exportPath, signedPdfBytes);
} else {
// Mode navigateur : uploader sur S3 et retourner l'URL
const { storagePut: put } = await import('./storage');
const { url } = await put(`bap-exports/${bapFilename}`, Buffer.from(signedPdfBytes), 'application/pdf');
pdfUrl = url;
}
} catch (pdfError: any) {
console.warn('[BAP] Erreur génération PDF:', pdfError.message);
// On continue même si le PDF échoue — on valide quand même
}
// ── Mise à jour de la facture ─────────────────────────────────────
const validatedAt = new Date();
await updateInvoice(input.id, {
bapValidated: 1,
bapValidatedAt: new Date(),
bapValidatedAt: validatedAt,
});
return { success: true, validatedAt: new Date() };
// ── Enregistrement dans l'historique BAP ─────────────────────────
await createBapHistoryEntry({
userId: ctx.user.id,
invoiceId: invoice.id,
supplierName: invoice.supplierName || null,
invoiceNumber: invoice.invoiceNumber || null,
invoiceDate: invoice.invoiceDate || null,
totalAmount: invoice.totalAmount ? String(invoice.totalAmount) : null,
typeAchat: invoice.typeAchat || null,
serviceConcerne: invoice.serviceConcerne || null,
ventilationComptable: invoice.ventilationComptable || null,
recipientName: (invoice as any).recipientName || null,
exportMode: bapExportMode === 'folder' ? 'folder' : 'browser',
exportPath: exportPath || null,
pdfUrl: pdfUrl || null,
signatureName: signatureName || null,
validatedAt,
});
return {
success: true,
validatedAt,
pdfUrl,
exportPath,
exportMode: bapExportMode,
};
}),
search: protectedProcedure
// ── Historique BAP ────────────────────────────────────────────────────
search: protectedProcedure
.input(z.object({ query: z.string() }))
.query(async ({ input, ctx }) => {
return searchInvoices(ctx.user.id, input.query);
}),
getStats: protectedProcedure.query(async ({ ctx }) => {
return getInvoiceStats(ctx.user.id);
}),
}),
// ============= BAP HISTORY ROUTES =============
bapHistory: router({
getAll: protectedProcedure.query(async ({ ctx }) => {
return getBapHistoryByUser(ctx.user.id);
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input, ctx }) => {
const entries = await getBapHistoryByUser(ctx.user.id);
const entry = entries.find(e => e.id === input.id);
if (!entry) throw new TRPCError({ code: 'NOT_FOUND' });
await deleteBapHistoryEntry(input.id);
return { success: true };
}),
}),
// ============= SOURCE FILES ROUTES =============
sourceFiles: router({
@@ -888,6 +1077,7 @@ export const appRouter = router({
emailImportPort: 993,
emailImportFrequency: 30,
exportFolder: null,
bapExportMode: "browser" as const,
};
}
@@ -907,6 +1097,7 @@ export const appRouter = router({
emailImportPort: z.number().min(1).max(65535).optional(),
emailImportFrequency: z.number().min(1).optional(),
exportFolder: z.string().nullable().optional(),
bapExportMode: z.enum(["browser", "folder"]).optional(),
}))
.mutation(async ({ input, ctx }) => {
const settings = await upsertImportSettings({