Checkpoint: Placement intelligent du cartouche BAP : détection de zone blanche par rasterisation PDF (pdftoppm + sharp), cartouche positionné automatiquement dans la zone blanche disponible, ou sur une nouvelle page annexe si aucune zone suffisante n'existe

This commit is contained in:
Manus
2026-04-15 07:35:41 -04:00
parent 927ab2ff22
commit 7fbb3d5f2c
5 changed files with 810 additions and 290 deletions

View File

@@ -78,6 +78,7 @@ import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generat
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server";
@@ -485,105 +486,10 @@ export const appRouter = router({
pdfBytes = Buffer.from(arrayBuffer);
}
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 160pt) ────────────────
const zoneHeight = 160;
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);
// Colonne gauche : texte BAP (2/3 de la largeur)
// Colonne droite : signature (1/3 de la largeur)
const leftColW = Math.floor(zoneW * 0.62);
const rightColX = zoneX + leftColW + 10;
const rightColW = zoneW - leftColW - 20;
// Ligne 1 : CAPEX/OPEX (type achat)
const typeAchatText = (invoice.typeAchat || 'N/A').toUpperCase();
lastPage.drawText(typeAchatText, {
x: zoneX + 10,
y: zoneY + zoneHeight - 22,
size: 11,
font,
color: rgb(0.1, 0.1, 0.5),
});
// Ligne 2 : BON À PAYER (en vert, plus grand)
lastPage.drawText('BON À PAYER', {
x: zoneX + 10,
y: zoneY + zoneHeight - 42,
size: 14,
font,
color: rgb(0, 0.5, 0),
});
// Ligne 3 : Destinataire
const recipientRaw = (invoice as any).recipientName || '';
const destinataireText = recipientRaw ? recipientRaw : 'TOUS';
lastPage.drawText(destinataireText, {
x: zoneX + 10,
y: zoneY + zoneHeight - 62,
size: 10,
font: fontNormal,
color: rgb(0.2, 0.2, 0.2),
});
// Séparateur horizontal
lastPage.drawLine({
start: { x: zoneX + 10, y: zoneY + zoneHeight - 72 },
end: { x: zoneX + leftColW - 10, y: zoneY + zoneHeight - 72 },
thickness: 0.5,
color: rgb(0.7, 0.7, 0.7),
});
// Ligne 4 : Service + Ventilation
const line2 = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`;
lastPage.drawText(line2, {
x: zoneX + 10,
y: zoneY + zoneHeight - 88,
size: 9,
font: fontNormal,
color: rgb(0.2, 0.2, 0.2),
});
// Ligne 5 : 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 - 104,
size: 8,
font: fontNormal,
color: rgb(0.4, 0.4, 0.4),
});
// Séparateur vertical entre colonne gauche et droite
lastPage.drawLine({
start: { x: zoneX + leftColW, y: zoneY + 10 },
end: { x: zoneX + leftColW, y: zoneY + zoneHeight - 10 },
thickness: 0.5,
color: rgb(0.8, 0.8, 0.8),
});
// ── Signature du service ──────────────────────────────────────────
// ── Récupération de la signature du service ───────────────────────
let sigBytesForCartouche: Buffer | undefined;
let sigMimeForCartouche: "image/png" | "image/jpeg" | undefined;
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const serviceName = invoice.serviceConcerne || '';
const assoc = serviceAssociations.find(
@@ -594,12 +500,10 @@ export const appRouter = router({
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
try {
let sigImageBytes: Buffer;
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try {
sigImageBytes = await fs.readFile(sigImagePath);
sigBytesForCartouche = await fs.readFile(sigImagePath);
} catch (_sigLocalErr) {
// Fichier signature non disponible localement → télécharger depuis l'URL
const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl;
@@ -609,39 +513,25 @@ export const appRouter = router({
}
const sigResp = await fetch(absoluteSigUrl);
if (!sigResp.ok) throw new Error(`Impossible de télécharger la signature: ${sigResp.status}`);
sigImageBytes = Buffer.from(await sigResp.arrayBuffer());
sigBytesForCartouche = Buffer.from(await sigResp.arrayBuffer());
}
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);
}
// Signature dans la colonne droite, centrée verticalement
const sigWidth = Math.min(rightColW - 10, 110);
const sigHeight = Math.round(sigWidth * 0.45);
const sigX = rightColX + (rightColW - sigWidth) / 2;
const sigY = zoneY + 35;
lastPage.drawImage(embeddedSig, {
x: sigX,
y: sigY,
width: sigWidth,
height: sigHeight,
});
// Nom du signataire centré sous la signature
const nameW = fontNormal.widthOfTextAtSize(signatureName, 8);
lastPage.drawText(signatureName, {
x: sigX + (sigWidth - nameW) / 2,
y: zoneY + 22,
size: 8,
font: fontNormal,
color: rgb(0.3, 0.3, 0.3),
});
sigMimeForCartouche = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore signature errors */ }
}
}
// ── Placement intelligent du cartouche BAP ────────────────────────
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: invoice.typeAchat || 'N/A',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt: new Date(),
signatureImageBytes: sigBytesForCartouche,
signatureMimeType: sigMimeForCartouche,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
@@ -750,114 +640,51 @@ export const appRouter = router({
sourcePdfBytes = Buffer.from(await resp.arrayBuffer());
}
const pdfDoc = await PDFDocument.load(sourcePdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width, height } = lastPage.getSize();
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica);
// Zone BAP en bas à droite, layout 2 colonnes
const zoneH = 160;
const zoneW = width - 60;
const zoneX = 30;
const zoneY = 10;
const leftColW = Math.floor(zoneW * 0.62);
const rightColX = zoneX + leftColW + 10;
const rightColW = zoneW - leftColW - 20;
lastPage.drawRectangle({
x: zoneX, y: zoneY, width: zoneW, height: zoneH,
color: rgb(1, 1, 1),
borderColor: rgb(0.2, 0.2, 0.2),
borderWidth: 1,
});
// Ligne 1 : Type achat
const typeAchatText = (invoice.typeAchat || '').toUpperCase();
lastPage.drawText(typeAchatText, {
x: zoneX + 10, y: zoneY + zoneH - 22,
size: 11, font: fontBold, color: rgb(0.1, 0.1, 0.5),
});
// Ligne 2 : BON À PAYER
lastPage.drawText('BON À PAYER', {
x: zoneX + 10, y: zoneY + zoneH - 42,
size: 14, font: fontBold, color: rgb(0, 0.5, 0),
});
// Ligne 3 : Destinataire
const recipientText = (invoice as any).recipientName || 'TOUS';
lastPage.drawText(recipientText, {
x: zoneX + 10, y: zoneY + zoneH - 62,
size: 10, font: fontNormal, color: rgb(0.2, 0.2, 0.2),
});
// Séparateur horizontal
lastPage.drawLine({
start: { x: zoneX + 10, y: zoneY + zoneH - 72 },
end: { x: zoneX + leftColW - 10, y: zoneY + zoneH - 72 },
thickness: 0.5, color: rgb(0.7, 0.7, 0.7),
});
// Ligne 4 : Service + Ventilation
const serviceVentLine = `Service : ${invoice.serviceConcerne || '-'} | Ventilation : ${invoice.ventilationComptable || '-'}`;
lastPage.drawText(serviceVentLine, {
x: zoneX + 10, y: zoneY + zoneH - 88,
size: 9, font: fontNormal, color: rgb(0.2, 0.2, 0.2),
});
// Ligne 5 : Date de validation
const bulkNow = new Date();
const bulkDateStr = bulkNow.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
const bulkTimeStr = bulkNow.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
lastPage.drawText(`Validé le ${bulkDateStr} à ${bulkTimeStr}`, {
x: zoneX + 10, y: zoneY + zoneH - 104,
size: 8, font: fontNormal, color: rgb(0.4, 0.4, 0.4),
});
// Séparateur vertical
lastPage.drawLine({
start: { x: zoneX + leftColW, y: zoneY + 10 },
end: { x: zoneX + leftColW, y: zoneY + zoneH - 10 },
thickness: 0.5, color: rgb(0.8, 0.8, 0.8),
});
// Signature du service
const serviceName = invoice.serviceConcerne || '';
const assoc = serviceSignaturesList.find(
a => a.serviceName.toLowerCase() === serviceName.toLowerCase()
// ── Récupération de la signature du service ───────────────────────
let sigBytesForCartouche2: Buffer | undefined;
let sigMimeForCartouche2: "image/png" | "image/jpeg" | undefined;
const serviceName2 = invoice.serviceConcerne || '';
const assoc2 = serviceSignaturesList.find(
a => a.serviceName.toLowerCase() === serviceName2.toLowerCase()
);
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
if (assoc2) {
const sig2 = await getSignatureById(assoc2.signatureId);
if (sig2) {
signatureName = `${sig2.firstName} ${sig2.lastName}`;
try {
let sigImageBytes: Buffer;
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
const sigImagePath2 = path.join(STORAGE_BASE_PATH, sig2.imageKey);
try {
sigImageBytes = await fs.readFile(sigImagePath);
sigBytesForCartouche2 = await fs.readFile(sigImagePath2);
} catch (_) {
const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const sigUrl2 = sig2.imageUrl;
if (!sigUrl2) throw new Error('Image signature introuvable');
let absoluteSigUrl2 = sigUrl2;
if (sigUrl2.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
absoluteSigUrl2 = `${baseUrl}${sigUrl2}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status}`);
sigImageBytes = Buffer.from(await sigResp.arrayBuffer());
const sigResp2 = await fetch(absoluteSigUrl2);
if (!sigResp2.ok) throw new Error(`HTTP ${sigResp2.status}`);
sigBytesForCartouche2 = Buffer.from(await sigResp2.arrayBuffer());
}
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const embeddedSig = mimeType === 'image/png'
? await pdfDoc.embedPng(sigImageBytes)
: await pdfDoc.embedJpg(sigImageBytes);
// Signature dans la colonne droite, centrée
const bulkSigWidth = Math.min(rightColW - 10, 110);
const bulkSigHeight = Math.round(bulkSigWidth * 0.45);
const bulkSigX = rightColX + (rightColW - bulkSigWidth) / 2;
const bulkSigY = zoneY + 35;
lastPage.drawImage(embeddedSig, {
x: bulkSigX, y: bulkSigY, width: bulkSigWidth, height: bulkSigHeight,
});
const bulkNameW = fontNormal.widthOfTextAtSize(signatureName, 8);
lastPage.drawText(signatureName, {
x: bulkSigX + (bulkSigWidth - bulkNameW) / 2, y: zoneY + 22,
size: 8, font: fontNormal, color: rgb(0.3, 0.3, 0.3),
});
} catch (_) { /* ignore signature errors */ }
sigMimeForCartouche2 = sig2.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
// ── Placement intelligent du cartouche BAP ────────────────────────
await drawBapCartouche(pdfDoc, sourcePdfBytes, {
typeAchat: invoice.typeAchat || '',
destinataire: (invoice as any).recipientName || 'TOUS',
serviceConcerne: invoice.serviceConcerne || '-',
ventilationComptable: invoice.ventilationComptable || '-',
validatedAt: validatedAt,
signatureImageBytes: sigBytesForCartouche2,
signatureMimeType: sigMimeForCartouche2,
signatureName: signatureName || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`;
@@ -1009,78 +836,51 @@ export const appRouter = router({
}
const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width } = lastPage.getSize();
const zoneHeight = 160;
const zoneX = 30;
const zoneY = 10;
const zoneW = width - 60;
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);
const leftColW = Math.floor(zoneW * 0.62);
const rightColX = zoneX + leftColW + 10;
const rightColW = zoneW - leftColW - 20;
// Utiliser les données de l'entrée bapHistory
const typeAchatText = (entry.typeAchat || 'N/A').toUpperCase();
lastPage.drawText(typeAchatText, { x: zoneX + 10, y: zoneY + zoneHeight - 22, size: 11, font, color: rgb(0.1, 0.1, 0.5) });
lastPage.drawText('BON À PAYER', { x: zoneX + 10, y: zoneY + zoneHeight - 42, size: 14, font, color: rgb(0, 0.5, 0) });
const destinataireText = entry.recipientName || 'TOUS';
lastPage.drawText(destinataireText, { x: zoneX + 10, y: zoneY + zoneHeight - 62, size: 10, font: fontNormal, color: rgb(0.2, 0.2, 0.2) });
lastPage.drawLine({ start: { x: zoneX + 10, y: zoneY + zoneHeight - 72 }, end: { x: zoneX + leftColW - 10, y: zoneY + zoneHeight - 72 }, thickness: 0.5, color: rgb(0.7, 0.7, 0.7) });
const line2 = `Service : ${entry.serviceConcerne || '-'} | Ventilation : ${entry.ventilationComptable || '-'}`;
lastPage.drawText(line2, { x: zoneX + 10, y: zoneY + zoneHeight - 88, size: 9, font: fontNormal, color: rgb(0.2, 0.2, 0.2) });
const validatedDate = entry.validatedAt ? new Date(entry.validatedAt) : new Date();
const dateStr = validatedDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
const timeStr = validatedDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
lastPage.drawText(`Validé le ${dateStr} à ${timeStr}`, { x: zoneX + 10, y: zoneY + zoneHeight - 104, size: 8, font: fontNormal, color: rgb(0.4, 0.4, 0.4) });
lastPage.drawLine({ start: { x: zoneX + leftColW, y: zoneY + 10 }, end: { x: zoneX + leftColW, y: zoneY + zoneHeight - 10 }, thickness: 0.5, color: rgb(0.8, 0.8, 0.8) });
// Signature
let signatureName: string | null = entry.signatureName || null;
// ── Récupération de la signature du service ───────────────────────
let sigBytesForCartouche3: Buffer | undefined;
let sigMimeForCartouche3: "image/png" | "image/jpeg" | undefined;
let signatureName3: string | null = entry.signatureName || null;
if (entry.serviceConcerne) {
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase());
if (assoc) {
const sig = await getSignatureById(assoc.signatureId);
if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`;
const serviceAssociations3 = await getServiceSignaturesByUser(ctx.user.id);
const assoc3 = serviceAssociations3.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase());
if (assoc3) {
const sig3 = await getSignatureById(assoc3.signatureId);
if (sig3) {
signatureName3 = `${sig3.firstName} ${sig3.lastName}`;
try {
let sigImageBytes: Buffer;
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { sigImageBytes = await fs.readFile(sigImagePath); } catch (_) {
const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl;
if (sigUrl.startsWith('/')) {
const sigImagePath3 = path.join(STORAGE_BASE_PATH, sig3.imageKey);
try { sigBytesForCartouche3 = await fs.readFile(sigImagePath3); } catch (_) {
const sigUrl3 = sig3.imageUrl;
if (!sigUrl3) throw new Error('Image signature introuvable');
let absoluteSigUrl3 = sigUrl3;
if (sigUrl3.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`;
absoluteSigUrl3 = `${baseUrl}${sigUrl3}`;
}
const sigResp = await fetch(absoluteSigUrl);
if (!sigResp.ok) throw new Error('Impossible de télécharger la signature');
sigImageBytes = Buffer.from(await sigResp.arrayBuffer());
const sigResp3 = await fetch(absoluteSigUrl3);
if (!sigResp3.ok) throw new Error('Impossible de télécharger la signature');
sigBytesForCartouche3 = Buffer.from(await sigResp3.arrayBuffer());
}
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const embeddedSig = mimeType === 'image/png' ? await pdfDoc.embedPng(sigImageBytes) : await pdfDoc.embedJpg(sigImageBytes);
const sigWidth = Math.min(rightColW - 10, 110);
const sigHeight = Math.round(sigWidth * 0.45);
const sigX = rightColX + (rightColW - sigWidth) / 2;
lastPage.drawImage(embeddedSig, { x: sigX, y: zoneY + 35, width: sigWidth, height: sigHeight });
const nameW = fontNormal.widthOfTextAtSize(signatureName, 8);
lastPage.drawText(signatureName, { x: sigX + (sigWidth - nameW) / 2, y: zoneY + 22, size: 8, font: fontNormal, color: rgb(0.3, 0.3, 0.3) });
sigMimeForCartouche3 = sig3.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
} catch (_) { /* ignore */ }
}
}
}
// ── Placement intelligent du cartouche BAP ────────────────────────
const validatedDate3 = entry.validatedAt ? new Date(entry.validatedAt) : new Date();
await drawBapCartouche(pdfDoc, pdfBytes, {
typeAchat: entry.typeAchat || 'N/A',
destinataire: entry.recipientName || 'TOUS',
serviceConcerne: entry.serviceConcerne || '-',
ventilationComptable: entry.ventilationComptable || '-',
validatedAt: validatedDate3,
signatureImageBytes: sigBytesForCartouche3,
signatureMimeType: sigMimeForCartouche3,
signatureName: signatureName3 || undefined,
});
const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey || `invoice_${invoice.id}.pdf`);
const bapFilename = `BAP_${Date.now()}_${filename}`;