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

@@ -88,6 +88,7 @@
"react-pdf": "^10.3.0", "react-pdf": "^10.3.0",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"sharp": "^0.34.5",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"ssh2-sftp-client": "^12.0.1", "ssh2-sftp-client": "^12.0.1",
"streamdown": "^1.4.0", "streamdown": "^1.4.0",
@@ -131,6 +132,9 @@
}, },
"overrides": { "overrides": {
"tailwindcss>nanoid": "3.3.7" "tailwindcss>nanoid": "3.3.7"
} },
"onlyBuiltDependencies": [
"sharp"
]
} }
} }

279
pnpm-lock.yaml generated
View File

@@ -241,6 +241,9 @@ importers:
recharts: recharts:
specifier: ^2.15.2 specifier: ^2.15.2
version: 2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) version: 2.15.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
sharp:
specifier: ^0.34.5
version: 0.34.5
sonner: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -640,6 +643,9 @@ packages:
'@drizzle-team/brocli@0.10.2': '@drizzle-team/brocli@0.10.2':
resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
'@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.is' deprecated: 'Merged into tsx: https://tsx.is'
@@ -1100,6 +1106,143 @@ packages:
'@iconify/utils@3.0.2': '@iconify/utils@3.0.2':
resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==} resolution: {integrity: sha512-EfJS0rLfVuRuJRn4psJHtK2A9TqVnkxPpHY6lYHiB9+8eSuudsxbwMiavocG45ujOo6FJ+CIRlRnlOGinzkaGQ==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -4637,6 +4780,10 @@ packages:
setprototypeof@1.2.0: setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -5817,6 +5964,11 @@ snapshots:
'@drizzle-team/brocli@0.10.2': {} '@drizzle-team/brocli@0.10.2': {}
'@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
dependencies: dependencies:
esbuild: 0.18.20 esbuild: 0.18.20
@@ -6077,6 +6229,102 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.9.2
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@isaacs/cliui@8.0.2': '@isaacs/cliui@8.0.2':
dependencies: dependencies:
string-width: 5.1.2 string-width: 5.1.2
@@ -10100,6 +10348,37 @@ snapshots:
setprototypeof@1.2.0: {} setprototypeof@1.2.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0

220
server/bapCartouche.ts Normal file
View 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
View 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 */ }
}
}

View File

