Version_Chatbot
CI-CD NDF Complet / test-and-quality (push) Successful in 1m5s
CI-CD NDF Complet / deploy (push) Successful in 1m27s

This commit is contained in:
2026-05-19 15:17:34 +02:00
parent 3d5923c49a
commit eb6ba9f78e
11 changed files with 5372 additions and 2270 deletions
+227 -131
View File
@@ -1,20 +1,9 @@
// ══════════════════════════════════════════════════════════════════════════════
// ndfPdfGenerator.js — v4 (pdfkit pur, 0% Python)
// Génère la fiche Note de Frais au format exact du modèle ENSUP
// avec signatures électroniques intégrées.
// v4 : Tarif km et Sous-total km intégrés dans le tableau après colonne Km
//
// Prérequis : pdfkit déjà installé (npm install pdfkit)
// Copier dans le même dossier que server.js.
// ndfPdfGenerator.js — v5 (pdfkit pur, support proratisation repas)
// ══════════════════════════════════════════════════════════════════════════════
import PDFDocument from 'pdfkit';
// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTES
// ─────────────────────────────────────────────────────────────────────────────
const C = {
blue: '#1B4F8A', header: '#2563EB',
totalBg: '#DBEAFE', altRow: '#EFF6FF',
@@ -27,14 +16,11 @@ const C = {
validBg: '#EEF2FF', validBorder: '#C7D2FE', validText: '#4F46E5',
refusBg: '#FEF2F2', refusBorder: '#FCA5A5', refusText: '#DC2626',
waitBg: '#F8FAFC', waitBorder: '#E2E8F0',
kmBg: '#F5F3FF', // fond violet clair pour colonne tarif km
kmBorder: '#DDD6FE', // bordure violet clair
kmText: '#7C3AED', // texte violet
kmTotalBg: '#EDE9FE', // fond sous-total km
kmBg: '#F5F3FF', kmBorder: '#DDD6FE', kmText: '#7C3AED', kmTotalBg: '#EDE9FE',
// ✅ Nouveaux — lignes proratisées
prorataBg: '#FFFBEB', prorataText: '#D97706', prorataBorder: '#FDE68A',
};
// Colonnes tableau (largeurs en points)
// ── v4 : 'tarifKm' et 'sousKm' insérées après 'km' ──
const COLS = [
{ key: 'num', label: 'N°pièce', w: 34, align: 'center' },
{ key: 'date', label: 'Date', w: 58, align: 'left' },
@@ -51,14 +37,13 @@ const COLS = [
{ key: 'ht', label: 'HT', w: 50, align: 'right' },
];
// Colonnes km (pour coloration spéciale)
const KM_COLS = ['km', 'tarifKm', 'sousKm'];
const MARGIN = 30;
const ROW_H = 16;
const HEAD_H = 20;
const PAGE_W = 841.89; // A4 largeur
const PAGE_H = 595.28; // A4 hauteur
const PAGE_W = 841.89;
const PAGE_H = 595.28;
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
@@ -89,11 +74,8 @@ function fmtDateTime(s) {
function drawRect(doc, x, y, w, h, fill, stroke = null, lw = 0.3) {
doc.save();
if (stroke) {
doc.lineWidth(lw).rect(x, y, w, h).fillAndStroke(fill, stroke);
} else {
doc.rect(x, y, w, h).fill(fill);
}
if (stroke) doc.lineWidth(lw).rect(x, y, w, h).fillAndStroke(fill, stroke);
else doc.rect(x, y, w, h).fill(fill);
doc.restore();
}
@@ -103,68 +85,130 @@ function drawCellText(doc, text, x, y, w, h, font, size, color, align, padX = 3)
doc.save().font(font).fontSize(size).fillColor(color);
while (text.length > 1 && doc.widthOfString(text) > maxW) text = text.slice(0, -1);
const ty = y + h * 0.28;
if (align === 'right') {
doc.text(text, x + padX, ty, { width: maxW, align: 'right', lineBreak: false });
} else if (align === 'center') {
doc.text(text, x + padX, ty, { width: maxW, align: 'center', lineBreak: false });
} else {
doc.text(text, x + padX, ty, { width: maxW, align: 'left', lineBreak: false });
}
if (align === 'right') doc.text(text, x + padX, ty, { width: maxW, align: 'right', lineBreak: false });
else if (align === 'center') doc.text(text, x + padX, ty, { width: maxW, align: 'center', lineBreak: false });
else doc.text(text, x + padX, ty, { width: maxW, align: 'left', lineBreak: false });
doc.restore();
}
function drawVLine(doc, x, y1, y2) {
doc.save().strokeColor(C.border).lineWidth(0.4)
.moveTo(x, y1).lineTo(x, y2).stroke().restore();
doc.save().strokeColor(C.border).lineWidth(0.4).moveTo(x, y1).lineTo(x, y2).stroke().restore();
}
function drawHLine(doc, x1, x2, y) {
doc.save().strokeColor(C.border).lineWidth(0.3)
.moveTo(x1, y).lineTo(x2, y).stroke().restore();
doc.save().strokeColor(C.border).lineWidth(0.3).moveTo(x1, y).lineTo(x2, y).stroke().restore();
}
// ─────────────────────────────────────────────────────────────────────────────
// preparerLignesPDF — convertit lignes formulaire → lignes PDF
// BARÈME KM fiscal (miroir de server.js)
// ─────────────────────────────────────────────────────────────────────────────
export function preparerLignesPDF(lignesParsed, tarifKm = TARIF_KM_DEFAULT) {
const BAREME_KM = {
3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 },
4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 },
5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 },
6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 },
7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 },
};
function getIndemniteKm(kmTotal, chevaux) {
const cv = Math.min(Math.max(parseInt(chevaux) || 7, 3), 7);
const b = BAREME_KM[cv];
if (!b || kmTotal <= 0) return 0;
if (kmTotal <= 5000) return parseFloat((kmTotal * b.t1).toFixed(2));
if (kmTotal <= 20000) return parseFloat((kmTotal * b.t2_a + b.t2_b).toFixed(2));
return parseFloat((kmTotal * b.t3).toFixed(2));
}
// ─────────────────────────────────────────────────────────────────────────────
// preparerLignesPDF — v5 : gère montantAjuste + barème CV fiscal
// ─────────────────────────────────────────────────────────────────────────────
export function preparerLignesPDF(lignesParsed, tarifKm = 0.697) {
console.log('🔍 preparerLignesPDF reçoit:', JSON.stringify(lignesParsed, null, 2));
return (lignesParsed || []).map((l, idx) => {
const isKm = (l.categorie || '').toLowerCase().includes('kilom');
const km = parseFloat(l.km) || 0;
const ttc = isKm ? 0 : (parseFloat(l.montant) || 0);
const taux = parseFloat(l.tauxTVA) || 0;
let ht = ttc, tva21 = 0, tva55 = 0, tva10 = 0, tva20 = 0;
const km = parseFloat(l.km) || 0;
const cv = parseInt(l.chevaux) || 7;
if (!isKm && taux > 0 && ttc > 0) {
ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2));
const tvaM = parseFloat((ttc - ht).toFixed(2));
if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM;
else if (Math.abs(taux - 5.5) < 0.01) tva55 = tvaM;
else if (Math.abs(taux - 10) < 0.01) tva10 = tvaM;
else if (Math.abs(taux - 20) < 0.01) tva20 = tvaM;
const indemniteKm = isKm ? getIndemniteKm(km, cv) : 0;
const tarifKmAffiche = isKm ? (km > 0 ? parseFloat((indemniteKm / km).toFixed(3)) : tarifKm) : 0;
const montantAjuste = l.montantAjuste === true;
const montantOriginal = montantAjuste
? (parseFloat(l.montantOriginal) || 0)
: 0;
// ✅ Toutes les variables avec let
let ttc = 0, tva21 = 0, tva55 = 0, tva10 = 0, tva20 = 0, ht = 0;
if (!isKm) {
if (montantAjuste) {
// ✅ Ligne proratisée — recalcul depuis montant retenu
ttc = parseFloat(l.montant) || 0;
const taux = parseFloat(l.tauxTVA) || 0;
if (taux > 0 && ttc > 0) {
ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2));
const tvaM = parseFloat((ttc - ht).toFixed(2));
if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM;
else if (Math.abs(taux - 5.5) < 0.01) tva55 = tvaM;
else if (Math.abs(taux - 10 ) < 0.01) tva10 = tvaM;
else if (Math.abs(taux - 20 ) < 0.01) tva20 = tvaM;
} else {
ht = ttc;
}
} else if (Array.isArray(l.tvaItems) && l.tvaItems.length > 0) {
// ✅ Multi-TVA normal
for (const item of l.tvaItems) {
const itemTTC = parseFloat(item.montantTTC) || 0;
const itemHT = parseFloat(item.montantHT) || 0;
const itemTau = parseFloat(item.taux) || 0;
ttc += itemTTC;
ht += itemHT;
const tvaM = parseFloat((itemTTC - itemHT).toFixed(2));
if (Math.abs(itemTau - 2.1) < 0.01) tva21 += tvaM;
else if (Math.abs(itemTau - 5.5) < 0.01) tva55 += tvaM;
else if (Math.abs(itemTau - 10 ) < 0.01) tva10 += tvaM;
else if (Math.abs(itemTau - 20 ) < 0.01) tva20 += tvaM;
}
ttc = parseFloat(ttc.toFixed(2));
ht = parseFloat(ht.toFixed(2));
} else {
// ✅ Mono-TVA simple
ttc = parseFloat(l.montant) || 0;
const taux = parseFloat(l.tauxTVA) || 0;
if (taux > 0 && ttc > 0) {
ht = parseFloat((ttc / (1 + taux / 100)).toFixed(2));
const tvaM = parseFloat((ttc - ht).toFixed(2));
if (Math.abs(taux - 2.1) < 0.01) tva21 = tvaM;
else if (Math.abs(taux - 5.5) < 0.01) tva55 = tvaM;
else if (Math.abs(taux - 10 ) < 0.01) tva10 = tvaM;
else if (Math.abs(taux - 20 ) < 0.01) tva20 = tvaM;
} else {
ht = ttc;
}
}
}
const indemniteKm = isKm ? parseFloat((km * tarifKm).toFixed(2)) : 0;
return {
numPiece: idx + 1,
date: l.date,
nature: l.categorie || '',
libelle: l.libelle || '',
km: isKm ? km : 0,
tarifKmVal: isKm ? tarifKm : 0, // ← valeur numérique tarif
montantTTC: isKm ? 0 : ttc,
numPiece: idx + 1,
date: l.date,
nature: l.categorie || '',
libelle: l.libelle || '',
km: isKm ? km : 0,
tarifKmVal: isKm ? tarifKmAffiche : 0,
montantTTC: isKm ? 0 : ttc,
tva21, tva55, tva10, tva20,
montantHT: isKm ? 0 : ht,
montantHT: isKm ? 0 : ht,
indemniteKm,
montantAjuste,
montantOriginal,
};
});
}
// ─────────────────────────────────────────────────────────────────────────────
// generateFicheSignee — point d'entrée appelé depuis server.js
// generateFicheSignee
// ─────────────────────────────────────────────────────────────────────────────
export async function generateFicheSignee(note, signatures = []) {
const tarifKm = parseFloat(note.tarifKm) || TARIF_KM_DEFAULT;
const tarifKm = parseFloat(note.tarifKm) || 0.697;
let lignesPDF = [];
if (note.lignesJson) {
@@ -176,16 +220,20 @@ export async function generateFicheSignee(note, signatures = []) {
} else if (note.lignes && Array.isArray(note.lignes)) {
lignesPDF = preparerLignesPDF(note.lignes, tarifKm);
} else {
// Fallback ligne unique (rétrocompat)
const isKm = !!(note.km && parseFloat(note.km) > 0);
const km = isKm ? parseFloat(note.km) : 0;
const cv = parseInt(note.chevaux) || 7;
const indem = isKm ? getIndemniteKm(km, cv) : 0;
lignesPDF = [{
numPiece: 1, date: note.date, nature: note.categorie || '', libelle: note.libelle || '',
km: isKm ? km : 0,
tarifKmVal: isKm ? tarifKm : 0,
tarifKmVal: isKm && km > 0 ? parseFloat((indem / km).toFixed(3)) : tarifKm,
montantTTC: isKm ? 0 : parseFloat(note.montant || 0),
tva21: 0, tva55: 0, tva10: 0, tva20: 0,
montantHT: isKm ? 0 : parseFloat(note.montantHT || note.montant || 0),
indemniteKm: isKm ? parseFloat((km * tarifKm).toFixed(2)) : 0,
indemniteKm: indem,
montantAjuste: false, montantOriginal: 0,
}];
}
@@ -209,19 +257,17 @@ export async function generateFicheSignee(note, signatures = []) {
}
// ─────────────────────────────────────────────────────────────────────────────
// _buildPDF — génère le Buffer PDF
// _buildPDF
// ─────────────────────────────────────────────────────────────────────────────
function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, signatures }) {
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: 0,
size: 'A4', layout: 'landscape', margin: 0,
info: {
Title: `Note de Frais ${reference}`,
Author: `ENSUP — ${nomPrenom}`,
Subject: `NDF ${reference}`,
Creator: 'NDF ENSUP v4',
Creator: 'NDF ENSUP v5',
},
});
@@ -230,19 +276,19 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', e => reject(e));
// ── Positions X colonnes ─────────────────────────────────────
// Positions X colonnes
const colX = {};
let cx = MARGIN;
for (const col of COLS) { colX[col.key] = cx; cx += col.w; }
const tableW = cx - MARGIN;
// ── 2. BANDEAU ───────────────────────────────────────────────
// ── Bandeau titre ────────────────────────────────────────────
const bandY = 44;
drawRect(doc, MARGIN, bandY, tableW, 18, C.header);
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.white)
.text('NOTE DE FRAIS', MARGIN, bandY + 3.5, { width: tableW, align: 'center', lineBreak: false });
// ── 3. INFOS COLLAB ──────────────────────────────────────────
// ── Infos collaborateur ──────────────────────────────────────
const infoY = bandY + 23;
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
.text(`NOM : ${nomPrenom}`, MARGIN, infoY, { lineBreak: false });
@@ -254,33 +300,24 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
.text(`Mois : ${mois}`, MARGIN, infoY + 13, { lineBreak: false });
// ── 4. EN-TÊTE COLONNES ──────────────────────────────────────
// ── En-tête colonnes ─────────────────────────────────────────
const tableTop = infoY + 27;
// Fond de base
drawRect(doc, MARGIN, tableTop, tableW, HEAD_H, C.headerRow, C.border, 0.5);
// Fond spécial violet pour les 3 colonnes km dans l'en-tête
for (const key of KM_COLS) {
for (const key of KM_COLS)
drawRect(doc, colX[key], tableTop, COLS.find(c => c.key === key).w, HEAD_H, C.kmBg);
}
for (const col of COLS) {
const isKmCol = KM_COLS.includes(col.key);
drawCellText(
doc, col.label,
colX[col.key], tableTop, col.w, HEAD_H,
drawCellText(doc, col.label, colX[col.key], tableTop, col.w, HEAD_H,
'Helvetica-Bold', isKmCol ? 6.5 : 7,
isKmCol ? C.kmText : C.dark,
col.align
);
isKmCol ? C.kmText : C.dark, col.align);
drawVLine(doc, colX[col.key], tableTop, tableTop + HEAD_H);
}
drawVLine(doc, MARGIN + tableW, tableTop, tableTop + HEAD_H);
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop);
drawHLine(doc, MARGIN, MARGIN + tableW, tableTop + HEAD_H);
// ── 5. LIGNES DONNÉES ────────────────────────────────────────
// ── Lignes données ───────────────────────────────────────────
const MIN_ROWS = 18;
const totalRows = Math.max(MIN_ROWS, lignes.length);
let y = tableTop + HEAD_H;
@@ -288,10 +325,15 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
for (let i = 0; i < totalRows; i++) {
const lig = lignes[i] || null;
// Fond de ligne alterné
drawRect(doc, MARGIN, y, tableW, ROW_H, i % 2 === 1 ? C.altRow : C.white);
const isProrata = lig?.montantAjuste === true;
// Fond violet léger sur les 3 colonnes km (toutes lignes)
// Fond : prorata = amber pâle, sinon alternance
const rowBg = isProrata
? C.prorataBg
: (i % 2 === 1 ? C.altRow : C.white);
drawRect(doc, MARGIN, y, tableW, ROW_H, rowBg);
// Fond violet km
for (const key of KM_COLS) {
const col = COLS.find(c => c.key === key);
drawRect(doc, colX[key], y, col.w, ROW_H,
@@ -315,13 +357,16 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
totHT += ht;
totSousKm += sousKm;
// ✅ Libellé enrichi si proratisé : afficher montant original barré
let libelleAffiche = lig.libelle || '';
const r = {
num: String(lig.numPiece || i + 1),
date: fmtDate(lig.date),
nature: lig.nature || '',
lib: lig.libelle || '',
lib: libelleAffiche,
km: km > 0 ? f2(km) : '',
tarifKm: tarif > 0 ? f3(tarif) : '', // ex: 0.697
tarifKm: tarif > 0 ? f3(tarif) : '',
sousKm: sousKm > 0 ? f2(sousKm) : '',
ttc: f2(ttc),
tva21: f2(t21), tva55: f2(t55),
@@ -331,34 +376,45 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
for (const col of COLS) {
const isKmCol = KM_COLS.includes(col.key);
drawCellText(
doc, r[col.key],
// ✅ Couleur ambre pour montants proratisés
const textColor = isProrata && ['ttc', 'ht'].includes(col.key)
? C.prorataText
: isKmCol ? C.kmText : C.dark;
drawCellText(doc, r[col.key],
colX[col.key], y, col.w, ROW_H,
'Helvetica', 7,
isKmCol ? C.kmText : C.dark,
col.align
);
'Helvetica', 7, textColor, col.align);
}
// ✅ Indicateur proratisation — petit triangle orange en coin haut-gauche
if (isProrata) {
doc.save()
.fillColor(C.prorataText)
.moveTo(MARGIN, y)
.lineTo(MARGIN + 6, y)
.lineTo(MARGIN, y + 6)
.fill()
.restore();
}
} else {
// Ligne vide — zéros en gris sur colonnes numériques
// Ligne vide — zéros en gris
for (const col of COLS) {
if (['ttc', 'tva21', 'tva55', 'tva10', 'tva20', 'ht'].includes(col.key))
drawCellText(doc, '0.00', colX[col.key], y, col.w, ROW_H, 'Helvetica', 7, C.border, 'right');
drawCellText(doc, '0.00', colX[col.key], y, col.w, ROW_H,
'Helvetica', 7, C.border, 'right');
}
}
// Bordures ligne
drawHLine(doc, MARGIN, MARGIN + tableW, y + ROW_H);
for (const col of COLS) drawVLine(doc, colX[col.key], y, y + ROW_H);
drawVLine(doc, MARGIN + tableW, y, y + ROW_H);
y += ROW_H;
}
// ── 6. LIGNE TOTAL ───────────────────────────────────────────
// ── Ligne Total ──────────────────────────────────────────────
const totalY = y;
drawRect(doc, MARGIN, totalY, tableW, ROW_H + 2, C.totalBg, C.border, 0.5);
// Fond violet sur les colonnes km dans la ligne total
for (const key of KM_COLS) {
const col = COLS.find(c => c.key === key);
drawRect(doc, colX[key], totalY, col.w, ROW_H + 2, C.kmTotalBg);
@@ -369,7 +425,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
const totMap = {
km: totKm > 0 ? f2(totKm) : '',
tarifKm: '', // pas de somme de tarifs
tarifKm: '',
sousKm: totSousKm > 0 ? f2(totSousKm) : '',
ttc: f2(totTTC),
tva21: f2(totT21), tva55: f2(totT55),
@@ -381,45 +437,82 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
if (!totMap[col.key] && totMap[col.key] !== '0.00') continue;
if (totMap[col.key] === '') continue;
const isKmCol = KM_COLS.includes(col.key);
drawCellText(
doc, totMap[col.key],
drawCellText(doc, totMap[col.key],
colX[col.key], totalY, col.w, ROW_H + 2,
'Helvetica-Bold', 8,
isKmCol ? C.kmText : C.dark,
'right'
);
'Helvetica-Bold', 8, isKmCol ? C.kmText : C.dark, 'right');
}
// ── 7. ZONE BAS (simplifiée — les infos km sont dans le tableau) ──
// ── Zone bas ─────────────────────────────────────────────────
const footY = totalY + ROW_H + 12;
const montantR = parseFloat((totTTC + totSousKm).toFixed(2));
const bw = 64;
// Montant à rembourser (simplifié)
// ✅ Légende proratisation si au moins une ligne ajustée
const hasProrata = lignes.some(l => l?.montantAjuste === true);
if (hasProrata) {
const nbProrata = lignes.filter(l => l?.montantAjuste === true).length;
const montantOriginalTotal = lignes
.filter(l => l?.montantAjuste === true)
.reduce((s, l) => s + (parseFloat(l.montantOriginal) || 0), 0);
const economie = parseFloat((montantOriginalTotal - lignes
.filter(l => l?.montantAjuste === true)
.reduce((s, l) => s + (parseFloat(l.montantTTC) || 0), 0)).toFixed(2));
drawRect(doc, MARGIN, footY - 1, tableW * 0.6, 14, C.prorataBg, C.prorataBorder, 0.5);
doc.font('Helvetica').fontSize(6.5).fillColor(C.prorataText)
.text(
`${nbProrata} ligne${nbProrata > 1 ? 's' : ''} de repas plafonnée${nbProrata > 1 ? 's' : ''} à 25 €/pers. par la Finance — ` +
`Montant soumis : ${f2(montantOriginalTotal)} € → Retenu : ${f2(montantOriginalTotal - economie)}`,
MARGIN + 3, footY + 1.5, { lineBreak: false }
);
}
const labelOffsetY = hasProrata ? 16 : 0;
doc.font('Helvetica-Bold').fontSize(9).fillColor(C.dark)
.text('Montant total à rembourser', MARGIN, footY + 4, { lineBreak: false });
drawRect(doc, MARGIN + 180, footY, bw + 10, 18, C.amountBg, C.border, 0.5);
.text('Montant total à rembourser', MARGIN, footY + 4 + labelOffsetY, { lineBreak: false });
drawRect(doc, MARGIN + 180, footY + labelOffsetY, bw + 10, 18, C.amountBg, C.border, 0.5);
doc.font('Helvetica-Bold').fontSize(11).fillColor(C.blue)
.text(f2(montantR) + ' €', MARGIN + 182, footY + 3.5, { width: bw + 6, align: 'right', lineBreak: false });
.text(f2(montantR) + ' €',
MARGIN + 182, footY + 3.5 + labelOffsetY,
{ width: bw + 6, align: 'right', lineBreak: false });
// Rappel tarif utilisé (petit, discret)
doc.font('Helvetica').fontSize(6.5).fillColor(C.grey)
.text(`Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)}`,
MARGIN, footY + 24, { lineBreak: false });
.text(
`Tarif km appliqué : ${tarifKm.toFixed(3)} €/km — Sous-total km : ${f2(totSousKm)} € — TTC hors km : ${f2(totTTC)}`,
MARGIN, footY + 24 + labelOffsetY, { lineBreak: false }
);
// ── 8. SIGNATURES ─────────────────────────────────────────────
// ── Signatures ────────────────────────────────────────────────
const sigStartX = MARGIN + 310;
const sigW = (tableW - 313) / 2 - 4;
const sigH = 52;
const sigY = footY - 2;
const sigY = footY - 2 + labelOffsetY;
const sigCollab = signatures.find(s => s.niveau === 'COLLAB');
const sigManager = signatures.find(s => ['N1', 'N2'].includes(s.niveau));
const sigManager = signatures.find(s => ['N1', 'N2', 'VERIF'].includes(s.niveau));
_drawSigBox(doc, sigCollab, sigStartX, sigY, sigW, sigH, 'Date et signature Collaborateur', false);
_drawSigBox(doc, sigManager, sigStartX + sigW + 6, sigY, sigW, sigH, 'Date et signature', true);
// ✅ Afficher toutes les signatures (jusqu'à 3 : COLLAB, N1/N2, VERIF)
const sigsAffichees = ['COLLAB', 'N1', 'N2', 'VERIF']
.map(niv => signatures.find(s => s.niveau === niv))
.filter(Boolean)
.slice(0, 3);
// ── 9. PIED DE PAGE ───────────────────────────────────────────
const nbSigs = sigsAffichees.length;
const sigWAdj = nbSigs > 2 ? (tableW - 313) / 3 - 4 : sigW;
sigsAffichees.forEach((sig, idx) => {
const label = sig.niveau === 'COLLAB' ? 'Date et signature Collaborateur'
: sig.niveau === 'VERIF' ? 'Vérification Finance'
: `Date et signature Validateur ${sig.niveau}`;
_drawSigBox(doc, sig,
sigStartX + idx * (sigWAdj + 4),
sigY, sigWAdj, sigH,
label,
sig.niveau !== 'COLLAB');
});
// ── Pied de page ──────────────────────────────────────────────
doc.font('Helvetica').fontSize(6).fillColor(C.light)
.text(
`Réf. ${reference} — Généré le ${new Date().toLocaleDateString('fr-FR')} — NDF ENSUP Groupe — Document électronique`,
@@ -431,7 +524,7 @@ function _buildPDF({ reference, nomPrenom, mois, departement, lignes, tarifKm, s
}
// ─────────────────────────────────────────────────────────────────────────────
// _drawSigBox — boîte signature avec ou sans contenu
// _drawSigBox
// ─────────────────────────────────────────────────────────────────────────────
function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
let bg, border, accent, icon;
@@ -440,6 +533,8 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
const a = sig.action || '';
if (a === 'refuser' || a === 'refuse') {
bg = C.refusBg; border = C.refusBorder; accent = C.refusText; icon = '✗ REFUSÉ';
} else if (sig.niveau === 'VERIF') {
bg = '#FEFCE8'; border = '#FDE047'; accent = '#CA8A04'; icon = '✓ VÉRIFIÉ Finance';
} else if (isManager) {
bg = C.validBg; border = C.validBorder; accent = C.validText; icon = '✓ VALIDÉ';
} else {
@@ -451,7 +546,6 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
drawRect(doc, x, y, w, h, bg, border, 1);
// Label haut
doc.font('Helvetica-Bold').fontSize(6.5).fillColor(C.grey)
.text(label, x + 4, y + 4, { width: w - 8, lineBreak: false });
@@ -477,9 +571,11 @@ function _drawSigBox(doc, sig, x, y, w, h, label, isManager) {
doc.save().strokeColor(border).lineWidth(0.5)
.moveTo(x + 3, y + h - 9).lineTo(x + w - 3, y + h - 9).stroke().restore();
doc.font('Helvetica').fontSize(6).fillColor(C.grey)
.text('Signature — NDF ENSUP', x + 4, y + h - 7, { width: w - 8, align: 'center', lineBreak: false });
.text('Signature — NDF ENSUP', x + 4, y + h - 7,
{ width: w - 8, align: 'center', lineBreak: false });
} else {
doc.font('Helvetica').fontSize(8).fillColor(C.grey)
.text('En attente de signature', x + 4, y + h / 2 - 5, { width: w - 8, align: 'center', lineBreak: false });
.text('En attente de signature', x + 4, y + h / 2 - 5,
{ width: w - 8, align: 'center', lineBreak: false });
}
}
}
+1739 -883
View File
File diff suppressed because it is too large Load Diff