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:
37
server/db.ts
37
server/db.ts
@@ -38,7 +38,10 @@ import {
|
||||
Signature,
|
||||
serviceSignatures,
|
||||
InsertServiceSignature,
|
||||
ServiceSignature
|
||||
ServiceSignature,
|
||||
bapHistory,
|
||||
InsertBapHistory,
|
||||
BapHistory
|
||||
} from "../drizzle/schema";
|
||||
import { ENV } from './_core/env';
|
||||
|
||||
@@ -776,3 +779,35 @@ export async function deleteServiceSignature(userId: number, serviceName: string
|
||||
await db.delete(serviceSignatures).where(eq(serviceSignatures.id, match.id));
|
||||
}
|
||||
}
|
||||
|
||||
// ============= BAP HISTORY HELPERS =============
|
||||
export async function createBapHistoryEntry(data: InsertBapHistory): Promise<BapHistory> {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(bapHistory).values(data);
|
||||
const insertId = (result[0] as any).insertId;
|
||||
const created = await getBapHistoryById(insertId);
|
||||
if (!created) throw new Error("Failed to retrieve created BAP history entry");
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function getBapHistoryById(id: number): Promise<BapHistory | undefined> {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const results = await db.select().from(bapHistory).where(eq(bapHistory.id, id));
|
||||
return results[0];
|
||||
}
|
||||
|
||||
export async function getBapHistoryByUser(userId: number): Promise<BapHistory[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(bapHistory)
|
||||
.where(eq(bapHistory.userId, userId))
|
||||
.orderBy(desc(bapHistory.validatedAt));
|
||||
}
|
||||
|
||||
export async function deleteBapHistoryEntry(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(bapHistory).where(eq(bapHistory.id, id));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user