311 lines
14 KiB
JavaScript
311 lines
14 KiB
JavaScript
/**
|
|
* routes/export.js
|
|
* Exports PDF, PPTX, XLSX, DOCX par vue
|
|
*
|
|
* PDF → tous les rôles authentifiés
|
|
* PPTX / XLSX / DOCX → SuperAdmin uniquement (vérifié ici)
|
|
*/
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
const { getMarches } = require('../services/baserow');
|
|
const {
|
|
isCloture, normalizeMarche, parseNum, formatMontant,
|
|
getDelaiRestant, niveauAlerte, niveauAvancement, niveauRisque,
|
|
DELAI_CRITIQUE, DELAI_ATTENTION, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT,
|
|
} = require('../services/calc');
|
|
|
|
const pdfGen = require('../services/export-pdf');
|
|
const { generateXlsx } = require('../services/export-xlsx');
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function applyFilters(rows, req) {
|
|
const { region, entrepreneur, projet, nature, statut } = req.query;
|
|
const regionFilter = req.regionFilter;
|
|
let r = rows;
|
|
if (regionFilter) r = r.filter(x => x.region === regionFilter);
|
|
else if (region) r = r.filter(x => x.region === region);
|
|
if (entrepreneur) r = r.filter(x => String(x.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
|
|
if (projet) r = r.filter(x => String(x.projet || '').toLowerCase().includes(projet.toLowerCase()));
|
|
if (nature) r = r.filter(x => String(x.nature || '').toLowerCase().includes(nature.toLowerCase()));
|
|
if (statut) r = r.filter(x => String(x.statut || '').toLowerCase().includes(statut.toLowerCase()));
|
|
return r;
|
|
}
|
|
|
|
async function buildViewData(view, req) {
|
|
const allRows = await getMarches();
|
|
let rows = applyFilters(allRows, req);
|
|
|
|
const actifs = rows.filter(r => !isCloture(r));
|
|
const clotures = rows.filter(r => isCloture(r));
|
|
|
|
switch (view) {
|
|
case 'synthese': {
|
|
const tauxList = actifs.map(r => parseNum(r.taux_phy ?? r.avt_phy)).filter(v => v > 0);
|
|
const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a,b)=>a+b,0)/tauxList.length*10)/10 : 0;
|
|
const totalBudget = actifs.reduce((s,r) => s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
|
|
const parStatut = {};
|
|
for (const r of rows) { const s=String(r.statut||'Inconnu'); parStatut[s]=(parStatut[s]||0)+1; }
|
|
const alertes = actifs
|
|
.map(r=>({...r,_d:getDelaiRestant(r)}))
|
|
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
|
|
.map(r=>({ref:r.ref||'',projet:r.projet||'',region:r.region||'',entrepreneur:r.entrepreneur||'',delai_restant:r._d,niveau:niveauAlerte(r._d)}))
|
|
.sort((a,b)=>a.delai_restant-b.delai_restant);
|
|
return {
|
|
total: rows.length, actifs: actifs.length, clotures: clotures.length,
|
|
taux_avancement_moyen: tauxMoyen, par_statut: parStatut,
|
|
budget: { total: formatMontant(totalBudget), total_raw: totalBudget },
|
|
alertes_delais: { count: alertes.length, critique: alertes.filter(a=>a.niveau==='critique').length, items: alertes },
|
|
};
|
|
}
|
|
case 'alertes': {
|
|
const items = actifs
|
|
.map(r=>({...r,_d:getDelaiRestant(r)}))
|
|
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
|
|
.map(r=>({...normalizeMarche(r),delai_restant:r._d,niveau:niveauAlerte(r._d),niveau_alerte:niveauAlerte(r._d)}))
|
|
.sort((a,b)=>a.delai_restant-b.delai_restant);
|
|
return { count: items.length, critique: items.filter(a=>a.niveau==='critique').length, items };
|
|
}
|
|
case 'en-service':
|
|
return { count: actifs.length, items: actifs.map(normalizeMarche) };
|
|
case 'en-cours': {
|
|
const enCours = actifs.filter(r=>parseNum(r.taux_phy??r.avt_phy)<100);
|
|
return { count: enCours.length, items: enCours.map(normalizeMarche) };
|
|
}
|
|
case 'par-region': {
|
|
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
|
const regions = ALL_REGIONS.map(reg => {
|
|
const regActifs = actifs.filter(r=>(r.region||'')=== reg);
|
|
const regTotal = rows.filter(r=>(r.region||'')=== reg);
|
|
const tauxList = regActifs.map(r=>parseNum(r.taux_phy??r.avt_phy)).filter(v=>v>0);
|
|
const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a,b)=>a+b,0)/tauxList.length*10)/10 : 0;
|
|
const budget = regActifs.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
|
|
const alertes = regActifs
|
|
.map(r=>({...r,_d:getDelaiRestant(r)}))
|
|
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION);
|
|
return { region: reg, actifs: regActifs.length, clotures: regTotal.length-regActifs.length, total: regTotal.length,
|
|
taux_moyen: tauxMoyen, budget: formatMontant(budget), alertes_count: alertes.length,
|
|
alertes_critique: alertes.filter(r=>r._d<=DELAI_CRITIQUE).length };
|
|
});
|
|
return { count: regions.length, regions };
|
|
}
|
|
case 'clotures': {
|
|
const totalBudget = clotures.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
|
|
return { count: clotures.length, budget_total: formatMontant(totalBudget), items: clotures.map(normalizeMarche) };
|
|
}
|
|
case 'pilotage': {
|
|
const items = actifs.map(r=>{
|
|
const d = getDelaiRestant(r);
|
|
return {...normalizeMarche(r), delai_restant:d, niveau_alerte:niveauAlerte(d), niveau_avancement:niveauAvancement(r.taux_phy??r.avt_phy,r.nature)};
|
|
});
|
|
const normal = items.filter(r=>r.niveau_avancement==='normal');
|
|
const sous = items.filter(r=>r.niveau_avancement==='sous_avancement');
|
|
const dep = items.filter(r=>r.niveau_avancement==='dépassé');
|
|
return { resume:{total:items.length,normal:normal.length,sous_avancement:sous.length,depasse:dep.length},
|
|
normal, sous_avancement:sous, depasse:dep, items };
|
|
}
|
|
case 'matrice-risque': {
|
|
const items = actifs.map(r=>{
|
|
const d=getDelaiRestant(r);
|
|
return {...normalizeMarche(r),delai_restant:d,niveau_alerte:niveauAlerte(d),niveau_risque:niveauRisque(r),
|
|
score_delai: d===null?1:d<=DELAI_CRITIQUE?3:d<=DELAI_ATTENTION?2:1,
|
|
score_avancement: parseNum(r.taux_phy??r.avt_phy)>=SEUIL_CRITIQUE_PCT?3:parseNum(r.taux_phy??r.avt_phy)>=SEUIL_STANDARD?2:1,
|
|
};
|
|
});
|
|
const pn={critique:0,élevé:0,moyen:0,faible:0};
|
|
for(const i of items){ if(pn[i.niveau_risque]!==undefined) pn[i.niveau_risque]++; else pn[i.niveau_risque]=1; }
|
|
return { total:items.length, par_niveau:pn, items };
|
|
}
|
|
default:
|
|
return { items: actifs.map(normalizeMarche) };
|
|
}
|
|
}
|
|
|
|
// ─── Route PDF ────────────────────────────────────────────────────────────────
|
|
|
|
router.get('/pdf', async (req, res) => {
|
|
try {
|
|
const view = req.query.view || 'synthese';
|
|
const data = await buildViewData(view, req);
|
|
|
|
let buf;
|
|
switch (view) {
|
|
case 'synthese': buf = await pdfGen.generateSynthese(data); break;
|
|
case 'alertes': buf = await pdfGen.generateAlertes(data); break;
|
|
case 'en-service': buf = await pdfGen.generateEnService(data); break;
|
|
case 'en-cours': buf = await pdfGen.generateEnCours(data); break;
|
|
case 'par-region': buf = await pdfGen.generateParRegion(data); break;
|
|
case 'clotures': buf = await pdfGen.generateClotures(data); break;
|
|
case 'pilotage': buf = await pdfGen.generatePilotage(data); break;
|
|
case 'matrice-risque': buf = await pdfGen.generateMatriceRisque(data); break;
|
|
default: buf = await pdfGen.generateGeneric(view, data); break;
|
|
}
|
|
|
|
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pdf`;
|
|
res.set({
|
|
'Content-Type': 'application/pdf',
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
'Content-Length': buf.length,
|
|
});
|
|
res.end(buf);
|
|
} catch (err) {
|
|
res.status(502).json({ error: 'Erreur génération PDF', detail: err.message });
|
|
}
|
|
});
|
|
|
|
// ─── Route XLSX (SuperAdmin) ──────────────────────────────────────────────────
|
|
|
|
router.get('/xlsx', async (req, res) => {
|
|
if (req.user?.role !== 'superadmin') {
|
|
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
|
}
|
|
try {
|
|
const view = req.query.view || 'synthese';
|
|
const data = await buildViewData(view, req);
|
|
const buf = await generateXlsx(view, data);
|
|
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.xlsx`;
|
|
res.set({
|
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
});
|
|
res.end(buf);
|
|
} catch (err) {
|
|
res.status(502).json({ error: 'Erreur génération XLSX', detail: err.message });
|
|
}
|
|
});
|
|
|
|
// ─── Route PPTX (SuperAdmin) ──────────────────────────────────────────────────
|
|
|
|
router.get('/pptx', async (req, res) => {
|
|
if (req.user?.role !== 'superadmin') {
|
|
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
|
}
|
|
try {
|
|
const PptxGenJS = require('pptxgenjs');
|
|
const view = req.query.view || 'synthese';
|
|
const data = await buildViewData(view, req);
|
|
|
|
const pptx = new PptxGenJS();
|
|
pptx.layout = 'LAYOUT_WIDE';
|
|
pptx.author = 'RLA API';
|
|
pptx.company = 'Tunisie Telecom Zone Sud';
|
|
pptx.subject = `RLA — ${view}`;
|
|
|
|
// Slide de titre
|
|
const slide1 = pptx.addSlide();
|
|
slide1.background = { color: '002D62' };
|
|
slide1.addText(`RLA — ${view.toUpperCase()}`, {
|
|
x: 0.5, y: 2, w: '90%', h: 1.2,
|
|
fontSize: 36, bold: true, color: 'FFFFFF', align: 'center',
|
|
});
|
|
slide1.addText('Marchés Tunisie Telecom Zone Sud', {
|
|
x: 0.5, y: 3.4, w: '90%', h: 0.5,
|
|
fontSize: 16, color: 'B3C5E0', align: 'center',
|
|
});
|
|
slide1.addText(new Date().toLocaleDateString('fr-FR'), {
|
|
x: 0.5, y: 4, w: '90%', h: 0.4,
|
|
fontSize: 12, color: 'E31837', align: 'center',
|
|
});
|
|
|
|
// Slide données
|
|
const slide2 = pptx.addSlide();
|
|
slide2.addText(`Données — ${view}`, {
|
|
x: 0.3, y: 0.2, w: '95%', h: 0.5,
|
|
fontSize: 18, bold: true, color: '002D62',
|
|
});
|
|
|
|
// Table si items
|
|
const items = data.items || data.regions || [];
|
|
if (items.length) {
|
|
const sample = items[0];
|
|
const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7);
|
|
const tableData = [
|
|
keys.map(k => ({ text: k, options: { bold: true, color: 'FFFFFF', fill: '002D62' } })),
|
|
...items.slice(0, 20).map(item =>
|
|
keys.map(k => ({ text: String(item[k] ?? '—') }))
|
|
),
|
|
];
|
|
slide2.addTable(tableData, {
|
|
x: 0.3, y: 0.9, w: 9.4,
|
|
fontSize: 9,
|
|
border: { type: 'solid', color: 'E2E8F0' },
|
|
colW: keys.map(() => +(9.4 / keys.length).toFixed(2)),
|
|
});
|
|
}
|
|
|
|
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pptx`;
|
|
const buf = await pptx.write({ outputType: 'nodebuffer' });
|
|
res.set({
|
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
});
|
|
res.end(buf);
|
|
} catch (err) {
|
|
res.status(502).json({ error: 'Erreur génération PPTX', detail: err.message });
|
|
}
|
|
});
|
|
|
|
// ─── Route DOCX (SuperAdmin) ──────────────────────────────────────────────────
|
|
|
|
router.get('/docx', async (req, res) => {
|
|
if (req.user?.role !== 'superadmin') {
|
|
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
|
}
|
|
try {
|
|
const { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, HeadingLevel, AlignmentType, WidthType, BorderStyle } = require('docx');
|
|
const view = req.query.view || 'synthese';
|
|
const data = await buildViewData(view, req);
|
|
const items = data.items || data.regions || [];
|
|
|
|
const children = [
|
|
new Paragraph({
|
|
text: `RLA — ${view.toUpperCase()}`,
|
|
heading: HeadingLevel.HEADING_1,
|
|
}),
|
|
new Paragraph({
|
|
text: `Marchés Tunisie Telecom Zone Sud — Édité le ${new Date().toLocaleDateString('fr-FR')}`,
|
|
children: [new TextRun({ text: '', break: 1 })],
|
|
}),
|
|
];
|
|
|
|
if (items.length) {
|
|
const sample = items[0];
|
|
const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7);
|
|
|
|
const tableRows = [
|
|
new TableRow({
|
|
children: keys.map(k => new TableCell({
|
|
children: [new Paragraph({ children: [new TextRun({ text: k, bold: true, color: 'FFFFFF' })], alignment: AlignmentType.CENTER })],
|
|
shading: { fill: '002D62' },
|
|
})),
|
|
}),
|
|
...items.slice(0, 50).map((item, i) => new TableRow({
|
|
children: keys.map(k => new TableCell({
|
|
children: [new Paragraph(String(item[k] ?? '—'))],
|
|
shading: i % 2 === 1 ? { fill: 'F1F5F9' } : undefined,
|
|
})),
|
|
})),
|
|
];
|
|
|
|
children.push(new Table({
|
|
rows: tableRows,
|
|
width: { size: 100, type: WidthType.PERCENTAGE },
|
|
}));
|
|
}
|
|
|
|
const doc = new Document({ sections: [{ children }] });
|
|
const buf = await Packer.toBuffer(doc);
|
|
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.docx`;
|
|
res.set({
|
|
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
|
});
|
|
res.end(buf);
|
|
} catch (err) {
|
|
res.status(502).json({ error: 'Erreur génération DOCX', detail: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|