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:
220
server/bapCartouche.ts
Normal file
220
server/bapCartouche.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* bapCartouche.ts
|
||||
* Helper centralisé pour dessiner le cartouche BAP sur un PDF.
|
||||
* Utilise la détection de zone blanche pour positionner le cartouche
|
||||
* sans masquer d'informations importantes.
|
||||
*/
|
||||
|
||||
import type { PDFDocument, PDFPage } from "pdf-lib";
|
||||
import { findBestWhiteZone } from "./pdfWhiteZone";
|
||||
|
||||
export interface BapCartoucheData {
|
||||
typeAchat: string;
|
||||
destinataire: string;
|
||||
serviceConcerne: string;
|
||||
ventilationComptable: string;
|
||||
validatedAt: Date;
|
||||
signatureImageBytes?: Buffer;
|
||||
signatureMimeType?: "image/png" | "image/jpeg";
|
||||
signatureName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dessine le cartouche BAP sur le PDF.
|
||||
* Détecte automatiquement une zone blanche disponible.
|
||||
* Si aucune zone n'est trouvée, ajoute une nouvelle page annexe.
|
||||
*
|
||||
* @returns Le PDFDocument modifié (même référence)
|
||||
*/
|
||||
export async function drawBapCartouche(
|
||||
pdfDoc: PDFDocument,
|
||||
pdfBuffer: Buffer,
|
||||
data: BapCartoucheData
|
||||
): Promise<PDFDocument> {
|
||||
const { rgb, StandardFonts } = await import("pdf-lib");
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
const lastPageIndex = pages.length - 1;
|
||||
const lastPage = pages[lastPageIndex];
|
||||
const { width: pageWidth, height: pageHeight } = lastPage.getSize();
|
||||
|
||||
const ZONE_HEIGHT = 160;
|
||||
const ZONE_MARGIN = 28;
|
||||
const ZONE_WIDTH = pageWidth - ZONE_MARGIN * 2;
|
||||
|
||||
// ── Détection de la zone blanche ─────────────────────────────────────────
|
||||
let targetPage: PDFPage;
|
||||
let zoneX: number;
|
||||
let zoneY: number;
|
||||
let zoneW: number;
|
||||
let zoneH: number;
|
||||
let isNewPage = false;
|
||||
|
||||
const whiteZone = await findBestWhiteZone(pdfBuffer, pageWidth, pageHeight, lastPageIndex);
|
||||
|
||||
if (whiteZone && whiteZone.height >= 140) {
|
||||
// Zone blanche trouvée sur la dernière page
|
||||
targetPage = pages[whiteZone.pageIndex];
|
||||
zoneX = whiteZone.x;
|
||||
zoneY = whiteZone.y;
|
||||
zoneW = whiteZone.width;
|
||||
zoneH = Math.min(ZONE_HEIGHT, whiteZone.height);
|
||||
} else {
|
||||
// Aucune zone suffisante → ajouter une nouvelle page
|
||||
targetPage = pdfDoc.addPage([pageWidth, pageHeight]);
|
||||
zoneX = ZONE_MARGIN;
|
||||
zoneY = pageHeight - ZONE_HEIGHT - 40;
|
||||
zoneW = ZONE_WIDTH;
|
||||
zoneH = ZONE_HEIGHT;
|
||||
isNewPage = true;
|
||||
|
||||
// Sur la nouvelle page, ajouter un en-tête discret
|
||||
const fontHeader = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
targetPage.drawText("Annexe BAP — Bon à Payer", {
|
||||
x: ZONE_MARGIN,
|
||||
y: pageHeight - 30,
|
||||
size: 10,
|
||||
font: fontHeader,
|
||||
color: rgb(0.5, 0.5, 0.5),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Dessin du cartouche ───────────────────────────────────────────────────
|
||||
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;
|
||||
|
||||
// Fond blanc avec bordure
|
||||
targetPage.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 : CAPEX/OPEX
|
||||
targetPage.drawText(data.typeAchat.toUpperCase(), {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 22,
|
||||
size: 11,
|
||||
font,
|
||||
color: rgb(0.1, 0.1, 0.5),
|
||||
});
|
||||
|
||||
// Ligne 2 : BON À PAYER
|
||||
targetPage.drawText("BON À PAYER", {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 42,
|
||||
size: 14,
|
||||
font,
|
||||
color: rgb(0, 0.5, 0),
|
||||
});
|
||||
|
||||
// Ligne 3 : Destinataire
|
||||
targetPage.drawText(data.destinataire || "TOUS", {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 62,
|
||||
size: 10,
|
||||
font: fontNormal,
|
||||
color: rgb(0.2, 0.2, 0.2),
|
||||
});
|
||||
|
||||
// Séparateur horizontal
|
||||
targetPage.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
|
||||
targetPage.drawText(
|
||||
`Service : ${data.serviceConcerne || "-"} | Ventilation : ${data.ventilationComptable || "-"}`,
|
||||
{
|
||||
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 dateStr = data.validatedAt.toLocaleDateString("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
const timeStr = data.validatedAt.toLocaleTimeString("fr-FR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
targetPage.drawText(`Validé le ${dateStr} à ${timeStr}`, {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 104,
|
||||
size: 8,
|
||||
font: fontNormal,
|
||||
color: rgb(0.4, 0.4, 0.4),
|
||||
});
|
||||
|
||||
// Note si nouvelle page
|
||||
if (isNewPage) {
|
||||
targetPage.drawText("(cartouche ajouté sur page annexe — facture complète sur page précédente)", {
|
||||
x: zoneX + 10,
|
||||
y: zoneY + zoneH - 120,
|
||||
size: 7,
|
||||
font: fontNormal,
|
||||
color: rgb(0.6, 0.6, 0.6),
|
||||
});
|
||||
}
|
||||
|
||||
// Séparateur vertical
|
||||
targetPage.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 ─────────────────────────────────────────────────────────────
|
||||
if (data.signatureImageBytes && data.signatureName) {
|
||||
try {
|
||||
const mimeType = data.signatureMimeType || "image/png";
|
||||
const embeddedSig =
|
||||
mimeType === "image/png"
|
||||
? await pdfDoc.embedPng(data.signatureImageBytes)
|
||||
: await pdfDoc.embedJpg(data.signatureImageBytes);
|
||||
|
||||
const sigWidth = Math.min(rightColW - 10, 110);
|
||||
const sigHeight = Math.round(sigWidth * 0.45);
|
||||
const sigX = rightColX + (rightColW - sigWidth) / 2;
|
||||
const sigY = zoneY + 35;
|
||||
|
||||
targetPage.drawImage(embeddedSig, {
|
||||
x: sigX,
|
||||
y: sigY,
|
||||
width: sigWidth,
|
||||
height: sigHeight,
|
||||
});
|
||||
|
||||
const nameW = fontNormal.widthOfTextAtSize(data.signatureName, 8);
|
||||
targetPage.drawText(data.signatureName, {
|
||||
x: sigX + (sigWidth - nameW) / 2,
|
||||
y: zoneY + 22,
|
||||
size: 8,
|
||||
font: fontNormal,
|
||||
color: rgb(0.3, 0.3, 0.3),
|
||||
});
|
||||
} catch (_) {
|
||||
/* ignore signature errors */
|
||||
}
|
||||
}
|
||||
|
||||
return pdfDoc;
|
||||
}
|
||||
217
server/pdfWhiteZone.ts
Normal file
217
server/pdfWhiteZone.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* pdfWhiteZone.ts
|
||||
* Détecte la plus grande zone blanche disponible sur la dernière page d'un PDF
|
||||
* pour y placer le cartouche BAP sans masquer d'informations.
|
||||
*
|
||||
* Stratégie :
|
||||
* 1. Rasteriser la dernière page du PDF avec pdftoppm (Poppler)
|
||||
* 2. Analyser l'image avec sharp pour trouver des bandes horizontales blanches
|
||||
* 3. Retourner la meilleure position (x, y) en coordonnées PDF (origine bas-gauche)
|
||||
* 4. Si aucune zone suffisante n'est trouvée, retourner null → le cartouche sera
|
||||
* ajouté sur une nouvelle page annexe
|
||||
*/
|
||||
|
||||
import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import * as fsSync from "fs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface WhiteZoneResult {
|
||||
/** Coordonnée X en points PDF (origine bas-gauche) */
|
||||
x: number;
|
||||
/** Coordonnée Y en points PDF (origine bas-gauche) */
|
||||
y: number;
|
||||
/** Largeur disponible en points PDF */
|
||||
width: number;
|
||||
/** Hauteur disponible en points PDF */
|
||||
height: number;
|
||||
/** Page cible (0-indexed). -1 = nouvelle page à ajouter */
|
||||
pageIndex: number;
|
||||
}
|
||||
|
||||
/** Seuil de luminosité : pixel considéré "blanc" si tous les canaux >= ce seuil */
|
||||
const WHITE_THRESHOLD = 240;
|
||||
/** Hauteur minimale de la zone blanche requise (en points PDF) */
|
||||
const MIN_ZONE_HEIGHT = 155;
|
||||
/** Largeur minimale de la zone blanche requise (en points PDF) */
|
||||
const MIN_ZONE_WIDTH = 400;
|
||||
/** Marge intérieure de la page (en points PDF) */
|
||||
const PAGE_MARGIN = 25;
|
||||
/** Résolution de rasterisation (DPI) */
|
||||
const RASTER_DPI = 150;
|
||||
/** Facteur de conversion points PDF → pixels à 150 DPI (1 pt = 1/72 inch) */
|
||||
const PT_TO_PX = RASTER_DPI / 72;
|
||||
|
||||
/**
|
||||
* Analyse un buffer PDF et retourne la meilleure zone blanche disponible
|
||||
* sur la dernière page, ou null si aucune zone suffisante n'existe.
|
||||
*/
|
||||
export async function findBestWhiteZone(
|
||||
pdfBuffer: Buffer,
|
||||
pageWidth: number,
|
||||
pageHeight: number,
|
||||
lastPageIndex: number
|
||||
): Promise<WhiteZoneResult | null> {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "bap-zone-"));
|
||||
const tmpPdf = path.join(tmpDir, "source.pdf");
|
||||
const tmpImgBase = path.join(tmpDir, "page");
|
||||
|
||||
try {
|
||||
await fs.writeFile(tmpPdf, pdfBuffer);
|
||||
|
||||
// Rasteriser uniquement la dernière page (1-indexed pour pdftoppm)
|
||||
const pageNum = lastPageIndex + 1;
|
||||
await execFileAsync("pdftoppm", [
|
||||
"-r", String(RASTER_DPI),
|
||||
"-f", String(pageNum),
|
||||
"-l", String(pageNum),
|
||||
"-png",
|
||||
tmpPdf,
|
||||
tmpImgBase,
|
||||
]);
|
||||
|
||||
// Trouver le fichier image généré
|
||||
const files = await fs.readdir(tmpDir);
|
||||
const imgFile = files.find(f => f.endsWith(".png") && f.startsWith("page"));
|
||||
if (!imgFile) return null;
|
||||
|
||||
const imgPath = path.join(tmpDir, imgFile);
|
||||
const sharp = (await import("sharp")).default;
|
||||
|
||||
// Charger l'image et obtenir les données brutes RGB
|
||||
const image = sharp(imgPath);
|
||||
const meta = await image.metadata();
|
||||
const imgWidth = meta.width!;
|
||||
const imgHeight = meta.height!;
|
||||
|
||||
const { data } = await image
|
||||
.flatten({ background: { r: 255, g: 255, b: 255 } })
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
// ── Analyse par bandes horizontales ─────────────────────────────────────
|
||||
// Pour chaque ligne de pixels, calculer le % de pixels "blancs"
|
||||
// Une ligne est "blanche" si >= 95% de ses pixels sont blancs
|
||||
const WHITE_LINE_THRESHOLD = 0.95;
|
||||
const isWhiteLine = new Uint8Array(imgHeight);
|
||||
|
||||
for (let y = 0; y < imgHeight; y++) {
|
||||
let whiteCount = 0;
|
||||
for (let x = 0; x < imgWidth; x++) {
|
||||
const idx = (y * imgWidth + x) * 3;
|
||||
const r = data[idx];
|
||||
const g = data[idx + 1];
|
||||
const b = data[idx + 2];
|
||||
if (r >= WHITE_THRESHOLD && g >= WHITE_THRESHOLD && b >= WHITE_THRESHOLD) {
|
||||
whiteCount++;
|
||||
}
|
||||
}
|
||||
isWhiteLine[y] = whiteCount / imgWidth >= WHITE_LINE_THRESHOLD ? 1 : 0;
|
||||
}
|
||||
|
||||
// Trouver les blocs de lignes blanches consécutives
|
||||
interface Band { startY: number; endY: number; heightPx: number }
|
||||
const bands: Band[] = [];
|
||||
let bandStart = -1;
|
||||
|
||||
for (let y = 0; y <= imgHeight; y++) {
|
||||
const isWhite = y < imgHeight ? isWhiteLine[y] === 1 : false;
|
||||
if (isWhite && bandStart === -1) {
|
||||
bandStart = y;
|
||||
} else if (!isWhite && bandStart !== -1) {
|
||||
bands.push({ startY: bandStart, endY: y - 1, heightPx: y - bandStart });
|
||||
bandStart = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (bands.length === 0) return null;
|
||||
|
||||
// Convertir les dimensions minimales en pixels
|
||||
const minHeightPx = Math.ceil(MIN_ZONE_HEIGHT * PT_TO_PX);
|
||||
const minWidthPx = Math.ceil(MIN_ZONE_WIDTH * PT_TO_PX);
|
||||
|
||||
// Filtrer les bandes suffisamment hautes
|
||||
const validBands = bands.filter(b => b.heightPx >= minHeightPx);
|
||||
if (validBands.length === 0) return null;
|
||||
|
||||
// Vérifier la largeur blanche disponible pour chaque bande valide
|
||||
// On prend la bande la plus grande (en bas de page de préférence)
|
||||
// Trier par position Y décroissante (bas de page en premier) puis par hauteur
|
||||
validBands.sort((a, b) => {
|
||||
// Préférer le bas de page
|
||||
const aIsBottom = a.endY > imgHeight * 0.5;
|
||||
const bIsBottom = b.endY > imgHeight * 0.5;
|
||||
if (aIsBottom && !bIsBottom) return -1;
|
||||
if (!aIsBottom && bIsBottom) return 1;
|
||||
// Sinon, préférer la plus grande
|
||||
return b.heightPx - a.heightPx;
|
||||
});
|
||||
|
||||
for (const band of validBands) {
|
||||
// Vérifier la largeur blanche disponible dans cette bande
|
||||
// Analyser les colonnes dans la zone de la bande
|
||||
const midY = Math.floor((band.startY + band.endY) / 2);
|
||||
let leftWhite = 0;
|
||||
let rightWhite = imgWidth - 1;
|
||||
|
||||
// Trouver la marge gauche blanche
|
||||
for (let x = 0; x < imgWidth; x++) {
|
||||
const idx = (midY * imgWidth + x) * 3;
|
||||
if (data[idx] >= WHITE_THRESHOLD && data[idx + 1] >= WHITE_THRESHOLD && data[idx + 2] >= WHITE_THRESHOLD) {
|
||||
leftWhite = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Trouver la marge droite blanche
|
||||
for (let x = imgWidth - 1; x >= 0; x--) {
|
||||
const idx = (midY * imgWidth + x) * 3;
|
||||
if (data[idx] >= WHITE_THRESHOLD && data[idx + 1] >= WHITE_THRESHOLD && data[idx + 2] >= WHITE_THRESHOLD) {
|
||||
rightWhite = x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const availableWidthPx = rightWhite - leftWhite;
|
||||
if (availableWidthPx < minWidthPx) continue;
|
||||
|
||||
// Convertir les coordonnées pixels → points PDF
|
||||
// pdftoppm : y=0 est en haut de l'image, PDF : y=0 est en bas
|
||||
const pdfX = PAGE_MARGIN;
|
||||
const pdfWidth = pageWidth - PAGE_MARGIN * 2;
|
||||
|
||||
// band.startY en pixels depuis le haut → convertir en points depuis le bas
|
||||
const bandTopFromBottom = pageHeight - (band.startY / PT_TO_PX);
|
||||
const bandBottomFromBottom = pageHeight - (band.endY / PT_TO_PX);
|
||||
|
||||
// On place le cartouche centré verticalement dans la bande
|
||||
const zoneHeight = Math.min(MIN_ZONE_HEIGHT, bandTopFromBottom - bandBottomFromBottom - 10);
|
||||
const pdfY = bandBottomFromBottom + 5; // légère marge en bas
|
||||
|
||||
return {
|
||||
x: pdfX,
|
||||
y: pdfY,
|
||||
width: pdfWidth,
|
||||
height: zoneHeight,
|
||||
pageIndex: lastPageIndex,
|
||||
};
|
||||
}
|
||||
|
||||
// Aucune zone suffisante trouvée
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn("[BAP] Erreur détection zone blanche:", err);
|
||||
return null;
|
||||
} finally {
|
||||
// Nettoyage des fichiers temporaires
|
||||
try {
|
||||
const files = await fs.readdir(tmpDir);
|
||||
await Promise.all(files.map(f => fs.unlink(path.join(tmpDir, f))));
|
||||
await fs.rmdir(tmpDir);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user