@@ -78,6 +78,7 @@ import { loginLocal, hashPassword, getAzureAuthUrl, isAzureAdConfigured, generat
import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor"; import { extractInvoicesWithMistral, generateMetadataJSON } from "./invoiceExtractor";
import { localStoragePut, generateStorageKey } from "./localStorage"; import { localStoragePut, generateStorageKey } from "./localStorage";
import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport"; import { testSftpConnection, exportInvoiceToSftp, getUserSftpConfig } from "./sftpExport";
import { drawBapCartouche } from "./bapCartouche";
import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck } from "./emailImportService"; import { startEmailImportService, stopEmailImportService, isEmailImportServiceRunning, triggerEmailCheck } from "./emailImportService";
import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService"; import { startFolderImportService, stopFolderImportService, isFolderImportServiceRunning } from "./folderImportService";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -485,105 +486,10 @@ export const appRouter = router({
pdfBytes = Buffer.from(arrayBuffer); pdfBytes = Buffer.from(arrayBuffer);
} }
const pdfDoc = await PDFDocument.load(pdfBytes); 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) ──────────────── // ── Récupération de la signature du service ───────────────────────
const zoneHeight = 160; let sigBytesForCartouche: Buffer | undefined;
const zoneX = 30; let sigMimeForCartouche: "image/png" | "image/jpeg" | undefined;
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 ──────────────────────────────────────────
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id);
const serviceName = invoice.serviceConcerne || ''; const serviceName = invoice.serviceConcerne || '';
const assoc = serviceAssociations.find( const assoc = serviceAssociations.find(
@@ -594,12 +500,10 @@ export const appRouter = router({
if (sig) { if (sig) {
signatureName = `${sig.firstName} ${sig.lastName}`; signatureName = `${sig.firstName} ${sig.lastName}`;
try { try {
let sigImageBytes: Buffer;
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { try {
sigImageBytes = await fs.readFile(sigImagePath); sigBytesForCartouche = await fs.readFile(sigImagePath);
} catch (_sigLocalErr) { } catch (_sigLocalErr) {
// Fichier signature non disponible localement → télécharger depuis l'URL
const sigUrl = sig.imageUrl; const sigUrl = sig.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable'); if (!sigUrl) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl; let absoluteSigUrl = sigUrl;
@@ -609,39 +513,25 @@ export const appRouter = router({
} }
const sigResp = await fetch(absoluteSigUrl); const sigResp = await fetch(absoluteSigUrl);
if (!sigResp.ok) throw new Error(`Impossible de télécharger la signature: ${sigResp.status}`); 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'; sigMimeForCartouche = 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),
});
} catch (_) { /* ignore signature errors */ } } 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 signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey); const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`; const bapFilename = `BAP_${Date.now()}_${filename}`;
@@ -750,114 +640,51 @@ export const appRouter = router({
sourcePdfBytes = Buffer.from(await resp.arrayBuffer()); sourcePdfBytes = Buffer.from(await resp.arrayBuffer());
} }
const pdfDoc = await PDFDocument.load(sourcePdfBytes); const pdfDoc = await PDFDocument.load(sourcePdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1]; // ── Récupération de la signature du service ───────────────────────
const { width, height } = lastPage.getSize(); let sigBytesForCartouche2: Buffer | undefined;
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); let sigMimeForCartouche2: "image/png" | "image/jpeg" | undefined;
const fontNormal = await pdfDoc.embedFont(StandardFonts.Helvetica); const serviceName2 = invoice.serviceConcerne || '';
// Zone BAP en bas à droite, layout 2 colonnes const assoc2 = serviceSignaturesList.find(
const zoneH = 160; a => a.serviceName.toLowerCase() === serviceName2.toLowerCase()
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()
); );
if (assoc) { if (assoc2) {
const sig = await getSignatureById(assoc.signatureId); const sig2 = await getSignatureById(assoc2.signatureId);
if (sig) { if (sig2) {
signatureName = `${sig.firstName} ${sig.lastName}`; signatureName = `${sig2.firstName} ${sig2.lastName}`;
try { try {
let sigImageBytes: Buffer; const sigImagePath2 = path.join(STORAGE_BASE_PATH, sig2.imageKey);
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey);
try { try {
sigImageBytes = await fs.readFile(sigImagePath); sigBytesForCartouche2 = await fs.readFile(sigImagePath2);
} catch (_) { } catch (_) {
const sigUrl = sig.imageUrl; const sigUrl2 = sig2.imageUrl;
if (!sigUrl) throw new Error('Image signature introuvable'); if (!sigUrl2) throw new Error('Image signature introuvable');
let absoluteSigUrl = sigUrl; let absoluteSigUrl2 = sigUrl2;
if (sigUrl.startsWith('/')) { if (sigUrl2.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`; absoluteSigUrl2 = `${baseUrl}${sigUrl2}`;
} }
const sigResp = await fetch(absoluteSigUrl); const sigResp2 = await fetch(absoluteSigUrl2);
if (!sigResp.ok) throw new Error(`HTTP ${sigResp.status}`); if (!sigResp2.ok) throw new Error(`HTTP ${sigResp2.status}`);
sigImageBytes = Buffer.from(await sigResp.arrayBuffer()); sigBytesForCartouche2 = Buffer.from(await sigResp2.arrayBuffer());
} }
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; sigMimeForCartouche2 = sig2.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const embeddedSig = mimeType === 'image/png' } catch (_) { /* ignore */ }
? await pdfDoc.embedPng(sigImageBytes) }
: await pdfDoc.embedJpg(sigImageBytes); }
// Signature dans la colonne droite, centrée
const bulkSigWidth = Math.min(rightColW - 10, 110); // ── Placement intelligent du cartouche BAP ────────────────────────
const bulkSigHeight = Math.round(bulkSigWidth * 0.45); await drawBapCartouche(pdfDoc, sourcePdfBytes, {
const bulkSigX = rightColX + (rightColW - bulkSigWidth) / 2; typeAchat: invoice.typeAchat || '',
const bulkSigY = zoneY + 35; destinataire: (invoice as any).recipientName || 'TOUS',
lastPage.drawImage(embeddedSig, { serviceConcerne: invoice.serviceConcerne || '-',
x: bulkSigX, y: bulkSigY, width: bulkSigWidth, height: bulkSigHeight, ventilationComptable: invoice.ventilationComptable || '-',
validatedAt: validatedAt,
signatureImageBytes: sigBytesForCartouche2,
signatureMimeType: sigMimeForCartouche2,
signatureName: signatureName || undefined,
}); });
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 */ }
}
}
const signedPdfBytes = await pdfDoc.save(); const signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey); const filename = path.basename(invoice.fileKey);
const bapFilename = `BAP_${Date.now()}_${filename}`; const bapFilename = `BAP_${Date.now()}_${filename}`;
@@ -1009,78 +836,51 @@ export const appRouter = router({
} }
const pdfDoc = await PDFDocument.load(pdfBytes); const pdfDoc = await PDFDocument.load(pdfBytes);
const pages = pdfDoc.getPages();
const lastPage = pages[pages.length - 1];
const { width } = lastPage.getSize();
const zoneHeight = 160; // ── Récupération de la signature du service ───────────────────────
const zoneX = 30; let sigBytesForCartouche3: Buffer | undefined;
const zoneY = 10; let sigMimeForCartouche3: "image/png" | "image/jpeg" | undefined;
const zoneW = width - 60; let signatureName3: string | null = entry.signatureName || null;
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;
if (entry.serviceConcerne) { if (entry.serviceConcerne) {
const serviceAssociations = await getServiceSignaturesByUser(ctx.user.id); const serviceAssociations3 = await getServiceSignaturesByUser(ctx.user.id);
const assoc = serviceAssociations.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase()); const assoc3 = serviceAssociations3.find(a => a.serviceName.toLowerCase() === (entry.serviceConcerne || '').toLowerCase());
if (assoc) { if (assoc3) {
const sig = await getSignatureById(assoc.signatureId); const sig3 = await getSignatureById(assoc3.signatureId);
if (sig) { if (sig3) {
signatureName = `${sig.firstName} ${sig.lastName}`; signatureName3 = `${sig3.firstName} ${sig3.lastName}`;
try { try {
let sigImageBytes: Buffer; const sigImagePath3 = path.join(STORAGE_BASE_PATH, sig3.imageKey);
const sigImagePath = path.join(STORAGE_BASE_PATH, sig.imageKey); try { sigBytesForCartouche3 = await fs.readFile(sigImagePath3); } catch (_) {
try { sigImageBytes = await fs.readFile(sigImagePath); } catch (_) { const sigUrl3 = sig3.imageUrl;
const sigUrl = sig.imageUrl; if (!sigUrl3) throw new Error('Image signature introuvable');
if (!sigUrl) throw new Error('Image signature introuvable'); let absoluteSigUrl3 = sigUrl3;
let absoluteSigUrl = sigUrl; if (sigUrl3.startsWith('/')) {
if (sigUrl.startsWith('/')) {
const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`; const baseUrl = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
absoluteSigUrl = `${baseUrl}${sigUrl}`; absoluteSigUrl3 = `${baseUrl}${sigUrl3}`;
} }
const sigResp = await fetch(absoluteSigUrl); const sigResp3 = await fetch(absoluteSigUrl3);
if (!sigResp.ok) throw new Error('Impossible de télécharger la signature'); if (!sigResp3.ok) throw new Error('Impossible de télécharger la signature');
sigImageBytes = Buffer.from(await sigResp.arrayBuffer()); sigBytesForCartouche3 = Buffer.from(await sigResp3.arrayBuffer());
} }
const mimeType = sig.imageKey.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; sigMimeForCartouche3 = sig3.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) });
} catch (_) { /* ignore */ } } 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 signedPdfBytes = await pdfDoc.save();
const filename = path.basename(invoice.fileKey || `invoice_${invoice.id}.pdf`); const filename = path.basename(invoice.fileKey || `invoice_${invoice.id}.pdf`);
const bapFilename = `BAP_${Date.now()}_${filename}`; const bapFilename = `BAP_${Date.now()}_${filename}`;