Gestion-des-Marches-RLA/services/export-xlsx.js

435 lines
16 KiB
JavaScript

/**
* services/export-xlsx.js
* Génération XLSX comprehensive — Situation des Marchés RLA Zone Sud
*/
const ExcelJS = require('exceljs');
const { buildRef } = require('./calc');
const C = {
NAVY: 'FF002D62',
WHITE: 'FFFFFFFF',
ACCENT: 'FF00D4FF',
GREEN: 'FF16A34A',
ORANGE: 'FFEA580C',
RED: 'FFDC2626',
YELLOW: 'FFEAB308',
GRAY: 'FF64748B',
ALT: 'FFF1F5F9',
LIGHT: 'FFE2E8F0',
CAPEX: 'FFD1FAE5',
OPEX: 'FFFEF3C7',
TOTAL: 'FFDBEAFE',
HEADER: 'FF0F172A',
};
function fill(argb) { return { type: 'pattern', pattern: 'solid', fgColor: { argb } }; }
function font(argb, bold = false, size = 10) { return { color: { argb }, bold, size }; }
function border(color = C.LIGHT) {
const s = { style: 'thin', color: { argb: color } };
return { top: s, bottom: s, left: s, right: s };
}
const ALL_REGIONS = ['Gabes', 'Gafsa', 'Kebili', 'Medenine', 'Sfax', 'Tataouine', 'Tozeur'];
function parseNum(v) {
if (v === null || v === undefined || v === '') return 0;
if (typeof v === 'object') return 0;
const n = parseFloat(String(v).replace(/\s/g, '').replace(',', '.'));
return isNaN(n) ? 0 : n;
}
function selectVal(v) {
if (!v) return '';
if (typeof v === 'object' && v.value !== undefined) return String(v.value);
if (Array.isArray(v)) return v.map(x => x.value !== undefined ? x.value : x).join(', ');
return String(v);
}
function parseDateFR(d) {
if (!d) return null;
const parts = String(d).split(/[\/\-]/);
if (parts.length === 3) {
const [a, b, c] = parts;
if (a.length === 4) return new Date(`${a}-${b}-${c}`);
if (c.length === 4) return new Date(`${c}-${b}-${a}`);
}
const dt = new Date(d);
return isNaN(dt.getTime()) ? null : dt;
}
function fmtDate(d) {
const dt = parseDateFR(d);
if (!dt) return '-';
return dt.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function fmtMDT(val) {
const n = parseNum(val);
if (n === 0) return '0';
if (n >= 1000000) return `${(n / 1000000).toFixed(1)} MDT`;
if (n >= 1000) return `${(n / 1000).toFixed(0)} kDT`;
return `${n.toFixed(0)} DT`;
}
function isCloture(r) {
const obs = selectVal(r.observation).toLowerCase();
return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture;
}
function getDelai(r) {
const dField = r.delai_restant;
if (dField !== null && dField !== undefined && dField !== '') {
const v = parseInt(String(dField), 10);
if (!isNaN(v)) return v;
}
const fin = r.date_fin || r.date_fin_marche || r.datefin;
const dt = parseDateFR(fin);
if (!dt) return '-';
return Math.ceil((dt - new Date()) / 86400000);
}
async function generateXlsx(view, data, allRows) {
const wb = new ExcelJS.Workbook();
wb.creator = 'RLA API';
wb.company = 'Tunisie Telecom Zone Sud';
wb.created = new Date();
const rows = allRows || data.items || data.regions || [];
const actifs = allRows ? rows.filter(r => !isCloture(r)) : rows;
await buildSheet1(wb, actifs);
await buildSheet2(wb, actifs);
return wb.xlsx.writeBuffer();
}
async function buildSheet1(wb, actifs) {
const ws = wb.addWorksheet('Situation des Marchés');
ws.views = [{ state: 'frozen', ySplit: 9 }];
// Column widths
const cols = [
{ width: 40 }, { width: 20 }, { width: 22 }, { width: 12 },
{ width: 16 }, { width: 14 }, { width: 8 }, { width: 14 },
{ width: 8 }, { width: 14 }, { width: 14 }, { width: 8 }, { width: 22 },
];
ws.columns = cols.map((c, i) => ({ key: String.fromCharCode(65 + i), width: c.width }));
const today = new Date().toLocaleDateString('fr-FR');
const capex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('CAPEX') ||
!String(selectVal(r.nature)).toUpperCase().includes('OPEX'));
const opex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('OPEX'));
const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
const avgPhy = (() => {
const vals = actifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0);
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
})();
// Row 1: Title
const r1 = ws.addRow(['📊 SITUATION DES MARCHÉS RLA — ZONE SUD', ...Array(12).fill('')]);
r1.height = 30;
ws.mergeCells('A1:M1');
const c1 = r1.getCell(1);
c1.fill = fill(C.NAVY);
c1.font = { color: { argb: C.WHITE }, bold: true, size: 16 };
c1.alignment = { horizontal: 'center', vertical: 'middle' };
// Row 2: Subtitle
const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(12).fill('')]);
r2.height = 18;
ws.mergeCells('A2:M2');
r2.getCell(1).fill = fill('FF0F172A');
r2.getCell(1).font = font('FFCBD5E1', false, 11);
r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
// Row 3: Stats bar
const r3 = ws.addRow([
`📅 ${today} │ 📋 ${actifs.length} marchés │ 💰 ${fmtMDT(totalBudget)} │ 📈 Phy moy: ${avgPhy.toFixed(0)}%`,
...Array(12).fill(''),
]);
r3.height = 16;
ws.mergeCells('A3:M3');
r3.getCell(1).fill = fill('FF1E3A5F');
r3.getCell(1).font = font('FF94A3B8', false, 10);
r3.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
// Row 4: empty
ws.addRow([]);
// Row 5: KPI headers
const r5 = ws.addRow(['📊 GLOBAL', '', '', '', '', '🟢 CAPEX', '', '', '', '', '🟠 OPEX', '', '']);
r5.height = 22;
ws.mergeCells('A5:E5'); ws.mergeCells('F5:J5'); ws.mergeCells('K5:M5');
const kpiHdrStyle = (cell, argb) => {
cell.fill = fill(argb);
cell.font = { color: { argb: C.WHITE }, bold: true, size: 11 };
cell.alignment = { horizontal: 'center', vertical: 'middle' };
};
kpiHdrStyle(r5.getCell(1), C.NAVY);
kpiHdrStyle(r5.getCell(6), 'FF16A34A');
kpiHdrStyle(r5.getCell(11), C.ORANGE);
const capexBudget = capex.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
const opexBudget = opex.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
const capexPhy = (() => { const v = capex.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; })();
const opexPhy = (() => { const v = opex.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; })();
// Row 6: KPI values
const r6 = ws.addRow([`${actifs.length} marchés`, '', '', '', '',
`${capex.length} marchés`, '', '', '', '', `${opex.length} marchés`, '', '']);
r6.height = 18;
ws.mergeCells('A6:E6'); ws.mergeCells('F6:J6'); ws.mergeCells('K6:M6');
[1, 6, 11].forEach(col => {
r6.getCell(col).font = { bold: true, size: 14, color: { argb: C.NAVY } };
r6.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
});
// Row 7: KPI details
const r7 = ws.addRow([
`Budget: ${fmtMDT(totalBudget)} • Phy moy: ${avgPhy.toFixed(0)}%`, '', '', '', '',
`Budget: ${fmtMDT(capexBudget)} • Phy: ${capexPhy.toFixed(0)}%`, '', '', '', '',
`Budget: ${fmtMDT(opexBudget)} • Phy: ${opexPhy.toFixed(0)}%`, '', '',
]);
r7.height = 16;
ws.mergeCells('A7:E7'); ws.mergeCells('F7:J7'); ws.mergeCells('K7:M7');
[1, 6, 11].forEach(col => {
r7.getCell(col).font = { size: 9, color: { argb: C.GRAY } };
r7.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
});
// Row 8: empty
ws.addRow([]);
// Row 9: Column headers
const HEADERS = ['Référence', 'Projet', 'Entrepreneur', 'Nature',
'Montant Marché', 'Av. Phy (DT)', 'Phy %', 'Av. Fin (DT)', 'Fin %',
'Début', 'Fin', 'Délai', 'Observation'];
const r9 = ws.addRow(HEADERS);
r9.height = 22;
r9.eachCell(cell => {
cell.fill = fill(C.HEADER);
cell.font = { color: { argb: C.WHITE }, bold: true, size: 10 };
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = border(C.NAVY);
});
// Per-region data
for (const region of ALL_REGIONS) {
const regRows = actifs.filter(r => (r.region || '') === region);
if (!regRows.length) continue;
// Region header row
const rh = ws.addRow([`📍 ${region}${regRows.length} marchés`, ...Array(12).fill('')]);
rh.height = 18;
ws.mergeCells(`A${rh.number}:M${rh.number}`);
rh.getCell(1).fill = fill('FF1E3A5F');
rh.getCell(1).font = { bold: true, size: 11, color: { argb: C.ACCENT } };
rh.getCell(1).alignment = { horizontal: 'left', vertical: 'middle', indent: 1 };
let subtotalBudget = 0, subtotalPhy = 0, subtotalFin = 0, phyCount = 0;
for (let i = 0; i < regRows.length; i++) {
const r = regRows[i];
const nat = selectVal(r.nature);
const isCapex = nat.toUpperCase().includes('CAPEX');
const budget = parseNum(r.tot_marche || r.totmarche || r.montant);
const phyDT = parseNum(r.avt_phy);
const phyPct = parseNum(r.taux_phy || r.avt_phy);
const finDT = parseNum(r.avt_fin);
const finPct = parseNum(r.taux_fin);
const delai = getDelai(r);
subtotalBudget += budget;
if (phyPct > 0) { subtotalPhy += phyPct; phyCount++; }
subtotalFin += finDT;
const rd = ws.addRow([
buildRef(r),
r.projet || '',
r.entrepreneur || '',
nat,
budget || '',
phyDT || '',
phyPct > 0 ? phyPct / 100 : '',
finDT || '',
finPct > 0 ? finPct / 100 : '',
fmtDate(r.date_debut || r.debut_marche),
fmtDate(r.date_fin || r.date_fin_marche),
delai,
selectVal(r.observation),
]);
rd.height = 15;
const altFill = i % 2 === 1 ? fill(C.ALT) : undefined;
const natFill = isCapex ? fill(C.CAPEX) : fill(C.OPEX);
rd.eachCell((cell, col) => {
if (altFill) cell.fill = altFill;
if (col === 4) cell.fill = natFill;
cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } };
cell.alignment = { vertical: 'middle' };
if ([5, 6, 8].includes(col)) cell.numFmt = '#,##0';
if ([7, 9].includes(col)) cell.numFmt = '0%';
if ([12].includes(col)) cell.alignment = { horizontal: 'center', vertical: 'middle' };
});
}
// Subtotal row
const avgPct = phyCount > 0 ? subtotalPhy / phyCount : 0;
const rst = ws.addRow([
`Sous-total ${region} (${regRows.length})`, '', '',
'', subtotalBudget, '', avgPct / 100, '', '',
'', '', '', '',
]);
rst.height = 16;
ws.mergeCells(`A${rst.number}:D${rst.number}`);
rst.eachCell(cell => {
cell.fill = fill('FF1E3A5F');
cell.font = { bold: true, size: 9, color: { argb: C.WHITE } };
cell.border = { top: { style: 'medium', color: { argb: C.NAVY } }, bottom: { style: 'medium', color: { argb: C.NAVY } } };
});
rst.getCell(5).numFmt = '#,##0';
rst.getCell(7).numFmt = '0%';
rst.getCell(1).alignment = { horizontal: 'right', vertical: 'middle' };
ws.addRow([]); // spacer
}
// Grand total row
const gt = ws.addRow([
`TOTAL ZONE SUD (${actifs.length} marchés)`, '', '', '',
actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0),
'', avgPhy / 100, '', '', '', '', '', '',
]);
ws.mergeCells(`A${gt.number}:D${gt.number}`);
gt.height = 22;
gt.eachCell(cell => {
cell.fill = fill(C.NAVY);
cell.font = { bold: true, size: 11, color: { argb: C.WHITE } };
cell.border = { top: { style: 'medium', color: { argb: C.ACCENT } } };
});
gt.getCell(5).numFmt = '#,##0';
gt.getCell(7).numFmt = '0%';
gt.getCell(1).alignment = { horizontal: 'right', vertical: 'middle' };
}
async function buildSheet2(wb, actifs) {
const ws = wb.addWorksheet('Estimation Évolution');
ws.views = [{ state: 'frozen', ySplit: 6 }];
const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD || 70);
function projection(r) {
const marche = parseNum(r.tot_marche || r.totmarche || r.montant);
const minDT = marche * (SEUIL_STD / 100);
const consomme = parseNum(r.avt_fin);
const debut = parseDateFR(r.date_debut || r.debut_marche);
const fin = parseDateFR(r.date_fin || r.date_fin_marche);
const now = new Date();
const elapsed = debut ? Math.max(0, (now.getFullYear()-debut.getFullYear())*12+(now.getMonth()-debut.getMonth())) : 0;
const total = (debut && fin) ? Math.max(1, (fin.getFullYear()-debut.getFullYear())*12+(fin.getMonth()-debut.getMonth())) : 0;
const parMois = elapsed > 0 ? consomme / elapsed : 0;
const projete = parMois * (total || elapsed);
let verdict;
if (projete > marche * 1.03) verdict = 'Avenant';
else if (projete >= minDT) verdict = 'Normal';
else verdict = 'Sous Min';
return { marche, minDT, consomme, parMois, projete, verdict };
}
ws.columns = [
{ width: 42 }, { width: 22 }, { width: 22 }, { width: 16 },
{ width: 14 }, { width: 14 }, { width: 14 }, { width: 14 }, { width: 18 },
];
const today = new Date().toLocaleDateString('fr-FR');
const COLS = 9;
// Row 1: Title
const r1 = ws.addRow(['ESTIMATION ÉVOLUTION — ZONE SUD', ...Array(COLS - 1).fill('')]);
r1.height = 30;
ws.mergeCells(`A1:I1`);
r1.getCell(1).fill = fill('FF6366F1');
r1.getCell(1).font = { color: { argb: C.WHITE }, bold: true, size: 16 };
r1.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(COLS - 1).fill('')]);
r2.height = 18;
ws.mergeCells('A2:I2');
r2.getCell(1).fill = fill('FF4F46E5');
r2.getCell(1).font = font(C.WHITE, false, 11);
r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
const r3 = ws.addRow([`📅 ${today} │ 📋 ${actifs.length} marchés`, ...Array(COLS - 1).fill('')]);
r3.height = 16;
ws.mergeCells('A3:I3');
r3.getCell(1).fill = fill('FF4338CA');
r3.getCell(1).font = font('FFFDE8EC', false, 10);
r3.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
ws.addRow([]);
// Column headers
const HEADERS2 = ['Référence','Projet','Entrepreneur','Marché DT','Min DT','Consommé DT','DT/Mois','Projeté DT','Résultat'];
const r5 = ws.addRow(HEADERS2);
r5.height = 22;
r5.eachCell(cell => {
cell.fill = fill('FF6366F1');
cell.font = { color: { argb: C.WHITE }, bold: true, size: 10 };
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = border(C.NAVY);
});
const VERDICT_COLOR = { Normal: C.GREEN, 'Sous Min': 'FFDC2626', Avenant: 'FFEA580C' };
for (let i = 0; i < actifs.length; i++) {
const r = actifs[i];
const p = projection(r);
const rd = ws.addRow([
buildRef(r),
r.projet || '',
r.entrepreneur || '',
p.marche || '',
p.minDT || '',
p.consomme || '',
p.parMois || '',
p.projete || '',
p.verdict,
]);
rd.height = 15;
if (i % 2 === 1) rd.eachCell(cell => { cell.fill = fill(C.ALT); });
[4, 5, 6, 7, 8].forEach(col => {
rd.getCell(col).numFmt = '#,##0';
rd.getCell(col).alignment = { horizontal: 'right', vertical: 'middle' };
});
rd.getCell(9).font = { color: { argb: VERDICT_COLOR[p.verdict] || C.GRAY }, bold: true };
rd.eachCell(cell => {
cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } };
if (!cell.alignment) cell.alignment = { vertical: 'middle' };
});
}
// Synthèse en bas
ws.addRow([]);
const nNormal = actifs.filter(r => projection(r).verdict === 'Normal').length;
const nSous = actifs.filter(r => projection(r).verdict === 'Sous Min').length;
const nAv = actifs.filter(r => projection(r).verdict === 'Avenant').length;
const rSum = ws.addRow([`✅ Normal: ${nNormal}`, '', '', `❌ Sous Min: ${nSous}`, '', '', `⚠️ Avenant: ${nAv}`, '', '']);
ws.mergeCells(`A${rSum.number}:C${rSum.number}`);
ws.mergeCells(`D${rSum.number}:F${rSum.number}`);
ws.mergeCells(`G${rSum.number}:I${rSum.number}`);
rSum.height = 20;
rSum.getCell(1).font = { color: { argb: C.GREEN }, bold: true, size: 10 };
rSum.getCell(4).font = { color: { argb: 'FFDC2626' }, bold: true, size: 10 };
rSum.getCell(7).font = { color: { argb: C.ORANGE }, bold: true, size: 10 };
[1, 4, 7].forEach(col => {
rSum.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
rSum.getCell(col).fill = fill(C.ALT);
});
}
module.exports = { generateXlsx };