Compare commits
No commits in common. "main" and "v0" have entirely different histories.
|
|
@ -1,79 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls /Volumes/web/rla/*.env /Volumes/web/rla/.env* 2>/dev/null; ls /Volumes/web/rla/node_modules | grep -E \"puppeteer|pptxgen|xlsx|exceljs|docx|pdf\" 2>/dev/null)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(node -e \"require\\('./services/calc'\\); console.log\\('calc OK'\\)\" 2>&1)",
|
||||
"Bash(node -e \"require\\('./services/export-pdf'\\); console.log\\('export-pdf OK'\\)\" 2>&1)",
|
||||
"Bash(node -e \"\nrequire\\('dotenv'\\).config\\(\\);\n// Mock pour tester sans baserow\nconsole.log\\('Routes test...'\\);\nrequire\\('./routes/synthese'\\);\nrequire\\('./routes/alertes'\\);\nrequire\\('./routes/en-service'\\);\nrequire\\('./routes/en-cours'\\);\nrequire\\('./routes/par-region'\\);\nrequire\\('./routes/clotures'\\);\nrequire\\('./routes/pilotage'\\);\nrequire\\('./routes/matrice-risque'\\);\nrequire\\('./routes/export'\\);\nconsole.log\\('Toutes les routes chargées OK'\\);\n\")",
|
||||
"Bash(node -e \"\n// Test complet de chargement\nrequire\\('dotenv'\\).config\\(\\);\nconst exp = require\\('./services/export-pdf'\\);\nconst xl = require\\('./services/export-xlsx'\\);\nconsole.log\\('PDF service:', typeof exp.generateSynthese\\);\nconsole.log\\('XLSX service:', typeof xl.generateXlsx\\);\n\n// Test mock PDF\nconst buf = exp.generateSynthese\\({\n total:3, actifs:2, clotures:1,\n taux_avancement_moyen: 65,\n budget: { total:'1 200 000 DT', consomme:'800 000 DT', restant:'400 000 DT' },\n par_statut: { 'En cours': 2, 'Clôturé': 1 },\n alertes_delais: { count:1, critique:0, items:[{ ref:'M-001', projet:'Projet A', region:'Gabes', entrepreneur:'Ent X', delai_restant:60, niveau:'attention' }] }\n}\\);\nconsole.log\\('PDF synthese OK, size:', buf.length, 'bytes'\\);\n\")",
|
||||
"Bash(node -e \"\nconst pdf = require\\('./services/export-pdf'\\);\npdf.generateSynthese\\({\n total:5, actifs:4, clotures:1,\n taux_avancement_moyen: 68,\n budget: { total:'1 500 000 DT', consomme:'900 000 DT', restant:'600 000 DT' },\n par_statut: { 'En cours': 3, 'En service': 1, 'Clôturé': 1 },\n alertes_delais: { count:2, critique:1, items:[\n { ref:'M-001', projet:'Projet A', region:'Gabes', entrepreneur:'Ent X', delai_restant:30, niveau:'critique' },\n { ref:'M-002', projet:'Projet B', region:'Sfax', entrepreneur:'Ent Y', delai_restant:75, niveau:'attention' },\n ]}\n}\\).then\\(buf => {\n console.log\\('PDF généré OK:', buf.length, 'bytes'\\);\n require\\('fs'\\).writeFileSync\\('/tmp/test-rla.pdf', buf\\);\n console.log\\('Fichier écrit : /tmp/test-rla.pdf'\\);\n}\\).catch\\(e => console.error\\('ERREUR:', e.message\\)\\);\n\")",
|
||||
"Bash(curl -s -X POST http://localhost:3005/api/auth/login \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"username\":\"nabil\",\"password\":\"Rla2025Sfax\"}' | python3 -c \"\nimport sys,json; d=json.load\\(sys.stdin\\)\nif 'token' in d:\n import base64\n payload = d['token'].split\\('.'\\)[1]\n payload += '=' * \\(4 - len\\(payload\\) % 4\\)\n info = json.loads\\(base64.b64decode\\(payload\\)\\)\n print\\('Login OK — role:', info['role'], '| region:', info['region']\\)\nelse:\n print\\('ERREUR:', d\\)\n\")",
|
||||
"Bash(which sshpass:*)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {sudo docker ps | grep rla}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1\n\nexpect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {ls /usr/local/bin/docker /usr/bin/docker /var/packages/ContainerManager/usr/bin/docker 2>&1; which docker; echo PATH=\\\\$PATH}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"WebFetch(domain:baserow.bolbol.tn)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {curl -s -H 'Authorization: Token zJaDdkttN1gr6oPvd3cxfCXNwzvvwMMF' 'https://baserow.bolbol.tn/api/database/rows/table/856/?page=1&size=3&user_field_names=true'}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {curl -s -H 'Authorization: Token zJaDdkttN1gr6oPvd3cxfCXNwzvvwMMF' 'https://baserow.bolbol.tn/api/database/rows/table/872/?page=1&size=3&user_field_names=true'}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuYWJpbCIsImlkIjoxLCJ1c2VybmFtZSI6Im5hYmlsIiwicm9sZSI6InN1cGVyYWRtaW4iLCJyZWdpb24iOiJhbGwiLCJpYXQiOjE3NzMzNTg3MTksImV4cCI6MTc3MzM4NzUxOX0.A1XEUDQAiOOyl8R-rPO6qYl8W_aa2Os-oB23aNIu2vs\" __NEW_LINE_096044aa0eaba021__ echo \"=== /api/en-service ===\" curl -sk https://rla.bolbol.tn/api/en-service -H \"Authorization: Bearer $TOKEN\" 2)",
|
||||
"Bash(1 __NEW_LINE_096044aa0eaba021__ echo \"\" echo \"=== /api/marches \\(first 2000 chars\\) ===\" curl -sk https://rla.bolbol.tn/api/marches -H \"Authorization: Bearer $TOKEN\")",
|
||||
"Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJuYWJpbCIsImlkIjoxLCJ1c2VybmFtZSI6Im5hYmlsIiwicm9sZSI6InN1cGVyYWRtaW4iLCJyZWdpb24iOiJhbGwiLCJpYXQiOjE3NzMzNTg3MTksImV4cCI6MTc3MzM4NzUxOX0.A1XEUDQAiOOyl8R-rPO6qYl8W_aa2Os-oB23aNIu2vs\"\necho \"=== /api/marches ===\"\ncurl -sk https://rla.bolbol.tn/api/marches -H \"Authorization: Bearer $TOKEN\" 2>&1 | python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); items=d.get\\('items',[d]\\) if isinstance\\(d,dict\\) else d; print\\('Keys in response:', list\\(d.keys\\(\\)\\) if isinstance\\(d,dict\\) else 'array'\\); print\\('First item keys:', list\\(items[0].keys\\(\\)\\) if items else 'empty'\\)\" 2>&1\n\necho \"\"\necho \"=== /api/synthese ===\"\ncurl -sk https://rla.bolbol.tn/api/synthese -H \"Authorization: Bearer $TOKEN\" 2>&1 | python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2, ensure_ascii=False\\)[:3000]\\)\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {ls /app 2>/dev/null || /usr/local/bin/docker exec rla-api ls /app}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {/usr/local/bin/docker exec rla-api cat /app/server.js}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {/usr/local/bin/docker exec rla-api cat /app/routes/marches.js}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {/usr/local/bin/docker exec rla-api cat /app/routes/en-service.js}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {/usr/local/bin/docker exec rla-api ls /app/routes/ && /usr/local/bin/docker exec rla-api cat /app/services/baserow.js}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {/usr/local/bin/docker exec rla-api cat /app/services/calc.js}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {/usr/local/bin/docker exec rla-api cat /app/routes/synthese.js}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(expect -c \"\nspawn ssh -o StrictHostKeyChecking=no -p 22222 Best0f@192.168.100.33 {/usr/local/bin/docker exec rla-api cat /app/routes/pipeline.js}\nexpect \\\\\"password:\\\\\"\nsend \\\\\"2L2u519w@ommi\\\\r\\\\\"\nexpect eof\n\" 2>&1)",
|
||||
"Bash(sshpass -p '2L2u519w@ommi' ssh -p 22222 -o StrictHostKeyChecking=no Best0f@192.168.100.33 \\\\\n \"echo 'SSH OK'; docker inspect rla-api --format '{{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}'; find /volume1 -name 'server.js' -path '*rla*' 2>/dev/null | head -3\")",
|
||||
"Bash(expect -c \"\nspawn ssh -p 22222 -o StrictHostKeyChecking=no Best0f@192.168.100.33 {sudo docker inspect rla-api --format '{{range .Mounts}}{{.Source}}:{{.Destination}} {{end}}' 2>/dev/null; find /volume1 -name server.js 2>/dev/null | grep -i rla | head -5; ls /volume1/web/ 2>/dev/null}\nexpect {\n \\\\\"password\\\\\" { send \\\\\"2L2u519w\\\\@ommi\\\\r\\\\\"; exp_continue }\n eof { }\n timeout { exit 1 }\n}\n\" 2>/dev/null)",
|
||||
"Bash(curl -s -X POST http://192.168.100.33:9000/api/auth \\\\\n -H 'Content-Type: application/json' \\\\\n -d '{\"username\":\"bestof\",\"password\":\"2L2u519wportainer\"}' | python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('jwt','ERROR'\\)[:40]\\)\")",
|
||||
"Bash(curl -s http://192.168.100.33:3005/api/health | python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d\\)\")",
|
||||
"Bash(curl -s https://rla.bolbol.tn/api/health 2>/dev/null | python3 -c \"import sys,json; print\\(json.load\\(sys.stdin\\)\\)\" || echo \"Public URL check failed \\(may need VPN\\)\")",
|
||||
"Bash(ls -la \"/Volumes/web/rla/Fichiers-cibles/\" && file \"/Volumes/web/rla/Fichiers-cibles/\"*)",
|
||||
"Bash(ls /Volumes/web/rla/Fichiers-cibles/ 2>/dev/null && ls /Volumes/web/rla/services/export-*.js 2>/dev/null)",
|
||||
"Bash(cd /Volumes/web/rla/Fichiers-cibles && unzip -p \"Marches_RLA_2025_Zone_Sud_02_2026.xlsx\" xl/worksheets/sheet1.xml 2>/dev/null | python3 -c \"import sys; from xml.etree import ElementTree as ET; root=ET.parse\\(sys.stdin\\); [print\\(c.attrib\\) for c in root.findall\\('.//{http://schemas.openxmlformats.org/spreadsheetml/2006/main}c'\\)[:30]]\" 2>/dev/null || echo \"fail\")",
|
||||
"Bash(cd /Volumes/web/rla/Fichiers-cibles && unzip -l \"Marches_RLA_2025_Zone_Sud_02_2026.xlsx\" 2>/dev/null | head -20)",
|
||||
"Bash(python3 << 'EOF'\nimport zipfile, xml.etree.ElementTree as ET\n\nns = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'\n\nwith zipfile.ZipFile\\('/Volumes/web/rla/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx'\\) as z:\n strings = []\n with z.open\\('xl/sharedStrings.xml'\\) as f:\n tree = ET.parse\\(f\\)\n for si in tree.findall\\(f'{ns}si'\\):\n t = ''.join\\(e.text or '' for e in si.iter\\(f'{ns}t'\\)\\)\n strings.append\\(t\\)\n \n def get_row_values\\(row\\):\n vals = []\n for c in row:\n v = c.find\\(f'{ns}v'\\)\n if v is not None:\n if c.get\\('t'\\) == 's':\n vals.append\\(strings[int\\(v.text\\)]\\)\n else:\n vals.append\\(v.text\\)\n else:\n vals.append\\(''\\)\n return vals\n \n # Sheet 1 - first 5 rows\n print\\('=== Sheet 1: Situation des Marchés ==='\\)\n with z.open\\('xl/worksheets/sheet1.xml'\\) as f:\n tree = ET.parse\\(f\\)\n rows = tree.findall\\(f'.//{ns}row'\\)\n for row in rows[:6]:\n print\\(get_row_values\\(row\\)\\)\n \n # Sheet 2 - first 5 rows\n print\\('\\\\n=== Sheet 2: Estimation Évolution ==='\\)\n with z.open\\('xl/worksheets/sheet2.xml'\\) as f:\n tree = ET.parse\\(f\\)\n rows = tree.findall\\(f'.//{ns}row'\\)\n for row in rows[:6]:\n print\\(get_row_values\\(row\\)\\)\nEOF)",
|
||||
"Bash(python3 << 'EOF'\nimport zipfile, xml.etree.ElementTree as ET\n\nns = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'\n\nwith zipfile.ZipFile\\('/Volumes/web/rla/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx'\\) as z:\n strings = []\n with z.open\\('xl/sharedStrings.xml'\\) as f:\n tree = ET.parse\\(f\\)\n for si in tree.findall\\(f'{ns}si'\\):\n t = ''.join\\(e.text or '' for e in si.iter\\(f'{ns}t'\\)\\)\n strings.append\\(t\\)\n \n def get_row_values\\(row\\):\n vals = []\n for c in row:\n v = c.find\\(f'{ns}v'\\)\n if v is not None:\n if c.get\\('t'\\) == 's':\n vals.append\\(strings[int\\(v.text\\)]\\)\n else:\n vals.append\\(v.text\\)\n else:\n vals.append\\(''\\)\n return vals\n \n # Sheet 1 - all non-empty rows\n print\\('=== Sheet 1: Situation des Marchés ==='\\)\n with z.open\\('xl/worksheets/sheet1.xml'\\) as f:\n tree = ET.parse\\(f\\)\n rows = tree.findall\\(f'.//{ns}row'\\)\n for i, row in enumerate\\(rows[:60]\\):\n vals = get_row_values\\(row\\)\n non_empty = [v for v in vals if v]\n if non_empty:\n print\\(f\"Row {i+1}: {non_empty}\"\\)\nEOF)",
|
||||
"Bash(python3 << 'EOF'\nimport zipfile, xml.etree.ElementTree as ET\n\nns = '{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'\n\nwith zipfile.ZipFile\\('/Volumes/web/rla/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx'\\) as z:\n strings = []\n with z.open\\('xl/sharedStrings.xml'\\) as f:\n tree = ET.parse\\(f\\)\n for si in tree.findall\\(f'{ns}si'\\):\n t = ''.join\\(e.text or '' for e in si.iter\\(f'{ns}t'\\)\\)\n strings.append\\(t\\)\n \n def get_row_values\\(row\\):\n vals = []\n for c in row:\n v = c.find\\(f'{ns}v'\\)\n if v is not None:\n if c.get\\('t'\\) == 's':\n vals.append\\(strings[int\\(v.text\\)]\\)\n else:\n vals.append\\(v.text\\)\n else:\n vals.append\\(''\\)\n return vals\n \n # Sheet 2\n print\\('=== Sheet 2: Estimation Évolution ==='\\)\n with z.open\\('xl/worksheets/sheet2.xml'\\) as f:\n tree = ET.parse\\(f\\)\n rows = tree.findall\\(f'.//{ns}row'\\)\n for i, row in enumerate\\(rows[:40]\\):\n vals = get_row_values\\(row\\)\n non_empty = [v for v in vals if v]\n if non_empty:\n print\\(f\"Row {i+1}: {non_empty}\"\\)\nEOF)",
|
||||
"Bash(python3 << 'EOF'\nimport zipfile, xml.etree.ElementTree as ET\n\nwith zipfile.ZipFile\\('/Volumes/web/rla/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_02_2026.docx'\\) as z:\n print\\(\"Files:\", z.namelist\\(\\)[:15]\\)\n \n with z.open\\('word/document.xml'\\) as f:\n content = f.read\\(\\).decode\\('utf-8'\\)\n # Extract text paragraphs\n tree = ET.fromstring\\(content\\)\n ns = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'\n texts = []\n for p in tree.findall\\(f'.//{ns}p'\\)[:50]:\n text = ''.join\\(r.text or '' for r in p.findall\\(f'.//{ns}t'\\)\\)\n if text.strip\\(\\):\n texts.append\\(text.strip\\(\\)\\)\n for t in texts[:40]:\n print\\(t\\)\nEOF)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(curl -s -X POST \"http://192.168.100.33:9000/api/auth\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"username\":\"bestof\",\"password\":\"2L2u519wportainer\"}' | python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('jwt','ERROR:',d\\)\\)\")",
|
||||
"Bash(python3 -m json.tool)",
|
||||
"Bash(git remote:*)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(python3 -c \"import sys,json; commits=json.load\\(sys.stdin\\); [print\\(c['sha'][:10], c['commit']['message'][:70]\\) for c in commits]\")",
|
||||
"Bash(python3 -c \"import sys,json; data=json.load\\(sys.stdin\\); [print\\(b['name'], b['commit']['id'][:10]\\) for b in data]\")",
|
||||
"Bash(python3 -c \"import sys,json; commits=json.load\\(sys.stdin\\); [print\\(c['sha'][:10], c['commit']['message'][:80]\\) for c in commits]\")",
|
||||
"Bash(curl -s -X PATCH -H 'Authorization: token 0aad77091496c9fcfab53dc2d8be79a52c781d84' -H 'Content-Type: application/json' -d '{\"sha\":\"4168f74ded2c1b79a52f2a8b0e0c9e0c7e0a2b3c\",\"force\":true}' https://gitea.bolbol.tn/api/v1/repos/bolbol/reglement-definitif/branches/main)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('name','?'\\), d.get\\('commit',{}\\).get\\('id','?'\\)[:10] if 'commit' in d else d\\)\")",
|
||||
"Bash(curl *)",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d['commit']['id'][:10], d['commit']['message'][:70]\\)\")",
|
||||
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('jwt','ERREUR: ' + str\\(d\\)\\)\\)\")",
|
||||
"Bash(env)",
|
||||
"Bash(ssh *)",
|
||||
"Bash(sshpass *)",
|
||||
"Bash(brew install *)",
|
||||
"Bash(expect -v)",
|
||||
"Bash(expect *)",
|
||||
"Bash(mount)",
|
||||
"Bash(node -e \"const b=require\\('bcryptjs'||'bcrypt'\\);b.compare\\('rla2025','\\\\$2a\\\\$10\\\\$4nqlMxOSsDV99mJHvVQJzubVLCArz6fnkQ0RSTD9p1CwdGxwibHdq'\\).then\\(r=>console.log\\('rla2025 :',r\\)\\)\")",
|
||||
"Bash(node -e \"require\\('bcryptjs'\\).compare\\('rla2025','\\\\$2a\\\\$10\\\\$4nqlMxOSsDV99mJHvVQJzubVLCArz6fnkQ0RSTD9p1CwdGxwibHdq'\\).then\\(r=>console.log\\('rla2025 :',r\\)\\)\")",
|
||||
"Bash(python3 -c ' *)",
|
||||
"Bash(python3 -)",
|
||||
"Bash(pip3 install *)",
|
||||
"Bash(python3 -c \" from docx import Document from docx.shared import Pt, RGBColor, Cm import json doc = Document\\('Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_03_2026.docx'\\) print\\('=== SECTIONS ==='\\) for i, section in enumerate\\(doc.sections\\): print\\(f'Section {i}: page_w={section.page_width.cm:.1f}cm page_h={section.page_height.cm:.1f}cm margin_top={section.top_margin.cm:.1f}cm'\\) print\\(\\) print\\('=== PARAGRAPHS & TABLES ==='\\) from docx.oxml.ns import qn body = doc.element.body def rgb_from_hex\\(hex_str\\): if not hex_str: return None h = hex_str.lstrip\\('#'\\) if len\\(h\\) == 6: return tuple\\(int\\(h[i:i+2],16\\) for i in \\(0,2,4\\)\\) return None def get_cell_bg\\(cell\\): shd = cell._tc.find\\(qn\\('w:tcPr'\\)\\) if shd is None: return None s = cell._tc.find\\('.//' + qn\\('w:shd'\\)\\) if s is not None: return s.get\\(qn\\('w:fill'\\)\\) return None def get_para_bg\\(para\\): pPr = para._p.find\\(qn\\('w:pPr'\\)\\) if pPr is None: return None shd = pPr.find\\(qn\\('w:shd'\\)\\) if shd is not None: return shd.get\\(qn\\('w:fill'\\)\\) return None items = [] for child in body: tag = child.tag.split\\('}'\\)[-1] if '}' in child.tag else child.tag if tag == 'p': from docx.text.paragraph import Paragraph para = Paragraph\\(child, doc\\) text = para.text.strip\\(\\) bg = get_para_bg\\(para\\) style = para.style.name if para.style else '' if text or bg: font_sizes = [] font_bolds = [] font_colors = [] for run in para.runs: if run.font.size: font_sizes.append\\(run.font.size.pt\\) font_bolds.append\\(run.font.bold\\) if run.font.color and run.font.color.type is not None: try: font_colors.append\\(str\\(run.font.color.rgb\\)\\) except: pass items.append\\({ 'type': 'para', 'style': style, 'text': text[:100], 'bg': bg, 'sizes': font_sizes[:3], 'bolds': font_bolds[:3], 'colors': font_colors[:3], }\\) elif tag == 'tbl': from docx.table import Table tbl = Table\\(child, doc\\) rows_count = len\\(tbl.rows\\) cols_count = len\\(tbl.columns\\) first_row = [] first_row_bg = [] for cell in tbl.rows[0].cells: first_row.append\\(cell.text.strip\\(\\)[:40]\\) first_row_bg.append\\(get_cell_bg\\(cell\\)\\) items.append\\({ 'type': 'table', 'rows': rows_count, 'cols': cols_count, 'header': first_row, 'header_bg': first_row_bg, }\\) for item in items: print\\(json.dumps\\(item, ensure_ascii=False\\)\\) \")",
|
||||
"Bash(python3 -c \" import fitz doc = fitz.open\\('Fichiers-cibles/Marches_RLA_Marchés_En_Service___Raccordement___Medenine__Sfax_2026-03-12.pdf'\\) print\\(f'Pages: {doc.page_count}'\\) for i, page in enumerate\\(doc\\): print\\(f'--- Page {i+1} ---'\\) blocks = page.get_text\\('dict'\\) colors_seen = set\\(\\) for block in blocks['blocks']: if block.get\\('type'\\) == 0: for line in block['lines']: for span in line['spans']: c = span.get\\('color', 0\\) r = \\(c >> 16\\) & 0xFF g = \\(c >> 8\\) & 0xFF b = c & 0xFF hex_c = f'#{r:02X}{g:02X}{b:02X}' if hex_c != '#000000' and hex_c != '#FFFFFF': colors_seen.add\\(hex_c\\) txt = span['text'].strip\\(\\) if txt: print\\(f' size={span[\\\\\"size\\\\\"]:.1f} color={hex_c} font={span[\\\\\"font\\\\\"][:30]} text={txt[:60]}'\\) print\\(f' Colors used: {colors_seen}'\\) if i >= 1: break \")",
|
||||
"Bash(python3 *)",
|
||||
"Bash(node *)",
|
||||
"Read(//Users/nabil.derouiche/.ssh/**)",
|
||||
"Bash(ssh-keyscan -p 22222 192.168.100.33)",
|
||||
"Bash(/usr/local/bin/brew install *)",
|
||||
"Bash(/opt/homebrew/bin/brew install *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
41
CLAUDE.md
41
CLAUDE.md
|
|
@ -1,6 +1,6 @@
|
|||
# RLA API — Gestion des Marchés Tunisie Telecom Zone Sud
|
||||
**Stack cible** : Node.js Express + Baserow + Docker (NAS 192.168.100.33)
|
||||
**URL prod** : https://rla.bolbol.tn
|
||||
**URL prod** : https://nd.i234.me/rla
|
||||
**Fichiers existants** : index.html, config.js uniquement
|
||||
|
||||
## Règles strictes (abonnement Pro limité en tokens)
|
||||
|
|
@ -13,42 +13,3 @@
|
|||
## MCP disponibles
|
||||
- baserow : http://192.168.100.33 (données marchés RLA)
|
||||
- portainer : http://192.168.100.33:9000 (déploiement Docker)
|
||||
|
||||
## Architecture frontend (index.html)
|
||||
- Fichier unique SPA — HTML + CSS + JS inline
|
||||
- API REST : `/api/auth/login`, `/api/marches`, `/api/stats`, `/api/pilotage-proactif`, `/api/pipeline`, `/api/users`, `/api/logs`, `/api/export/:format`
|
||||
- JWT stocké en localStorage (`rla_jwt`)
|
||||
- Thème persisté en localStorage (`rla_theme`)
|
||||
- Rôles : `user` (lecture, régions restreintes) · `admin` (pipeline) · `superadmin` (users, logs, exports)
|
||||
|
||||
## Champs API normalisés via `normalizeMarche(r)`
|
||||
Appeler `normalizeMarche(r)` à la réception — ensuite utiliser uniquement :
|
||||
- `r.id_marche`, `r.region`, `r.taux_phy` (number), `r.tot_marche` (number), `r.date_debut`, `r.date_fin`, `r.observation` (string)
|
||||
|
||||
## Régions Zone Sud — source unique
|
||||
Défini dans `CONFIG.ALL_REGIONS` (config.js). Ne pas dupliquer dans le HTML.
|
||||
Accès via la variable globale `ALL_REGIONS` (initialisée depuis CONFIG).
|
||||
|
||||
## Changelog v4 — 2026-04-18
|
||||
### Bugs corrigés
|
||||
- `logo-TT.png` / `Nabil.Derouiche.jpg` absents → remplacés par `logo-RLA.svg` + avatar initiales
|
||||
- Thème par défaut : `loadTheme()` utilise désormais `CONFIG.DEFAULT_THEME` (plus d'incohérence avec config.js)
|
||||
- `showSlide()` : active uniquement les `btn-slide-N`, pas les boutons export (ancien bug d'index)
|
||||
- Filtre entrepreneur "En Service" : se réinitialise quand la région change (`onServiceRegionChange`)
|
||||
- Variable `const now` inutilisée supprimée
|
||||
|
||||
### Améliorations
|
||||
- **normalizeMarche()** : préprocesseur centralisé des champs API multi-noms
|
||||
- **ALL_REGIONS centralisé** dans config.js, injecté partout via `buildRegionOptions()`
|
||||
- **2 graphiques région** dans Vue Générale : avancement moyen + marchés actifs par région (bar charts)
|
||||
- **Taille de page configurable** : sélecteur 10/25/50/100 dans la pagination
|
||||
- **Icônes de tri actives** : fa-sort-up / fa-sort-down + classe CSS sur colonne active
|
||||
- **Filtres période** sur liste Marchés (date début / date fin) + bouton reset
|
||||
- **Filtre région + état** dans slide Pilotage Proactif
|
||||
- **Modal édition utilisateur** : modifier rôle, région, mot de passe (PATCH /api/users/:id)
|
||||
- **Toast de confirmation** pour suppression utilisateur (remplace `confirm()` natif)
|
||||
- **Gestion session expirée** : 401 → toast warning + déconnexion automatique après 1,5s
|
||||
- **Titre onglet dynamique** : `⚠️ N alertes — Marchés RLA` si alertes actives
|
||||
- **Toast unifié** (error / success / warning) remplace errorToast mono-usage
|
||||
- **Footer** : avatar initiales SVG remplace image absente
|
||||
- **Touche Escape** ferme le modal édition
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
17
config.js
17
config.js
|
|
@ -15,17 +15,14 @@ const CONFIG = {
|
|||
// Thème par défaut
|
||||
DEFAULT_THEME: 'light',
|
||||
|
||||
// Régions Zone Sud (source unique)
|
||||
ALL_REGIONS: ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'],
|
||||
|
||||
// Couleurs régions
|
||||
REGION_COLORS: {
|
||||
'Gabes': '#17A2B8',
|
||||
'Gafsa': '#22C55E',
|
||||
'Kebili': '#9333EA',
|
||||
'Medenine': '#0EA5E9',
|
||||
'Sfax': '#002855',
|
||||
'Gabes': '#17A2B8',
|
||||
'Gafsa': '#22C55E',
|
||||
'Kebili': '#9333EA',
|
||||
'Medenine': '#0EA5E9',
|
||||
'Sfax': '#002855',
|
||||
'Tataouine': '#14B8A6',
|
||||
'Tozeur': '#818CF8'
|
||||
'Tozeur': '#818CF8'
|
||||
}
|
||||
};
|
||||
};
|
||||
1502
index.html
1502
index.html
File diff suppressed because it is too large
Load Diff
24
logo-RLA.svg
24
logo-RLA.svg
|
|
@ -1,24 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Background -->
|
||||
<rect width="64" height="64" rx="14" fill="#0b2a55"/>
|
||||
|
||||
<!-- Signal arc outer left -->
|
||||
<path d="M14,20 Q5,32 14,44" stroke="#00d4ff" stroke-width="2.2" fill="none" stroke-linecap="round" opacity="0.55"/>
|
||||
<!-- Signal arc inner left -->
|
||||
<path d="M19,24 Q12,32 19,40" stroke="#00d4ff" stroke-width="2.6" fill="none" stroke-linecap="round" opacity="0.85"/>
|
||||
|
||||
<!-- Signal arc inner right -->
|
||||
<path d="M45,24 Q52,32 45,40" stroke="#00d4ff" stroke-width="2.6" fill="none" stroke-linecap="round" opacity="0.85"/>
|
||||
<!-- Signal arc outer right -->
|
||||
<path d="M50,20 Q59,32 50,44" stroke="#00d4ff" stroke-width="2.2" fill="none" stroke-linecap="round" opacity="0.55"/>
|
||||
|
||||
<!-- Tower pole -->
|
||||
<rect x="29.5" y="22" width="5" height="26" rx="2.5" fill="white"/>
|
||||
|
||||
<!-- Tower head / triangle -->
|
||||
<polygon points="32,7 22.5,22 41.5,22" fill="#00d4ff"/>
|
||||
|
||||
<!-- Base platform -->
|
||||
<rect x="21" y="48" width="22" height="4" rx="2" fill="rgba(255,255,255,0.25)"/>
|
||||
<rect x="26" y="48" width="12" height="4" rx="2" fill="rgba(255,255,255,0.15)"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
449
routes/export.js
449
routes/export.js
|
|
@ -13,13 +13,10 @@ const {
|
|||
isCloture, normalizeMarche, parseNum, formatMontant, selectVal,
|
||||
getDelaiRestant, niveauAlerte, niveauAvancement, niveauRisque,
|
||||
DELAI_CRITIQUE, DELAI_ATTENTION, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT,
|
||||
buildRef,
|
||||
} = require('../services/calc');
|
||||
|
||||
const pdfGen = require('../services/export-pdf');
|
||||
const { generateXlsx } = require('../services/export-xlsx');
|
||||
const { generatePptx } = require('../services/export-pptx');
|
||||
const { generateDocx } = require('../services/export-docx');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -53,7 +50,7 @@ async function buildViewData(view, req) {
|
|||
const alertes = actifs
|
||||
.map(r=>({...r,_d:getDelaiRestant(r)}))
|
||||
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
|
||||
.map(r=>({ref:buildRef(r),projet:r.projet||'',region:r.region||'',entrepreneur:r.entrepreneur||'',delai_restant:r._d,niveau:niveauAlerte(r._d)}))
|
||||
.map(r=>({ref:r.id_marche||r.reference||'',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,
|
||||
|
|
@ -188,13 +185,226 @@ router.get('/pptx', async (req, res) => {
|
|||
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
||||
}
|
||||
try {
|
||||
const allRows = await getMarches();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
const PptxGenJS = require('pptxgenjs');
|
||||
const allRows = await getMarches();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
const today = new Date().toLocaleDateString('fr-FR');
|
||||
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||||
|
||||
const parseN = v => { const n = parseFloat(String(v||'').replace(/\s/g,'').replace(',','.')); return isNaN(n)?0:n; };
|
||||
const fmtPct = v => { const n = parseN(v); return n===0?'0%':`${n.toFixed(0)}%`; };
|
||||
const fmtMDT = v => { const n = parseN(v); if(!n) return '—'; if(n>=1e6) return `${(n/1e6).toFixed(1)} MDT`; return `${(n/1e3).toFixed(0)} kDT`; };
|
||||
const selVal = v => { if(!v) return ''; if(typeof v==='object'&&v.value!==undefined) return String(v.value); return String(v); };
|
||||
|
||||
const totalBudget = actifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const phyList = actifs.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const avgPhy = phyList.length ? phyList.reduce((a,b)=>a+b,0)/phyList.length : 0;
|
||||
const alerteItems = actifs
|
||||
.map(r=>({...r,_d: (() => { const v=parseInt(String(r.delai_restant||''),10); return !isNaN(v)?v:null; })()}))
|
||||
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
|
||||
.sort((a,b)=>a._d-b._d);
|
||||
|
||||
const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD||70);
|
||||
const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION||50);
|
||||
const classify = r => {
|
||||
const t = parseN(r.taux_phy||r.avt_phy);
|
||||
const s = String(selVal(r.nature)||'').toLowerCase().includes('modern') ? SEUIL_MOD : SEUIL_STD;
|
||||
if(!t) return 'Non déterminé';
|
||||
if(t>=SEUIL_CRITIQUE_PCT) return 'Dépassement';
|
||||
if(t>=s) return 'Normal';
|
||||
return 'Sous Avancement';
|
||||
};
|
||||
const normal = actifs.filter(r=>classify(r)==='Normal');
|
||||
const sous = actifs.filter(r=>classify(r)==='Sous Avancement');
|
||||
const dep = actifs.filter(r=>classify(r)==='Dépassement');
|
||||
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.layout = 'LAYOUT_WIDE';
|
||||
pptx.author = 'RLA API';
|
||||
pptx.company = 'Tunisie Telecom Zone Sud';
|
||||
pptx.subject = 'Marchés RLA Zone Sud';
|
||||
|
||||
const addFooter = slide => {
|
||||
slide.addText(`Tunisie Telecom • Zone Sud • ${today}`, {
|
||||
x: 0, y: 5.3, w: '100%', h: 0.25,
|
||||
fontSize: 8, color: '64748B', align: 'center',
|
||||
});
|
||||
};
|
||||
|
||||
// ── Slide 1: Couverture
|
||||
const s1 = pptx.addSlide();
|
||||
s1.background = { color: '002D62' };
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.08, fill: { color: '00D4FF' } });
|
||||
s1.addText('TUNISIE TELECOM', { x: 0.5, y: 0.4, w: '90%', h: 0.5, fontSize: 14, color: '94A3B8', align: 'center' });
|
||||
s1.addText('RAPPORT DE SUIVI DES MARCHÉS RLA', { x: 0.5, y: 1.1, w: '90%', h: 1.0, fontSize: 30, bold: true, color: 'FFFFFF', align: 'center' });
|
||||
s1.addText('Zone Sud — Situation Actuelle & Pilotage Proactif', { x: 0.5, y: 2.2, w: '90%', h: 0.5, fontSize: 16, color: '00D4FF', align: 'center' });
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 3.5, y: 3.0, w: 6.5, h: 0.05, fill: { color: '00D4FF' }, line: { color: '00D4FF' } });
|
||||
s1.addText(`📅 ${today}`, { x: 0.5, y: 3.2, w: '90%', h: 0.4, fontSize: 12, color: 'CBD5E1', align: 'center' });
|
||||
s1.addText(`📋 ${actifs.length} marchés actifs │ 💰 ${fmtMDT(totalBudget)}`, { x: 0.5, y: 3.7, w: '90%', h: 0.4, fontSize: 11, color: '94A3B8', align: 'center' });
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 0, y: 5.47, w: '100%', h: 0.08, fill: { color: '00D4FF' } });
|
||||
|
||||
// ── Slide 2: Synthèse globale
|
||||
const s2 = pptx.addSlide();
|
||||
s2.background = { color: '0F172A' };
|
||||
s2.addText('SYNTHÈSE GLOBALE', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' });
|
||||
s2.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } });
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Marchés', val: String(actifs.length), color: '00D4FF' },
|
||||
{ label: 'Budget Total', val: fmtMDT(totalBudget), color: '10B981' },
|
||||
{ label: 'Avt. Phy Moy', val: fmtPct(avgPhy), color: '10B981' },
|
||||
{ label: 'Alertes', val: String(alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length), color: 'EF4444' },
|
||||
];
|
||||
kpis.forEach((k, i) => {
|
||||
const x = 0.3 + i * 2.5;
|
||||
s2.addShape(pptx.ShapeType.roundRect, { x, y: 0.75, w: 2.3, h: 1.3, fill: { color: '1E3A5F' }, line: { color: '002D62' } });
|
||||
s2.addText(k.val, { x, y: 0.85, w: 2.3, h: 0.7, fontSize: 26, bold: true, color: k.color, align: 'center' });
|
||||
s2.addText(k.label, { x, y: 1.6, w: 2.3, h: 0.35, fontSize: 9, color: '94A3B8', align: 'center' });
|
||||
});
|
||||
|
||||
// Par statut
|
||||
const parStatut = {};
|
||||
for (const r of filtered) {
|
||||
const s = selVal(r.observation) || 'Inconnu';
|
||||
parStatut[s] = (parStatut[s] || 0) + 1;
|
||||
}
|
||||
const statRows = Object.entries(parStatut).slice(0, 8).map(([s, n]) => [
|
||||
{ text: s, options: { color: 'CBD5E1', fontSize: 9 } },
|
||||
{ text: String(n), options: { color: '00D4FF', bold: true, fontSize: 9, align: 'right' } },
|
||||
]);
|
||||
if (statRows.length) {
|
||||
s2.addText('Répartition par Statut', { x: 0.3, y: 2.2, w: 4, h: 0.3, fontSize: 11, bold: true, color: 'FFFFFF' });
|
||||
s2.addTable([
|
||||
[{ text: 'Statut', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } },
|
||||
{ text: 'Nb', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }],
|
||||
...statRows,
|
||||
], { x: 0.3, y: 2.55, w: 4, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [3.2, 0.8] });
|
||||
}
|
||||
|
||||
// Par région
|
||||
const regData = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'].map(reg => {
|
||||
const ra = actifs.filter(r=>(r.region||'')===reg);
|
||||
const pl = ra.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0;
|
||||
return [
|
||||
{ text: reg, options: { color: 'CBD5E1', fontSize: 9 } },
|
||||
{ text: String(ra.length), options: { color: '00D4FF', bold: true, fontSize: 9, align: 'center' } },
|
||||
{ text: fmtPct(pm), options: { color: pm>=70?'10B981':'EF4444', bold: true, fontSize: 9, align: 'center' } },
|
||||
];
|
||||
});
|
||||
s2.addText('Par Région', { x: 5.2, y: 2.2, w: 4.5, h: 0.3, fontSize: 11, bold: true, color: 'FFFFFF' });
|
||||
s2.addTable([
|
||||
[{ text: 'Région', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } },
|
||||
{ text: 'Marchés', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } },
|
||||
{ text: 'Phy %', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }],
|
||||
...regData,
|
||||
], { x: 5.2, y: 2.55, w: 4.5, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [2.5, 1.0, 1.0] });
|
||||
addFooter(s2);
|
||||
|
||||
// ── Slide 3: Alertes
|
||||
const s3 = pptx.addSlide();
|
||||
s3.background = { color: '0F172A' };
|
||||
s3.addText('ALERTES DÉLAIS', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: 'EF4444' });
|
||||
s3.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: 'EF4444' } });
|
||||
s3.addText(`${alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length} critique(s) • ${alerteItems.length} total`, {
|
||||
x: 0.3, y: 0.7, w: 9.4, h: 0.3, fontSize: 10, color: '94A3B8',
|
||||
});
|
||||
if (alerteItems.length) {
|
||||
const alertRows = alerteItems.slice(0, 18).map(r => {
|
||||
const alColor = r._d <= DELAI_CRITIQUE ? 'EF4444' : 'EA580C';
|
||||
return [
|
||||
{ text: r.id_marche||r.reference||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.projet||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.region||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.entrepreneur||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: String(r._d||'—'), options: { fontSize: 8, bold: true, color: alColor, align: 'center' } },
|
||||
];
|
||||
});
|
||||
s3.addTable([
|
||||
['Référence','Projet','Région','Entrepreneur','Délai (j)'].map(t => ({
|
||||
text: t, options: { bold: true, color: 'FFFFFF', fill: '7F1D1D', fontSize: 8 },
|
||||
})),
|
||||
...alertRows,
|
||||
], { x: 0.3, y: 1.1, w: 9.4, fontSize: 8, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 2.5, 1.2, 2.5, 1.2] });
|
||||
}
|
||||
addFooter(s3);
|
||||
|
||||
// ── Slide 4: Pilotage proactif
|
||||
const s4 = pptx.addSlide();
|
||||
s4.background = { color: '0F172A' };
|
||||
s4.addText('PILOTAGE PROACTIF', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' });
|
||||
s4.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } });
|
||||
|
||||
const pilotKpis = [
|
||||
{ label: 'Normal', val: String(normal.length), color: '10B981' },
|
||||
{ label: 'Sous Avancement', val: String(sous.length), color: 'EF4444' },
|
||||
{ label: 'Dépassement', val: String(dep.length), color: 'EA580C' },
|
||||
];
|
||||
pilotKpis.forEach((k, i) => {
|
||||
const x = 0.5 + i * 3.3;
|
||||
s4.addShape(pptx.ShapeType.roundRect, { x, y: 0.75, w: 3.0, h: 1.0, fill: { color: '1E3A5F' }, line: { color: '002D62' } });
|
||||
s4.addText(k.val, { x, y: 0.8, w: 3.0, h: 0.55, fontSize: 28, bold: true, color: k.color, align: 'center' });
|
||||
s4.addText(k.label, { x, y: 1.4, w: 3.0, h: 0.25, fontSize: 9, color: '94A3B8', align: 'center' });
|
||||
});
|
||||
|
||||
const pilotItems = [...sous, ...dep].slice(0, 18);
|
||||
if (pilotItems.length) {
|
||||
const pilRows = pilotItems.map((r, i) => {
|
||||
const t = parseN(r.taux_phy||r.avt_phy);
|
||||
const res = classify(r);
|
||||
return [
|
||||
{ text: r.id_marche||r.reference||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.projet||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.region||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.entrepreneur||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: fmtPct(t), options: { fontSize: 8, bold: true, color: t>=SEUIL_CRITIQUE_PCT?'EA580C':'EF4444', align: 'center' } },
|
||||
{ text: res, options: { fontSize: 8, bold: true, color: res==='Dépassement'?'EA580C':'EF4444', align: 'center' } },
|
||||
];
|
||||
});
|
||||
s4.addText('Marchés Sous Avancement & Dépassement', { x: 0.3, y: 1.9, w: 9.4, h: 0.3, fontSize: 10, bold: true, color: 'FFFFFF' });
|
||||
s4.addTable([
|
||||
['Référence','Projet','Région','Entrepreneur','Phy %','Résultat'].map(t => ({
|
||||
text: t, options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 8 },
|
||||
})),
|
||||
...pilRows,
|
||||
], { x: 0.3, y: 2.25, w: 9.4, fontSize: 8, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 2.3, 1.0, 2.0, 0.9, 1.2] });
|
||||
}
|
||||
addFooter(s4);
|
||||
|
||||
// ── Slide 5: Par région
|
||||
const s5 = pptx.addSlide();
|
||||
s5.background = { color: '0F172A' };
|
||||
s5.addText('SYNTHÈSE PAR RÉGION', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' });
|
||||
s5.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } });
|
||||
|
||||
const regTableData = ALL_REGIONS.map(reg => {
|
||||
const ra = actifs.filter(r=>(r.region||'')===reg);
|
||||
const rc = filtered.filter(r=>(r.region||'')===reg&&isCloture(r));
|
||||
const bud = ra.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const pl = ra.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0;
|
||||
const al = ra.map(r=>({_d:parseInt(String(r.delai_restant||''),10)||null})).filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION);
|
||||
return [
|
||||
{ text: reg, options: { color: '00D4FF', bold: true, fontSize: 9 } },
|
||||
{ text: String(ra.length), options: { color: 'CBD5E1', fontSize: 9, align: 'center' } },
|
||||
{ text: String(rc.length), options: { color: '64748B', fontSize: 9, align: 'center' } },
|
||||
{ text: fmtMDT(bud), options: { color: 'CBD5E1', fontSize: 9, align: 'right' } },
|
||||
{ text: fmtPct(pm), options: { color: pm>=70?'10B981':'EF4444', bold: true, fontSize: 9, align: 'center' } },
|
||||
{ text: String(al.length), options: { color: al.length>0?'EF4444':'10B981', bold: true, fontSize: 9, align: 'center' } },
|
||||
];
|
||||
});
|
||||
s5.addTable([
|
||||
['Région','Actifs','Clôturés','Budget','Phy Moy','Alertes'].map(t => ({
|
||||
text: t, options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 },
|
||||
})),
|
||||
...regTableData,
|
||||
], { x: 0.3, y: 0.75, w: 9.4, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 1.2, 1.2, 1.8, 1.5, 1.7] });
|
||||
addFooter(s5);
|
||||
|
||||
const filename = `Marches_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.pptx`;
|
||||
const buf = await generatePptx(actifs, clotures, filtered);
|
||||
const buf = await pptx.write({ outputType: 'nodebuffer' });
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
|
|
@ -212,14 +422,221 @@ router.get('/docx', async (req, res) => {
|
|||
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
||||
}
|
||||
try {
|
||||
const { getPipeline } = require('../services/baserow');
|
||||
const allRows = await getMarches();
|
||||
const pipelineRows = await getPipeline();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
const {
|
||||
Document, Packer, Paragraph, Table, TableRow, TableCell,
|
||||
TextRun, HeadingLevel, AlignmentType, WidthType, PageBreak,
|
||||
Header, Footer, ImageRun,
|
||||
} = require('docx');
|
||||
|
||||
const buf = await generateDocx({ actifs, clotures, filtered, pipelineRows });
|
||||
const allRows = await getMarches();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
const today = new Date().toLocaleDateString('fr-FR');
|
||||
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||||
|
||||
const parseN = v => { const n = parseFloat(String(v||'').replace(/\s/g,'').replace(',','.')); return isNaN(n)?0:n; };
|
||||
const fmtPct = v => { const n = parseN(v); return n===0?'0 %':`${n.toFixed(0)} %`; };
|
||||
const fmtMDT = v => { const n = parseN(v); if(!n) return '—'; if(n>=1e6) return `${(n/1e6).toFixed(1)} MDT`; return `${(n/1e3).toFixed(0)} kDT`; };
|
||||
const selVal = v => { if(!v) return ''; if(typeof v==='object'&&v.value!==undefined) return String(v.value); return String(v); };
|
||||
const parseDt = d => { if(!d) return null; const p=String(d).split(/[\/\-]/); if(p.length===3){const[a,b,c]=p; 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; };
|
||||
const fmtDate = d => { const dt=parseDt(d); if(!dt) return '—'; return dt.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); };
|
||||
|
||||
const totalBudget = actifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const phyList = actifs.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const avgPhy = phyList.length ? phyList.reduce((a,b)=>a+b,0)/phyList.length : 0;
|
||||
|
||||
const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD||70);
|
||||
const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION||50);
|
||||
const classify = r => {
|
||||
const t = parseN(r.taux_phy||r.avt_phy);
|
||||
const s = String(selVal(r.nature)||'').toLowerCase().includes('modern') ? SEUIL_MOD : SEUIL_STD;
|
||||
if(!t) return 'Non déterminé';
|
||||
if(t>=SEUIL_CRITIQUE_PCT) return 'Dépassement';
|
||||
if(t>=s) return 'Normal';
|
||||
return 'Sous Avancement';
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const navyFill = { fill: '002D62' };
|
||||
const altFill = { fill: 'F1F5F9' };
|
||||
const hdr = (texts, opts={}) => new Paragraph({ children: texts, ...opts });
|
||||
const tr = (cells, isHeader=false) => new TableRow({
|
||||
tableHeader: isHeader,
|
||||
children: cells.map(([text, width, shade]) => new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ text: String(text??'—'), bold: isHeader, color: isHeader?'FFFFFF':'1E293B', size: 18 })],
|
||||
alignment: AlignmentType.LEFT,
|
||||
})],
|
||||
width: { size: width||1000, type: WidthType.DXA },
|
||||
shading: shade || (isHeader ? navyFill : undefined),
|
||||
})),
|
||||
});
|
||||
const h1 = text => new Paragraph({
|
||||
children: [new TextRun({ text, bold: true, color: '002D62', size: 32 })],
|
||||
spacing: { before: 300, after: 150 },
|
||||
border: { bottom: { style: 'single', size: 8, color: '00D4FF' } },
|
||||
});
|
||||
const h2 = text => new Paragraph({
|
||||
children: [new TextRun({ text, bold: true, color: '0F172A', size: 24 })],
|
||||
spacing: { before: 200, after: 100 },
|
||||
});
|
||||
const spacer = () => new Paragraph({ text: '', spacing: { before: 80, after: 80 } });
|
||||
|
||||
const children = [];
|
||||
|
||||
// ── Page de couverture
|
||||
children.push(new Paragraph({ children: [new TextRun({ text: '', break: 4 })] }));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'TUNISIE TELECOM', bold: true, color: '002D62', size: 36, allCaps: true })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 100, after: 50 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Direction Centrale', color: '64748B', size: 24 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Zone Sud', color: '64748B', size: 24 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Rapport de Suivi des Marchés RLA', bold: true, color: '002D62', size: 44 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 100, after: 80 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Situation Actuelle & Analyse Prospective', color: '0F172A', size: 26 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `📅 ${today} 📋 ${actifs.length} marchés 💰 Budget total : ${fmtMDT(totalBudget)}`, color: '475569', size: 20 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 100 },
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Sommaire exécutif
|
||||
children.push(h1('Sommaire Exécutif'));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `Ce rapport présente la situation au ${today} des ${actifs.length} marchés actifs de la Zone Sud de Tunisie Telecom. Budget global engagé : ${fmtMDT(totalBudget)}. Avancement physique moyen : ${fmtPct(avgPhy)}.`, size: 20, color: '374151' })],
|
||||
spacing: { after: 120 },
|
||||
}));
|
||||
|
||||
// KPI table
|
||||
const normal = actifs.filter(r=>classify(r)==='Normal').length;
|
||||
const sous = actifs.filter(r=>classify(r)==='Sous Avancement').length;
|
||||
const dep = actifs.filter(r=>classify(r)==='Dépassement').length;
|
||||
const nd = actifs.filter(r=>classify(r)==='Non déterminé').length;
|
||||
|
||||
children.push(new Paragraph({ children: [new TextRun({ text: 'Indicateurs Clés', bold: true, size: 22, color: '002D62' })], spacing: { before: 150, after: 80 } }));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Indicateur',2500,navyFill],['Valeur',2000,navyFill]], true),
|
||||
tr([['Total marchés actifs', 2500], [String(actifs.length), 2000]]),
|
||||
tr([['Marchés clôturés', 2500], [String(clotures.length), 2000]], false),
|
||||
tr([['Budget total', 2500], [fmtMDT(totalBudget), 2000]], false),
|
||||
tr([['Avancement physique moyen', 2500], [fmtPct(avgPhy), 2000]], false),
|
||||
tr([['Normal', 2500], [String(normal), 2000]], false),
|
||||
tr([['Sous Avancement', 2500], [String(sous), 2000]], false),
|
||||
tr([['Dépassement', 2500], [String(dep), 2000]], false),
|
||||
],
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Alertes
|
||||
children.push(h1('Alertes Délais'));
|
||||
const alerteItems = actifs
|
||||
.map(r => ({ ...r, _d: (() => { const v=parseInt(String(r.delai_restant||''),10); return !isNaN(v)?v:null; })() }))
|
||||
.filter(r => r._d !== null && r._d <= DELAI_ATTENTION)
|
||||
.sort((a, b) => a._d - b._d);
|
||||
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `${alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length} marché(s) en alerte critique (< ${DELAI_CRITIQUE}j) • ${alerteItems.length} total (< ${DELAI_ATTENTION}j)`, size: 20, color: 'EF4444', bold: true })],
|
||||
spacing: { after: 100 },
|
||||
}));
|
||||
if (alerteItems.length) {
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Référence',2500,navyFill],['Projet',3000,navyFill],['Région',1200,navyFill],['Délai (j)',1200,navyFill]], true),
|
||||
...alerteItems.slice(0,20).map((r, i) => tr([
|
||||
[r.id_marche||r.reference||'', 2500, i%2===1?altFill:undefined],
|
||||
[r.projet||'', 3000, i%2===1?altFill:undefined],
|
||||
[r.region||'', 1200, i%2===1?altFill:undefined],
|
||||
[String(r._d||'—'), 1200, i%2===1?altFill:undefined],
|
||||
])),
|
||||
],
|
||||
}));
|
||||
}
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Synthèse par région
|
||||
children.push(h1('Synthèse par Région'));
|
||||
for (const region of ALL_REGIONS) {
|
||||
const regActifs = actifs.filter(r => (r.region||'') === region);
|
||||
if (!regActifs.length) continue;
|
||||
children.push(h2(`📍 ${region}`));
|
||||
const bud = regActifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const pl = regActifs.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0;
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `${regActifs.length} marchés • Budget : ${fmtMDT(bud)} • Phy moy : ${fmtPct(pm)}`, size: 18, color: '64748B' })],
|
||||
spacing: { after: 80 },
|
||||
}));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Référence',2500,navyFill],['Projet',2800,navyFill],['Entrepreneur',2000,navyFill],['Phy %',700,navyFill],['Statut',1000,navyFill]], true),
|
||||
...regActifs.map((r, i) => {
|
||||
const phy = parseN(r.taux_phy||r.avt_phy);
|
||||
return tr([
|
||||
[r.id_marche||r.reference||'', 2500, i%2===1?altFill:undefined],
|
||||
[r.projet||'', 2800, i%2===1?altFill:undefined],
|
||||
[r.entrepreneur||'', 2000, i%2===1?altFill:undefined],
|
||||
[fmtPct(phy), 700, i%2===1?altFill:undefined],
|
||||
[selVal(r.observation)||'—', 1000, i%2===1?altFill:undefined],
|
||||
]);
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(spacer());
|
||||
}
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Pilotage proactif
|
||||
children.push(h1('Pilotage Proactif'));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `Normal: ${normal} • Sous Avancement: ${sous} • Dépassement: ${dep} • Non déterminé: ${nd}`, size: 20, color: '374151' })],
|
||||
spacing: { after: 100 },
|
||||
}));
|
||||
const pilotItems = actifs.map(r => ({ r, res: classify(r), phy: parseN(r.taux_phy||r.avt_phy) }))
|
||||
.sort((a,b) => a.phy - b.phy);
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Référence',2200,navyFill],['Projet',2500,navyFill],['Région',1000,navyFill],['Entrepreneur',1800,navyFill],['Phy %',700,navyFill],['Résultat',1300,navyFill]], true),
|
||||
...pilotItems.map(({ r, res, phy }, i) => tr([
|
||||
[r.id_marche||r.reference||'', 2200, i%2===1?altFill:undefined],
|
||||
[r.projet||'', 2500, i%2===1?altFill:undefined],
|
||||
[r.region||'', 1000, i%2===1?altFill:undefined],
|
||||
[r.entrepreneur||'', 1800, i%2===1?altFill:undefined],
|
||||
[fmtPct(phy), 700, i%2===1?altFill:undefined],
|
||||
[res, 1300, i%2===1?altFill:undefined],
|
||||
])),
|
||||
],
|
||||
}));
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'RLA API',
|
||||
description: 'Rapport Marchés Tunisie Telecom Zone Sud',
|
||||
title: 'Rapport RLA Zone Sud',
|
||||
sections: [{ children }],
|
||||
});
|
||||
const buf = await Packer.toBuffer(doc);
|
||||
const filename = `Rapport_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.docx`;
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
/**
|
||||
* routes/modernisation.js
|
||||
* Vue "Succession des marchés Modernisation"
|
||||
* Croise table 856 (marchés actifs, nature=Modernisation) avec table 872 (AO en lancement)
|
||||
* Lien : region commune entre les deux tables
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getMarches, getPipeline } = require('../services/baserow');
|
||||
const { normalizeMarche, isCloture, selectVal } = require('../services/calc');
|
||||
|
||||
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||||
|
||||
function isModernisation(r) {
|
||||
const nature = selectVal(r.nature);
|
||||
return nature && nature.toLowerCase().includes('moderni');
|
||||
}
|
||||
|
||||
function pipelineRegions(r) {
|
||||
const v = r['Regions'] || r.regions || [];
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.map(x => (typeof x === 'object' ? x.value : x)).filter(Boolean);
|
||||
}
|
||||
|
||||
function phaseAO(r) {
|
||||
const now = new Date();
|
||||
const limit = r['date-limite'] ? new Date(r['date-limite']) : null;
|
||||
const ouv = r['date-ouverture-adm-tech'] ? new Date(r['date-ouverture-adm-tech']) : null;
|
||||
const clos = r['date-cloture-evaluation'] ? new Date(r['date-cloture-evaluation']) : null;
|
||||
|
||||
if (clos && now > clos) return { label:'Attribué', code:'attribue', color:'#6b7280' };
|
||||
if (ouv && now > ouv) return { label:'Évaluation', code:'evaluation', color:'#8b5cf6' };
|
||||
if (limit && now > limit) return { label:'Dépouillé', code:'depouille', color:'#f59e0b' };
|
||||
if (limit) return { label:'Ouvert', code:'ouvert', color:'#10b981' };
|
||||
return { label:'Préparation', code:'preparation', color:'#3b82f6' };
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return null;
|
||||
const dt = new Date(d);
|
||||
if (isNaN(dt.getTime())) return String(d);
|
||||
return `${String(dt.getDate()).padStart(2,'0')}/${String(dt.getMonth()+1).padStart(2,'0')}/${dt.getFullYear()}`;
|
||||
}
|
||||
|
||||
// GET /api/modernisation
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const [rawMarches, rawPipeline] = await Promise.all([getMarches(), getPipeline()]);
|
||||
|
||||
// Marchés modernisation actifs par région (non clôturés)
|
||||
const modActifs = rawMarches
|
||||
.filter(r => !isCloture(r) && isModernisation(r))
|
||||
.map(normalizeMarche);
|
||||
|
||||
// Pour chaque région, construire la chaîne actuel → suivant
|
||||
const regions = ALL_REGIONS.map(reg => {
|
||||
const actuels = modActifs.filter(r => (r.region || '') === reg);
|
||||
const suivants = rawPipeline.filter(r => pipelineRegions(r).includes(reg));
|
||||
|
||||
return {
|
||||
region: reg,
|
||||
actuels: actuels.map(r => ({
|
||||
ref: r.ref,
|
||||
projet: r.projet,
|
||||
entrepreneur: r.entrepreneur,
|
||||
taux_phy: r.taux_phy,
|
||||
taux_fin: r.taux_fin,
|
||||
date_fin: r.date_fin,
|
||||
delai_restant: r.delai_restant,
|
||||
montant: r.montant,
|
||||
statut: r.statut,
|
||||
})),
|
||||
suivants: suivants.map(r => ({
|
||||
num_ao: r['num-ao'] || '',
|
||||
description: r['Description du projet'] || '',
|
||||
estimation: parseFloat(r.Estimation || 0) || 0,
|
||||
duree: r['Duree'] || '',
|
||||
date_limite: fmtDate(r['date-limite']),
|
||||
date_ouverture: fmtDate(r['date-ouverture-adm-tech']),
|
||||
date_evaluation: fmtDate(r['date-cloture-evaluation']),
|
||||
phase: phaseAO(r),
|
||||
jours_limite: r['date-limite']
|
||||
? Math.ceil((new Date(r['date-limite']) - new Date()) / 86400000)
|
||||
: null,
|
||||
})),
|
||||
};
|
||||
}).filter(r => r.actuels.length > 0 || r.suivants.length > 0);
|
||||
|
||||
res.json({ count: regions.length, regions });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur modernisation', detail: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,54 +1,12 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const router = express.Router();
|
||||
const { getPipeline } = require('../services/baserow');
|
||||
|
||||
const REGION_COLORS = {
|
||||
Gabes:'#17A2B8', Gafsa:'#22C55E', Kebili:'#9333EA',
|
||||
Medenine:'#0EA5E9', Sfax:'#002855', Tataouine:'#14B8A6', Tozeur:'#818CF8',
|
||||
};
|
||||
|
||||
function phaseAO(r) {
|
||||
const now = new Date();
|
||||
const limit = r['date-limite'] ? new Date(r['date-limite']) : null;
|
||||
const ouv = r['date-ouverture-adm-tech'] ? new Date(r['date-ouverture-adm-tech']) : null;
|
||||
const clos = r['date-cloture-evaluation'] ? new Date(r['date-cloture-evaluation']) : null;
|
||||
|
||||
if (clos && now > clos) return { label:'Attribué', code:'attribue', color:'#6b7280' };
|
||||
if (ouv && now > ouv) return { label:'Évaluation', code:'evaluation', color:'#8b5cf6' };
|
||||
if (limit && now > limit) return { label:'Dépouillé', code:'depouille', color:'#f59e0b' };
|
||||
if (limit) return { label:'Ouvert', code:'ouvert', color:'#10b981' };
|
||||
return { label:'Préparation', code:'preparation', color:'#3b82f6' };
|
||||
}
|
||||
|
||||
function joursAvantLimite(r) {
|
||||
if (!r['date-limite']) return null;
|
||||
const d = new Date(r['date-limite']);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
return Math.ceil((d - new Date()) / 86400000);
|
||||
}
|
||||
|
||||
function formatRegions(r) {
|
||||
const v = r['Regions'] || r.regions || [];
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.map(x => ({
|
||||
name: typeof x === 'object' ? (x.value || '') : x,
|
||||
color: REGION_COLORS[typeof x === 'object' ? x.value : x] || '#888',
|
||||
})).filter(x => x.name);
|
||||
}
|
||||
|
||||
// GET /api/pipeline
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await getPipeline();
|
||||
const enriched = rows.map(r => ({
|
||||
...r,
|
||||
_phase: phaseAO(r),
|
||||
_jours_limite: joursAvantLimite(r),
|
||||
_regions: formatRegions(r),
|
||||
_estimation: parseFloat(r.Estimation || r.estimation || 0) || 0,
|
||||
}));
|
||||
const totalEstimation = enriched.reduce((s, r) => s + r._estimation, 0);
|
||||
res.json({ count: enriched.length, total_estimation: totalEstimation, results: enriched });
|
||||
const rows = await getPipeline();
|
||||
res.json({ count: rows.length, results: rows });
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,7 @@ app.use('/api/export', auth, requireUser, filterByRegion, require('./r
|
|||
|
||||
// ─── Protégées (admin+) ──────────────────────────────────────────────────────
|
||||
|
||||
app.use('/api/pipeline', auth, requireAdmin, require('./routes/pipeline'));
|
||||
app.use('/api/modernisation', auth, requireAdmin, require('./routes/modernisation'));
|
||||
app.use('/api/pipeline', auth, requireAdmin, require('./routes/pipeline'));
|
||||
|
||||
// ─── Protégées (superadmin) ──────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -139,12 +139,6 @@ function niveauRisque(r) {
|
|||
|
||||
// ─── Normalisation d'un marché ───────────────────────────────────────────────
|
||||
|
||||
function buildRef(r) {
|
||||
const base = r.id_marche || r.reference || String(r.id || '');
|
||||
const reg = r.region_csc || r.region || '';
|
||||
return reg ? `${base} - ${reg}` : base;
|
||||
}
|
||||
|
||||
function normalizeMarche(r) {
|
||||
const obsValue = selectVal(r.observation);
|
||||
const natureValue = selectVal(r.nature);
|
||||
|
|
@ -158,7 +152,7 @@ function normalizeMarche(r) {
|
|||
|
||||
return {
|
||||
id: r.id,
|
||||
ref: buildRef(r),
|
||||
ref: r.id_marche || r.reference || String(r.id || ''),
|
||||
projet: r.projet || '',
|
||||
region: r.region || r.region_csc || '',
|
||||
region_csc: r.region_csc || r.region || '',
|
||||
|
|
@ -203,5 +197,5 @@ module.exports = {
|
|||
formatMontant, formatDateFR, formatPct,
|
||||
isCloture, getDelaiRestant, niveauAlerte,
|
||||
niveauAvancement, resultatFinancier, resultatPhysique, niveauRisque,
|
||||
normalizeMarche, buildRef,
|
||||
normalizeMarche,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,661 +0,0 @@
|
|||
/**
|
||||
* services/export-docx.js
|
||||
* DOCX conforme au fichier cible Rapport_RLA_2025_Zone_Sud.docx
|
||||
* Structure : Couverture, TOC, Sommaire Exécutif, Synthèse, Alertes,
|
||||
* Modernisation, Synthèse/Région, Estimation, Matrice, Recommandations
|
||||
*/
|
||||
const {
|
||||
Document, Packer, Paragraph, Table, TableRow, TableCell,
|
||||
TextRun, HeadingLevel, AlignmentType, WidthType, PageBreak,
|
||||
TableOfContents, ShadingType, BorderStyle, convertInchesToTwip,
|
||||
} = require('docx');
|
||||
const {
|
||||
parseNum, selectVal, parseDateFR, formatDateFR, formatMontant, buildRef,
|
||||
getDelaiRestant, niveauAlerte, DELAI_CRITIQUE, DELAI_ATTENTION,
|
||||
SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT,
|
||||
} = require('./calc');
|
||||
|
||||
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||||
|
||||
const REGION_HEX = {
|
||||
Gabes:'0891B2', Gafsa:'059669', Kebili:'7C3AED',
|
||||
Medenine:'2563EB', Sfax:'0B2A55', Tataouine:'0D9488', Tozeur:'6366F1',
|
||||
};
|
||||
|
||||
// ── Helpers visuels ─────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtPct(v) {
|
||||
const n = parseNum(v);
|
||||
return n === 0 ? '0 %' : `${n.toFixed(0)} %`;
|
||||
}
|
||||
|
||||
function fmtMDT(v) {
|
||||
const n = parseNum(v);
|
||||
if (!n) return '—';
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(3)} MDT`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kDT`;
|
||||
return `${n.toFixed(0)} DT`;
|
||||
}
|
||||
|
||||
function periode(r) {
|
||||
const d = formatDateFR(r.date_debut || r.debut_marche);
|
||||
const f = formatDateFR(r.date_fin || r.date_fin_marche);
|
||||
if (d === '—' && f === '—') return '—';
|
||||
return `${d} → ${f}`;
|
||||
}
|
||||
|
||||
function elapsedMonths(dateDebut) {
|
||||
const d = parseDateFR(dateDebut);
|
||||
if (!d) return 0;
|
||||
const now = new Date();
|
||||
return Math.max(0, (now.getFullYear() - d.getFullYear()) * 12 + (now.getMonth() - d.getMonth()));
|
||||
}
|
||||
|
||||
function totalMonths(dateDebut, dateFin) {
|
||||
const d = parseDateFR(dateDebut);
|
||||
const f = parseDateFR(dateFin);
|
||||
if (!d || !f) return 0;
|
||||
return Math.max(1, (f.getFullYear() - d.getFullYear()) * 12 + (f.getMonth() - d.getMonth()));
|
||||
}
|
||||
|
||||
function projection(r) {
|
||||
const marche = parseNum(r.tot_marche || r.totmarche || r.montant);
|
||||
const minDT = marche * (SEUIL_STANDARD / 100);
|
||||
const consomme = parseNum(r.avt_fin);
|
||||
const elapsed = elapsedMonths(r.date_debut || r.debut_marche);
|
||||
const total = totalMonths(r.date_debut || r.debut_marche, r.date_fin || r.date_fin_marche);
|
||||
const parMois = elapsed > 0 ? consomme / elapsed : 0;
|
||||
const projete = parMois * total;
|
||||
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 };
|
||||
}
|
||||
|
||||
// ── Constructeurs DOCX ──────────────────────────────────────────────────────────
|
||||
|
||||
const NAVY = '0B2A55';
|
||||
const ALT = 'F1F5F9';
|
||||
const WH = 'FFFFFF';
|
||||
|
||||
function cellShade(hex) {
|
||||
return { fill: hex, type: ShadingType.SOLID, color: hex };
|
||||
}
|
||||
|
||||
function hdrCell(text, width, hexBg = NAVY) {
|
||||
return new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ text: String(text), bold: true, color: WH, size: 18 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
})],
|
||||
width: { size: width, type: WidthType.DXA },
|
||||
shading: cellShade(hexBg),
|
||||
margins: { top: 60, bottom: 60, left: 80, right: 80 },
|
||||
});
|
||||
}
|
||||
|
||||
function dataCell(text, width, shade, align = AlignmentType.LEFT, bold = false, color = '1E293B') {
|
||||
return new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ text: String(text ?? '—'), size: 17, color, bold })],
|
||||
alignment: align,
|
||||
})],
|
||||
width: { size: width, type: WidthType.DXA },
|
||||
shading: shade ? cellShade(shade) : undefined,
|
||||
margins: { top: 50, bottom: 50, left: 80, right: 80 },
|
||||
});
|
||||
}
|
||||
|
||||
function hdrRow(cols, hexBg = NAVY) {
|
||||
return new TableRow({
|
||||
tableHeader: true,
|
||||
children: cols.map(([text, w]) => hdrCell(text, w, hexBg)),
|
||||
});
|
||||
}
|
||||
|
||||
function dataRow(cells, isAlt = false) {
|
||||
const shade = isAlt ? ALT : undefined;
|
||||
return new TableRow({
|
||||
children: cells.map(([text, w, align, bold, color]) =>
|
||||
dataCell(text, w, shade, align || AlignmentType.LEFT, bold || false, color || '1E293B')),
|
||||
});
|
||||
}
|
||||
|
||||
function bannerRow(text, hexBg) {
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ text, bold: true, color: WH, size: 22 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
})],
|
||||
shading: cellShade(hexBg),
|
||||
columnSpan: 1,
|
||||
margins: { top: 100, bottom: 100 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
borders: { top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } },
|
||||
});
|
||||
}
|
||||
|
||||
function h1(text) {
|
||||
return new Paragraph({
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
children: [new TextRun({ text, bold: true, color: NAVY, size: 36 })],
|
||||
spacing: { before: 400, after: 160 },
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: '0680C3' } },
|
||||
});
|
||||
}
|
||||
|
||||
function h2(text, hexColor = NAVY) {
|
||||
return new Paragraph({
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
children: [new TextRun({ text, bold: true, color: hexColor, size: 26 })],
|
||||
spacing: { before: 280, after: 100 },
|
||||
});
|
||||
}
|
||||
|
||||
function para(text, size = 20, color = '374151') {
|
||||
return new Paragraph({
|
||||
children: [new TextRun({ text, size, color })],
|
||||
spacing: { after: 120 },
|
||||
});
|
||||
}
|
||||
|
||||
function spacer() {
|
||||
return new Paragraph({ text: '', spacing: { before: 80, after: 80 } });
|
||||
}
|
||||
|
||||
// ── Export principal ────────────────────────────────────────────────────────────
|
||||
|
||||
async function generateDocx({ actifs, clotures, filtered, pipelineRows }) {
|
||||
const today = new Date().toLocaleDateString('fr-FR');
|
||||
const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||
const phyList = actifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0);
|
||||
const avgPhy = phyList.length ? phyList.reduce((a, b) => a + b, 0) / phyList.length : 0;
|
||||
const finList = actifs.map(r => parseNum(r.taux_fin)).filter(v => v > 0);
|
||||
const avgFin = finList.length ? finList.reduce((a, b) => a + b, 0) / finList.length : 0;
|
||||
|
||||
const capexRows = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('CAPEX'));
|
||||
const opexRows = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('OPEX'));
|
||||
|
||||
const alertes = actifs
|
||||
.map(r => ({ ...r, _d: getDelaiRestant(r) }))
|
||||
.filter(r => r._d !== null && r._d <= DELAI_ATTENTION)
|
||||
.sort((a, b) => a._d - b._d);
|
||||
|
||||
const modActifs = actifs.filter(r =>
|
||||
String(selectVal(r.nature)).toLowerCase().includes('moderni'));
|
||||
|
||||
const pipeline = Array.isArray(pipelineRows) ? pipelineRows : [];
|
||||
|
||||
const children = [];
|
||||
|
||||
// ── Couverture ────────────────────────────────────────────────────────────────
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({ children: [new TextRun({ text: 'TUNISIE TELECOM', bold: true, color: WH, size: 28 })], alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ children: [new TextRun({ text: 'Direction Centrale', color: 'CBD5E1', size: 20 })], alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ children: [new TextRun({ text: 'Zone Sud', color: 'CBD5E1', size: 20 })], alignment: AlignmentType.CENTER }),
|
||||
],
|
||||
shading: cellShade(NAVY),
|
||||
margins: { top: 200, bottom: 200, left: 300, right: 300 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
borders: { top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } },
|
||||
}));
|
||||
children.push(spacer());
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Rapport de Suivi des Marchés RLA', bold: true, color: NAVY, size: 52 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 200, after: 100 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Situation Actuelle & Analyse Prospective', color: '1E40AF', size: 32 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}));
|
||||
children.push(bannerRow('', '1E40AF'));
|
||||
children.push(spacer());
|
||||
children.push(new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `📅 ${today}`, size: 24, color: NAVY }),
|
||||
new TextRun({ text: ` 📊 Données au ${today}`, size: 24, bold: true, color: '475569' }),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 80 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `📋 ${actifs.length} marchés`, size: 24, color: NAVY }),
|
||||
new TextRun({ text: ` 💰 Budget total : ${fmtMDT(totalBudget)}`, size: 24, bold: true, color: '475569' }),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new TextRun({ text: 'Nabil Derouiche', bold: true, color: NAVY, size: 28 })], alignment: AlignmentType.CENTER }));
|
||||
children.push(new Paragraph({ children: [new TextRun({ text: 'Responsable Achats Zone Sud', color: '475569', size: 20 })], alignment: AlignmentType.CENTER }));
|
||||
children.push(new Paragraph({ children: [new TextRun({ text: 'Tunisie Telecom', color: '94A3B8', size: 20 })], alignment: AlignmentType.CENTER, spacing: { after: 300 } }));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Table des Matières ────────────────────────────────────────────────────────
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Table des Matières', bold: true, color: NAVY, size: 44 })],
|
||||
spacing: { before: 100, after: 100 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'ℹ️ Mettre à jour : clic droit → Mettre à jour les champs', size: 18, color: '94A3B8' })],
|
||||
spacing: { after: 200 },
|
||||
}));
|
||||
children.push(new TableOfContents('Table des Matières', {
|
||||
hyperlink: true,
|
||||
headingStyleRange: '1-3',
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Sommaire Exécutif ─────────────────────────────────────────────────────────
|
||||
children.push(h1('Sommaire Exécutif'));
|
||||
children.push(bannerRow('', '1E40AF'));
|
||||
children.push(spacer());
|
||||
const capexBudget = capexRows.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||
const opexBudget = opexRows.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||
|
||||
children.push(para(
|
||||
`La Zone Sud gère actuellement ${actifs.length} marchés RLA pour un budget global de ${totalBudget.toLocaleString('fr-FR', { minimumFractionDigits: 3 })} DT, ` +
|
||||
`répartis en ${capexRows.length} marchés CAPEX (${fmtMDT(capexBudget)}) et ${opexRows.length} marchés OPEX (${fmtMDT(opexBudget)}).`
|
||||
));
|
||||
children.push(para(
|
||||
`Sur le plan des alertes, ${alertes.filter(r => r._d <= DELAI_CRITIQUE).length} marché(s) sont en situation critique (délai ≤ ${DELAI_CRITIQUE} jours) ` +
|
||||
`et ${alertes.length - alertes.filter(r => r._d <= DELAI_CRITIQUE).length} marché(s) nécessitent une attention renforcée.`
|
||||
));
|
||||
const regsSansModerni = ALL_REGIONS.filter(reg => !modActifs.some(r => (r.region || '') === reg));
|
||||
if (regsSansModerni.length) {
|
||||
children.push(para(
|
||||
`Concernant la Modernisation, ${regsSansModerni.length} région(s) ne disposent d'aucun marché actif (${regsSansModerni.join(', ')}). ` +
|
||||
`Conformément à la politique TT, un lancement immédiat est requis pour ces régions.`
|
||||
));
|
||||
}
|
||||
const projs = actifs.map(r => projection(r));
|
||||
children.push(para(
|
||||
`L'analyse prospective révèle ${projs.filter(p => p.verdict === 'Sous Min').length} marché(s) projetés sous le Montant Minimum ` +
|
||||
`et ${projs.filter(p => p.verdict === 'Avenant').length} en situation de dépassement nécessitant un avenant.`
|
||||
));
|
||||
|
||||
// ── Synthèse des Indicateurs ──────────────────────────────────────────────────
|
||||
children.push(h1('Synthèse des Indicateurs'));
|
||||
|
||||
const capexPhy = (() => { const v = capexRows.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a, b) => a + b) / v.length : 0; })();
|
||||
const opexPhy = (() => { const v = opexRows.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a, b) => a + b) / v.length : 0; })();
|
||||
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Indicateur',3000],['GLOBAL',1800],['CAPEX',1800],['OPEX',1800]], NAVY),
|
||||
new TableRow({ children: [hdrCell('Indicateur',3000,NAVY), hdrCell('GLOBAL',1800,'0B2A55'), hdrCell('CAPEX',1800,'059669'), hdrCell('OPEX',1800,'D97706')] }),
|
||||
dataRow([['Marchés actifs',3000], [actifs.length,1800,AlignmentType.CENTER], [capexRows.length,1800,AlignmentType.CENTER], [opexRows.length,1800,AlignmentType.CENTER]]),
|
||||
dataRow([['Budget total',3000], [fmtMDT(totalBudget),1800,AlignmentType.CENTER], [fmtMDT(capexBudget),1800,AlignmentType.CENTER], [fmtMDT(opexBudget),1800,AlignmentType.CENTER]], true),
|
||||
dataRow([['Avancement physique',3000],[fmtPct(avgPhy),1800,AlignmentType.CENTER], [fmtPct(capexPhy),1800,AlignmentType.CENTER], [fmtPct(opexPhy),1800,AlignmentType.CENTER]]),
|
||||
dataRow([['Avancement financier',3000],[fmtPct(avgFin),1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER]], true),
|
||||
dataRow([['Alertes (total)',3000], [alertes.length,1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER]]),
|
||||
dataRow([['Critiques (≤45j)',3000], [alertes.filter(a=>a._d<=DELAI_CRITIQUE).length,1800,AlignmentType.CENTER,true,'DC2626'], ['—',1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER]], true),
|
||||
],
|
||||
}));
|
||||
children.push(spacer());
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Alertes ───────────────────────────────────────────────────────────────────
|
||||
children.push(h1('Alertes'));
|
||||
children.push(para(`${alertes.length} marché(s) nécessitent une attention immédiate : ${alertes.filter(a=>a._d<=DELAI_CRITIQUE).length} critiques et ${alertes.filter(a=>a._d>DELAI_CRITIQUE).length} en surveillance renforcée.`, 20, '475569'));
|
||||
|
||||
if (alertes.length) {
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Référence',2800],['Projet',2400],['Entrepreneur',2000],['Région',1200],['Av. Phy',900],['Délai',800],['Niveau',1300]]),
|
||||
...alertes.map((r, i) => {
|
||||
const al = niveauAlerte(r._d);
|
||||
const alLabel = { critique: 'CRITIQUE', attention: 'ATTENTION' }[al] || String(al || '').toUpperCase();
|
||||
const alColor = al === 'critique' ? 'DC2626' : al === 'attention' ? 'EA580C' : '059669';
|
||||
return dataRow([
|
||||
[buildRef(r), 2800],
|
||||
[r.projet || '', 2400],
|
||||
[r.entrepreneur || '', 2000],
|
||||
[r.region || '', 1200, AlignmentType.CENTER],
|
||||
[fmtPct(r.taux_phy || r.avt_phy), 900, AlignmentType.CENTER],
|
||||
[r._d !== null ? r._d + ' j' : '—', 800, AlignmentType.CENTER, true, alColor],
|
||||
[alLabel, 1300, AlignmentType.CENTER, true, alColor],
|
||||
], i % 2 === 1);
|
||||
}),
|
||||
],
|
||||
}));
|
||||
}
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Modernisation ─────────────────────────────────────────────────────────────
|
||||
children.push(h1('Modernisation'));
|
||||
children.push(para(`Seuil : avancement physique ≥ ${SEUIL_MODERNISATION}% — la région devra procéder au lancement d'un nouveau marché.`, 20, '475569'));
|
||||
|
||||
const modEnSeuil = modActifs.filter(r => parseNum(r.taux_phy || r.avt_phy) >= SEUIL_MODERNISATION);
|
||||
children.push(h2(`⚡ Marchés Modernisation ≥ ${SEUIL_MODERNISATION}%`, 'D97706'));
|
||||
if (modEnSeuil.length) {
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Référence',2800],['Entrepreneur',2200],['Région',1400],['Av. Phy',900],['Délai',800],['Action',3100]], 'D97706'),
|
||||
...modEnSeuil.map((r, i) => {
|
||||
const phy = parseNum(r.taux_phy || r.avt_phy);
|
||||
const d = getDelaiRestant(r);
|
||||
return dataRow([
|
||||
[buildRef(r), 2800],
|
||||
[r.entrepreneur||'', 2200],
|
||||
[r.region||'', 1400, AlignmentType.CENTER],
|
||||
[fmtPct(phy), 900, AlignmentType.CENTER, true, phy>=SEUIL_CRITIQUE_PCT?'DC2626':'D97706'],
|
||||
[d!==null?d+' j':'—', 800, AlignmentType.CENTER],
|
||||
['Lancer nouveau marché', 3100, AlignmentType.LEFT, true, 'D97706'],
|
||||
], i % 2 === 1);
|
||||
}),
|
||||
],
|
||||
}));
|
||||
} else {
|
||||
children.push(para('Aucun marché modernisation au seuil de déclenchement.', 20, '475569'));
|
||||
}
|
||||
children.push(spacer());
|
||||
|
||||
children.push(h2('🔴 Régions sans marché Modernisation actif', 'DC2626'));
|
||||
if (regsSansModerni.length) {
|
||||
for (const reg of regsSansModerni) {
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: reg, bold: true, color: 'DC2626', size: 24 })],
|
||||
spacing: { after: 60 },
|
||||
}));
|
||||
}
|
||||
children.push(para('→ Lancement immédiat d\'un nouveau marché Modernisation requis pour chacune de ces régions.', 20, 'DC2626'));
|
||||
} else {
|
||||
children.push(para('Toutes les régions disposent d\'un marché Modernisation actif.', 20, '059669'));
|
||||
}
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Synthèse par Région ───────────────────────────────────────────────────────
|
||||
children.push(h1('Synthèse par Région'));
|
||||
children.push(para('Vue consolidée des 7 régions de la Zone Sud.', 20, '475569'));
|
||||
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Région',1800],['Marchés',1000],['Budget',2000],['Av. Physique',1500],['Av. Financier',1500],['Risque',1600]]),
|
||||
...ALL_REGIONS.map((reg, i) => {
|
||||
const ra = actifs.filter(r => (r.region || '') === reg);
|
||||
const bud = ra.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||
const pl = ra.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0);
|
||||
const pm = pl.length ? pl.reduce((a, b) => a + b) / pl.length : 0;
|
||||
const fl = ra.map(r => parseNum(r.taux_fin)).filter(v => v > 0);
|
||||
const fm = fl.length ? fl.reduce((a, b) => a + b) / fl.length : 0;
|
||||
const al = ra.filter(r => { const d = getDelaiRestant(r); return d !== null && d <= DELAI_CRITIQUE; });
|
||||
const risque = al.length > 0 ? 'CRITIQUE' : pm < SEUIL_STANDARD ? 'ATTENTION' : 'NORMAL';
|
||||
const rColor = risque === 'CRITIQUE' ? 'DC2626' : risque === 'ATTENTION' ? 'D97706' : '059669';
|
||||
return dataRow([
|
||||
[reg, 1800, AlignmentType.LEFT, true, REGION_HEX[reg]||NAVY],
|
||||
[ra.length, 1000, AlignmentType.CENTER],
|
||||
[fmtMDT(bud), 2000, AlignmentType.CENTER],
|
||||
[fmtPct(pm), 1500, AlignmentType.CENTER, true, pm >= SEUIL_STANDARD ? '059669' : 'DC2626'],
|
||||
[fmtPct(fm), 1500, AlignmentType.CENTER],
|
||||
[risque, 1600, AlignmentType.CENTER, true, rColor],
|
||||
], i % 2 === 1);
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(spacer());
|
||||
|
||||
for (const region of ALL_REGIONS) {
|
||||
const regActifs = actifs.filter(r => (r.region || '') === region);
|
||||
if (!regActifs.length) continue;
|
||||
|
||||
const hexReg = REGION_HEX[region] || NAVY;
|
||||
const bud = regActifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||
const pl = regActifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0);
|
||||
const pm = pl.length ? pl.reduce((a, b) => a + b) / pl.length : 0;
|
||||
const fl = regActifs.map(r => parseNum(r.taux_fin)).filter(v => v > 0);
|
||||
const fm = fl.length ? fl.reduce((a, b) => a + b) / fl.length : 0;
|
||||
|
||||
children.push(h1(`📍 ${region}`));
|
||||
children.push(bannerRow(`${regActifs.length} marchés • Phy ${pm.toFixed(0)}% • Fin ${fm.toFixed(0)}%`, hexReg));
|
||||
|
||||
children.push(para(
|
||||
`La région ${region} compte ${regActifs.length} marché(s) pour un budget total de ${bud.toLocaleString('fr-FR', { minimumFractionDigits: 3 })} DT. ` +
|
||||
`L'avancement physique moyen est de ${pm.toFixed(0)}% et l'avancement financier de ${fm.toFixed(0)}%.`
|
||||
));
|
||||
|
||||
const enService = regActifs.filter(r =>
|
||||
String(selectVal(r.observation)).toLowerCase().includes('en service'));
|
||||
const enCours = regActifs.filter(r =>
|
||||
!String(selectVal(r.observation)).toLowerCase().includes('en service'));
|
||||
|
||||
if (enService.length) {
|
||||
children.push(h2('✅ Marchés en service', '059669'));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Référence',2600],['Projet',2200],['Entrepreneur',1800],['Période',1800],['Montant',1200],['Phy',700],['Fin',700],['Délai',700]], hexReg),
|
||||
...enService.map((r, i) => {
|
||||
const phy = parseNum(r.taux_phy || r.avt_phy);
|
||||
const fin = parseNum(r.taux_fin);
|
||||
const d = getDelaiRestant(r);
|
||||
return dataRow([
|
||||
[buildRef(r), 2600],
|
||||
[r.projet||'', 2200],
|
||||
[r.entrepreneur||'', 1800],
|
||||
[periode(r), 1800],
|
||||
[fmtMDT(r.tot_marche||r.montant), 1200, AlignmentType.CENTER],
|
||||
[fmtPct(phy), 700, AlignmentType.CENTER],
|
||||
[fmtPct(fin), 700, AlignmentType.CENTER],
|
||||
[d!==null?d+' j':'—', 700, AlignmentType.CENTER],
|
||||
], i % 2 === 1);
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(spacer());
|
||||
}
|
||||
|
||||
if (enCours.length) {
|
||||
children.push(h2('⏳ Marchés en cours'));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Référence',3000],['Projet',2500],['Entrepreneur',2000],['Observation',3900]], '1E40AF'),
|
||||
...enCours.map((r, i) => dataRow([
|
||||
[buildRef(r), 3000],
|
||||
[r.projet||'', 2500],
|
||||
[r.entrepreneur||'', 2000],
|
||||
[selectVal(r.observation)||'—', 3900],
|
||||
], i % 2 === 1)),
|
||||
],
|
||||
}));
|
||||
children.push(spacer());
|
||||
}
|
||||
}
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Estimation & Projection ───────────────────────────────────────────────────
|
||||
children.push(h1('Estimation & Projection'));
|
||||
children.push(para('Projection fin PO basée sur le taux de consommation mensuel. Modernisation exemptée d\'avenant.', 20, '475569'));
|
||||
|
||||
const projRows = actifs.map(r => ({ r, p: projection(r) }));
|
||||
const verdictColors = { Normal: '059669', 'Sous Min': 'DC2626', Avenant: 'D97706' };
|
||||
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Référence',2600],['Projet',2000],['Marché DT',1200],['Min DT',1100],['Consommé',1100],['DT/Mois',1100],['Projeté',1100],['Verdict',1100]], '6366F1'),
|
||||
...projRows.map(({ r, p }, i) => dataRow([
|
||||
[buildRef(r), 2600],
|
||||
[r.projet||'', 2000],
|
||||
[fmtMDT(p.marche), 1200, AlignmentType.CENTER],
|
||||
[fmtMDT(p.minDT), 1100, AlignmentType.CENTER],
|
||||
[fmtMDT(p.consomme), 1100, AlignmentType.CENTER],
|
||||
[fmtMDT(p.parMois), 1100, AlignmentType.CENTER],
|
||||
[fmtMDT(p.projete), 1100, AlignmentType.CENTER],
|
||||
[p.verdict, 1100, AlignmentType.CENTER, true, verdictColors[p.verdict]||'1E293B'],
|
||||
], i % 2 === 1)),
|
||||
],
|
||||
}));
|
||||
|
||||
const nNormal = projRows.filter(x => x.p.verdict === 'Normal').length;
|
||||
const nSous = projRows.filter(x => x.p.verdict === 'Sous Min').length;
|
||||
const nAv = projRows.filter(x => x.p.verdict === 'Avenant').length;
|
||||
children.push(spacer());
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: `✅ Normal`, bold:true, color:'059669', size:20 }), new TextRun({ text: ` ${nNormal}`, bold:true, size:24 })], alignment: AlignmentType.CENTER })], width:{size:33,type:WidthType.PERCENTAGE} }),
|
||||
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: `❌ Sous Min`, bold:true, color:'DC2626', size:20 }), new TextRun({ text: ` ${nSous}`, bold:true, size:24 })], alignment: AlignmentType.CENTER })], width:{size:33,type:WidthType.PERCENTAGE} }),
|
||||
new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: `⚠️ Avenant`, bold:true, color:'D97706', size:20 }), new TextRun({ text: ` ${nAv}`, bold:true, size:24 })], alignment: AlignmentType.CENTER })], width:{size:34,type:WidthType.PERCENTAGE} }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Matrice de Risque Globale ─────────────────────────────────────────────────
|
||||
children.push(h1('Matrice de Risque Globale'));
|
||||
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
hdrRow([['Région',1200],['Marchés',800],['Budget',1400],['Phy',800],['Fin',800],['Écart',800],['Projeté',1200],['Tendance',1000],['Risque',1000]]),
|
||||
...ALL_REGIONS.map((reg, i) => {
|
||||
const ra = actifs.filter(r => (r.region || '') === reg);
|
||||
const bud = ra.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||
const pl = ra.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0);
|
||||
const pm = pl.length ? pl.reduce((a, b) => a + b) / pl.length : 0;
|
||||
const fl = ra.map(r => parseNum(r.taux_fin)).filter(v => v > 0);
|
||||
const fm = fl.length ? fl.reduce((a, b) => a + b) / fl.length : 0;
|
||||
const ecart = pm - fm;
|
||||
const rProjs = ra.map(r => projection(r));
|
||||
const projTotal = rProjs.reduce((s, p) => s + p.projete, 0);
|
||||
const tendance = pm > fm ? '📈 Hausse phy' : pm < fm ? '📉 Hausse fin' : '➡️ Équilibre';
|
||||
const critiques = ra.filter(r => { const d = getDelaiRestant(r); return d !== null && d <= DELAI_CRITIQUE; });
|
||||
const risque = critiques.length > 0 ? 'CRITIQUE' : pm < SEUIL_STANDARD ? 'ÉLEVÉ' : 'NORMAL';
|
||||
const rc = risque === 'CRITIQUE' ? 'DC2626' : risque === 'ÉLEVÉ' ? 'D97706' : '059669';
|
||||
return dataRow([
|
||||
[reg, 1200, AlignmentType.LEFT, true, REGION_HEX[reg]||NAVY],
|
||||
[ra.length, 800, AlignmentType.CENTER],
|
||||
[fmtMDT(bud), 1400, AlignmentType.CENTER],
|
||||
[fmtPct(pm), 800, AlignmentType.CENTER],
|
||||
[fmtPct(fm), 800, AlignmentType.CENTER],
|
||||
[`${ecart >= 0 ? '+' : ''}${ecart.toFixed(0)}%`, 800, AlignmentType.CENTER, false, ecart >= 0 ? '059669' : 'DC2626'],
|
||||
[fmtMDT(projTotal), 1200, AlignmentType.CENTER],
|
||||
[tendance, 1000, AlignmentType.CENTER],
|
||||
[risque, 1000, AlignmentType.CENTER, true, rc],
|
||||
], i % 2 === 1);
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Recommandations ───────────────────────────────────────────────────────────
|
||||
children.push(h1('Recommandations'));
|
||||
|
||||
const recs = [
|
||||
[`1. ⚡ Modernisation ≥${SEUIL_MODERNISATION}%`, `${modEnSeuil.map(r => r.region).filter((v,i,a)=>a.indexOf(v)===i).join(', ')||'Aucun'} — Procéder au lancement d'un nouveau marché pour assurer la continuité du service.`],
|
||||
[`2. ❌ Sous-consommation`, `${projRows.filter(x=>x.p.verdict==='Sous Min').length} marché(s) projeté(s) sous le montant minimum. Accélérer les réceptions de travaux.`],
|
||||
[`3. 📈 Dépassements`, `${projRows.filter(x=>x.p.verdict==='Avenant').length} marché(s) au-delà du montant marché. Préparer les avenants nécessaires.`],
|
||||
[`4. 🚀 Régions sans Modernisation`, `${regsSansModerni.join(', ')||'Aucune'} — Lancement immédiat d'un nouveau marché Modernisation requis.`],
|
||||
[`5. 📋 Suivi mensuel`, `Maintenir le reporting mensuel avec mise à jour des avancements physiques et financiers.`],
|
||||
[`6. 🎯 Objectif`, `100% d'exécution avant échéance PO pour tous les marchés actifs.`],
|
||||
];
|
||||
|
||||
for (const [titre, contenu] of recs) {
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({ children: [new TextRun({ text: titre, bold: true, color: NAVY, size: 22 })], spacing: { after: 60 } }),
|
||||
new Paragraph({ children: [new TextRun({ text: contenu, color: '374151', size: 20 })] }),
|
||||
],
|
||||
shading: cellShade('F8FAFC'),
|
||||
margins: { top: 120, bottom: 120, left: 200, right: 200 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
borders: { top: { style: BorderStyle.SINGLE, size: 4, color: '0680C3' }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } },
|
||||
}));
|
||||
children.push(spacer());
|
||||
}
|
||||
|
||||
// Légende
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [
|
||||
new Paragraph({ children: [new TextRun({ text: '📖 Légende', bold: true, color: NAVY, size: 22 })], spacing: { after: 80 } }),
|
||||
new Paragraph({ children: [new TextRun({ text: `Seuils avancement : 🟢 ≥ ${SEUIL_STANDARD}% Normal • 🟡 seuil Modernisation ≥ ${SEUIL_MODERNISATION}% • 🔴 CRITIQUE ≥ ${SEUIL_CRITIQUE_PCT}%`, size: 18, color: '475569' })] }),
|
||||
new Paragraph({ children: [new TextRun({ text: `Délais : 🟢 > ${DELAI_ATTENTION}j • 🟡 ≤ ${DELAI_ATTENTION}j ATTENTION • 🔴 ≤ ${DELAI_CRITIQUE}j CRITIQUE`, size: 18, color: '475569' })] }),
|
||||
],
|
||||
shading: cellShade('F8FAFC'),
|
||||
margins: { top: 120, bottom: 120, left: 200, right: 200 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
borders: { top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } },
|
||||
}));
|
||||
children.push(spacer());
|
||||
|
||||
// Pied de page
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({ children: [
|
||||
new Paragraph({ children: [new TextRun({ text: 'Nabil Derouiche', bold:true, color:WH, size:22 })], alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ children: [new TextRun({ text: 'Responsable Achats Zone Sud', color:'CBD5E1', size:18 })], alignment: AlignmentType.CENTER }),
|
||||
], shading: cellShade(NAVY), margins: { top:100, bottom:100 }, width:{size:50,type:WidthType.PERCENTAGE} }),
|
||||
new TableCell({ children: [
|
||||
new Paragraph({ children: [new TextRun({ text: 'TUNISIE TELECOM', bold:true, color:WH, size:22 })], alignment: AlignmentType.CENTER }),
|
||||
new Paragraph({ children: [new TextRun({ text: `Sfax — ${today}`, color:'CBD5E1', size:18 })], alignment: AlignmentType.CENTER }),
|
||||
], shading: cellShade(NAVY), margins: { top:100, bottom:100 }, width:{size:50,type:WidthType.PERCENTAGE} }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'RLA API',
|
||||
description: 'Rapport de Suivi des Marchés RLA Zone Sud — Tunisie Telecom',
|
||||
title: 'Rapport RLA Zone Sud',
|
||||
styles: {
|
||||
paragraphStyles: [
|
||||
{ id: 'Heading1', name: 'Heading 1', run: { size: 36, bold: true, color: NAVY } },
|
||||
{ id: 'Heading2', name: 'Heading 2', run: { size: 26, bold: true, color: NAVY } },
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
|
||||
return Packer.toBuffer(doc);
|
||||
}
|
||||
|
||||
module.exports = { generateDocx };
|
||||
|
|
@ -4,20 +4,18 @@
|
|||
*/
|
||||
const PDFDocument = require('pdfkit');
|
||||
|
||||
// Palette RLA — conforme fichier PDF cible
|
||||
// Palette RLA / McKinsey
|
||||
const C = {
|
||||
primary: '#0B2A55',
|
||||
accent: '#0680C3',
|
||||
success: '#059669',
|
||||
warning: '#D97706',
|
||||
danger: '#DC2626',
|
||||
muted: '#475569',
|
||||
light: '#F8FAFC',
|
||||
border: '#E2E8F0',
|
||||
text: '#1E293B',
|
||||
white: '#FFFFFF',
|
||||
navy2: '#1E40AF',
|
||||
indigo: '#6366F1',
|
||||
primary: '#002D62',
|
||||
accent: '#E31837',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
muted: '#6b7280',
|
||||
light: '#f8fafc',
|
||||
border: '#e2e8f0',
|
||||
text: '#1e293b',
|
||||
white: '#ffffff',
|
||||
};
|
||||
|
||||
function hex(h) {
|
||||
|
|
|
|||
|
|
@ -1,209 +0,0 @@
|
|||
/**
|
||||
* services/export-pptx.js
|
||||
* PPTX conforme aux fichiers cibles :
|
||||
* Slide 1 : Couverture
|
||||
* Slide 2 : Alertes (header #DC2626, 8 cols, barres █/░)
|
||||
* Slides 3-N : CAPEX paginé (7 cols)
|
||||
* Slides N+1-M : OPEX paginé (7 cols)
|
||||
* Dernière : En cours / Hors service (4 cols)
|
||||
*/
|
||||
const PptxGenJS = require('pptxgenjs');
|
||||
const { parseNum, selectVal, parseDateFR, formatDateFR } = require('./calc');
|
||||
|
||||
const REGION_COLORS = {
|
||||
Gabes:'0891B2', Gafsa:'059669', Kebili:'7C3AED',
|
||||
Medenine:'2563EB', Sfax:'0B2A55', Tataouine:'0D9488', Tozeur:'6366F1',
|
||||
};
|
||||
|
||||
const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10);
|
||||
const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10);
|
||||
|
||||
function fmtMDT(v) {
|
||||
const n = parseNum(v);
|
||||
if (!n) return '—';
|
||||
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MDT`;
|
||||
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kDT`;
|
||||
return `${n.toFixed(0)} DT`;
|
||||
}
|
||||
|
||||
function fmtPct(v) {
|
||||
const n = parseNum(v);
|
||||
return n === 0 ? '0 %' : `${n.toFixed(0)} %`;
|
||||
}
|
||||
|
||||
function periode(r) {
|
||||
const d = formatDateFR(r.date_debut || r.debut_marche);
|
||||
const f = formatDateFR(r.date_fin || r.date_fin_marche);
|
||||
if (d === '—' && f === '—') return '—';
|
||||
return `${d} → ${f}`;
|
||||
}
|
||||
|
||||
function progBar(pctRaw, len = 14) {
|
||||
const pct = Math.min(100, Math.max(0, parseNum(pctRaw)));
|
||||
const n = Math.round(pct / 100 * len);
|
||||
return '\u2588'.repeat(n) + '\u2591'.repeat(len - n) + ` ${pct.toFixed(0)}%`;
|
||||
}
|
||||
|
||||
function getDelai(r) {
|
||||
const v = parseInt(String(r.delai_restant || ''), 10);
|
||||
if (!isNaN(v)) return v;
|
||||
const dt = parseDateFR(r.date_fin || r.date_fin_marche);
|
||||
if (!dt) return null;
|
||||
return Math.ceil((dt - new Date()) / 86400000);
|
||||
}
|
||||
|
||||
function niveauAlerte(d) {
|
||||
if (d === null || d === undefined) return { label: '—', color: '94A3B8' };
|
||||
if (d <= DELAI_CRITIQUE) return { label: 'CRITIQUE', color: 'DC2626' };
|
||||
if (d <= DELAI_ATTENTION) return { label: 'ATTENTION', color: 'EA580C' };
|
||||
return { label: 'OK', color: '059669' };
|
||||
}
|
||||
|
||||
function txt(text, options) {
|
||||
return { text: String(text ?? '—'), options };
|
||||
}
|
||||
|
||||
async function generatePptx(actifs, _clotures, filtered) {
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.layout = 'LAYOUT_WIDE';
|
||||
pptx.author = 'RLA API';
|
||||
pptx.company = 'Tunisie Telecom Zone Sud';
|
||||
|
||||
const today = new Date().toLocaleDateString('fr-FR');
|
||||
const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||
|
||||
const capex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('CAPEX'));
|
||||
const opex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('OPEX'));
|
||||
const enCours = (filtered || actifs).filter(r =>
|
||||
['en cours', 'raccordement', 'hors service', 'en attente'].some(k =>
|
||||
String(selectVal(r.observation)).toLowerCase().includes(k)));
|
||||
|
||||
// ── Slide 1 : Couverture ──────────────────────────────────────────────────────
|
||||
const s1 = pptx.addSlide();
|
||||
s1.background = { color: 'FFFFFF' };
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 2.6, fill: { color: '0B2A55' }, line: { color: '0B2A55' } });
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 0, y: 2.6, w: '100%', h: 0.09, fill: { color: '0680C3' }, line: { color: '0680C3' } });
|
||||
s1.addText('TUNISIE TELECOM', { x: 0.4, y: 0.2, w: '90%', h: 0.4, fontSize: 11, color: '94A3B8', align: 'center' });
|
||||
s1.addText('Direction Centrale Achats', { x: 0.4, y: 0.58, w: '90%', h: 0.32, fontSize: 9.5, color: 'CBD5E1', align: 'center' });
|
||||
s1.addText('SITUATION DES MARCHÉS RLA', { x: 0.4, y: 1.08, w: '90%', h: 0.75, fontSize: 25, color: 'FFFFFF', bold: true, align: 'center' });
|
||||
s1.addText('Zone Sud — Axe Achats', { x: 0.4, y: 1.83, w: '90%', h: 0.45, fontSize: 14, color: '00D4FF', align: 'center' });
|
||||
s1.addText(today, { x: 0.4, y: 3.0, w: '90%', h: 0.55, fontSize: 20, color: '0680C3', bold: true, align: 'center' });
|
||||
s1.addText(`${actifs.length} marchés • ${fmtMDT(totalBudget)}`, { x: 0.4, y: 3.65, w: '90%', h: 0.35, fontSize: 11, color: '475569', align: 'center' });
|
||||
s1.addText('Nabil Derouiche • Responsable Achats Zone Sud', { x: 0.4, y: 4.5, w: '90%', h: 0.3, fontSize: 9.5, color: '475569', align: 'center' });
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 0, y: 5.4, w: '100%', h: 0.1, fill: { color: '0680C3' }, line: { color: '0680C3' } });
|
||||
|
||||
// ── Slide 2 : Alertes ─────────────────────────────────────────────────────────
|
||||
const alertes = actifs
|
||||
.map(r => ({ ...r, _d: getDelai(r) }))
|
||||
.filter(r => r._d !== null && r._d <= DELAI_ATTENTION)
|
||||
.sort((a, b) => a._d - b._d);
|
||||
|
||||
const s2 = pptx.addSlide();
|
||||
s2.background = { color: 'FAFAFA' };
|
||||
s2.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.72, fill: { color: 'DC2626' }, line: { color: 'DC2626' } });
|
||||
s2.addText('ALERTES DÉLAIS', { x: 0.3, y: 0.1, w: '55%', h: 0.5, fontSize: 17, bold: true, color: 'FFFFFF' });
|
||||
s2.addText(`${alertes.filter(r => r._d <= DELAI_CRITIQUE).length} critique(s) • ${alertes.length} total`,
|
||||
{ x: 7, y: 0.15, w: 5.5, h: 0.38, fontSize: 10, color: 'FEE2E2', align: 'right' });
|
||||
|
||||
const alHdr = ['Région','Désignation','Projet','Entrepreneur','Période','Avt Phy','Délai PO','Alerte'].map(t =>
|
||||
txt(t, { bold: true, color: 'FFFFFF', fill: '7F1D1D', fontSize: 8, valign: 'middle' }));
|
||||
|
||||
const alRows = alertes.slice(0, 15).map(r => {
|
||||
const al = niveauAlerte(r._d);
|
||||
const phy = parseNum(r.taux_phy || r.avt_phy);
|
||||
return [
|
||||
txt(r.region || '—', { fontSize: 7.5, color: REGION_COLORS[r.region] || '1E293B', bold: true }),
|
||||
txt(r.ref || '', { fontSize: 7, color: '1E293B' }),
|
||||
txt(r.projet || '', { fontSize: 7.5, color: '1E293B' }),
|
||||
txt(r.entrepreneur || '', { fontSize: 7.5, color: '1E293B' }),
|
||||
txt(periode(r), { fontSize: 6.5, color: '475569' }),
|
||||
txt(progBar(phy, 13), { fontSize: 6.5, color: '1E293B', fontFace: 'Courier New' }),
|
||||
txt(r._d !== null ? r._d + ' j' : '—', { fontSize: 8, bold: true, color: al.color, align: 'center' }),
|
||||
txt(al.label, { fontSize: 7.5, bold: true, color: al.color, align: 'center' }),
|
||||
];
|
||||
});
|
||||
|
||||
if (alRows.length) {
|
||||
s2.addTable([alHdr, ...alRows], {
|
||||
x: 0.15, y: 0.82, w: 13.15, fontSize: 7.5,
|
||||
border: { type: 'solid', color: 'E2E8F0' },
|
||||
colW: [1.1, 1.9, 2.0, 2.0, 1.75, 2.1, 0.8, 0.85],
|
||||
rowH: 0.28,
|
||||
});
|
||||
} else {
|
||||
s2.addText('Aucune alerte active.', { x: 0.3, y: 2.5, w: 13, h: 0.5, fontSize: 14, color: '059669', align: 'center' });
|
||||
}
|
||||
s2.addText(`Tunisie Telecom • Zone Sud • ${today}`, { x: 0, y: 5.42, w: '100%', h: 0.15, fontSize: 7, color: '94A3B8', align: 'center' });
|
||||
|
||||
// ── Slides CAPEX / OPEX ───────────────────────────────────────────────────────
|
||||
function addMarcheSlides(rows, typeLabel, headerColor) {
|
||||
const PER = 13;
|
||||
const total = Math.ceil(rows.length / PER) || 1;
|
||||
for (let p = 0; p < total; p++) {
|
||||
const chunk = rows.slice(p * PER, (p + 1) * PER);
|
||||
const s = pptx.addSlide();
|
||||
s.background = { color: 'FAFAFA' };
|
||||
s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.7, fill: { color: headerColor }, line: { color: headerColor } });
|
||||
s.addText(`MARCHÉS ${typeLabel}`, { x: 0.3, y: 0.1, w: '65%', h: 0.5, fontSize: 16, bold: true, color: 'FFFFFF' });
|
||||
s.addText(`(${p + 1}/${total})`, { x: 10.5, y: 0.12, w: 2.5, h: 0.42, fontSize: 13, color: 'FFFFFF', align: 'right' });
|
||||
|
||||
const hdr = ['Désignation','Projet','Entrepreneur','Période','Délai PO','Avt Fin %','Avt Phy %'].map(t =>
|
||||
txt(t, { bold: true, color: 'FFFFFF', fill: headerColor, fontSize: 8, valign: 'middle' }));
|
||||
|
||||
const dataRows = chunk.map(r => {
|
||||
const phy = parseNum(r.taux_phy || r.avt_phy);
|
||||
const fin = parseNum(r.taux_fin);
|
||||
const d = getDelai(r);
|
||||
const al = niveauAlerte(d);
|
||||
return [
|
||||
txt(r.ref || '', { fontSize: 7, color: '1E293B' }),
|
||||
txt(r.projet || '', { fontSize: 7.5, color: '1E293B' }),
|
||||
txt(r.entrepreneur || '', { fontSize: 7.5, color: '1E293B' }),
|
||||
txt(periode(r), { fontSize: 6.5, color: '475569' }),
|
||||
txt(d !== null ? d + ' j' : '—', { fontSize: 8, bold: true, color: al.color, align: 'center' }),
|
||||
txt(fmtPct(fin), { fontSize: 8, align: 'center', color: fin >= 70 ? '059669' : 'DC2626' }),
|
||||
txt(progBar(phy, 10), { fontSize: 6.5, color: '1E293B', fontFace: 'Courier New' }),
|
||||
];
|
||||
});
|
||||
|
||||
s.addTable([hdr, ...dataRows], {
|
||||
x: 0.15, y: 0.79, w: 13.15, fontSize: 7.5,
|
||||
border: { type: 'solid', color: 'E2E8F0' },
|
||||
colW: [2.35, 2.2, 2.1, 1.9, 0.85, 0.85, 2.9],
|
||||
rowH: 0.31,
|
||||
});
|
||||
s.addText(`Tunisie Telecom • Zone Sud • ${today}`, { x: 0, y: 5.42, w: '100%', h: 0.15, fontSize: 7, color: '94A3B8', align: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
addMarcheSlides(capex, 'CAPEX', '059669');
|
||||
addMarcheSlides(opex, 'OPEX', 'D97706');
|
||||
|
||||
// ── Slide finale : En cours / Hors service ────────────────────────────────────
|
||||
const sLast = pptx.addSlide();
|
||||
sLast.background = { color: 'FAFAFA' };
|
||||
sLast.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.7, fill: { color: '1E40AF' }, line: { color: '1E40AF' } });
|
||||
sLast.addText('MARCHÉS EN COURS / HORS SERVICE', { x: 0.3, y: 0.1, w: '85%', h: 0.5, fontSize: 15, bold: true, color: 'FFFFFF' });
|
||||
|
||||
const ecHdr = ['Désignation','Projet','Entrepreneur','Observation'].map(t =>
|
||||
txt(t, { bold: true, color: 'FFFFFF', fill: '1E40AF', fontSize: 9, valign: 'middle' }));
|
||||
|
||||
const ecRows = enCours.slice(0, 20).map(r => [
|
||||
txt(r.ref || r.id_marche || '', { fontSize: 8, color: '1E293B' }),
|
||||
txt(r.projet || '', { fontSize: 8, color: '1E293B' }),
|
||||
txt(r.entrepreneur || '', { fontSize: 8, color: '1E293B' }),
|
||||
txt(selectVal(r.observation) || '—', { fontSize: 8, color: '475569' }),
|
||||
]);
|
||||
|
||||
sLast.addTable([ecHdr, ...ecRows], {
|
||||
x: 0.15, y: 0.83, w: 13.15, fontSize: 8,
|
||||
border: { type: 'solid', color: 'E2E8F0' },
|
||||
colW: [3.3, 3.3, 3.0, 3.55],
|
||||
rowH: 0.33,
|
||||
});
|
||||
sLast.addText(`Tunisie Telecom • Zone Sud • ${today}`, { x: 0, y: 5.42, w: '100%', h: 0.15, fontSize: 7, color: '94A3B8', align: 'center' });
|
||||
|
||||
return pptx.write({ outputType: 'nodebuffer' });
|
||||
}
|
||||
|
||||
module.exports = { generatePptx };
|
||||
|
|
@ -2,8 +2,7 @@
|
|||
* 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 ExcelJS = require('exceljs');
|
||||
|
||||
const C = {
|
||||
NAVY: 'FF002D62',
|
||||
|
|
@ -245,7 +244,7 @@ async function buildSheet1(wb, actifs) {
|
|||
subtotalFin += finDT;
|
||||
|
||||
const rd = ws.addRow([
|
||||
buildRef(r),
|
||||
r.id_marche || r.reference || '',
|
||||
r.projet || '',
|
||||
r.entrepreneur || '',
|
||||
nat,
|
||||
|
|
@ -315,120 +314,137 @@ async function buildSheet1(wb, actifs) {
|
|||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
const ws = wb.addWorksheet('Pilotage Proactif');
|
||||
ws.views = [{ state: 'frozen', ySplit: 9 }];
|
||||
|
||||
ws.columns = [
|
||||
{ width: 42 }, { width: 22 }, { width: 22 }, { width: 16 },
|
||||
{ width: 14 }, { width: 14 }, { width: 14 }, { width: 14 }, { width: 18 },
|
||||
{ width: 40 }, { width: 20 }, { width: 22 }, { width: 16 },
|
||||
{ width: 10 }, { width: 10 }, { width: 12 }, { width: 12 }, { 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('')]);
|
||||
// Title
|
||||
const r1 = ws.addRow(['📈 PILOTAGE PROACTIF — ZONE SUD', ...Array(8).fill('')]);
|
||||
r1.height = 30;
|
||||
ws.mergeCells(`A1:I1`);
|
||||
r1.getCell(1).fill = fill('FF6366F1');
|
||||
ws.mergeCells('A1:I1');
|
||||
r1.getCell(1).fill = fill(C.NAVY);
|
||||
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('')]);
|
||||
const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(8).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).fill = fill('FF0F172A');
|
||||
r2.getCell(1).font = font('FFCBD5E1', false, 11);
|
||||
r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
|
||||
const r3 = ws.addRow([`📅 ${today} │ 📋 ${actifs.length} marchés`, ...Array(COLS - 1).fill('')]);
|
||||
const r3 = ws.addRow([`📅 ${today} │ 📋 ${actifs.length} marchés actifs`, ...Array(8).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).fill = fill('FF1E3A5F');
|
||||
r3.getCell(1).font = font('FF94A3B8', 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);
|
||||
// KPIs
|
||||
const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD || 70);
|
||||
const SEUIL_CRIT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90);
|
||||
const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION || 50);
|
||||
|
||||
const classify = r => {
|
||||
const taux = parseNum(r.taux_phy || r.avt_phy);
|
||||
const nat = String(selectVal(r.nature) || '').toLowerCase();
|
||||
const seuil = nat.includes('modern') ? SEUIL_MOD : SEUIL_STD;
|
||||
if (taux === 0) return 'Non déterminé';
|
||||
if (taux >= SEUIL_CRIT) return 'Dépassement';
|
||||
if (taux >= seuil) return 'Normal';
|
||||
return 'Sous Avancement';
|
||||
};
|
||||
|
||||
const normal = actifs.filter(r => classify(r) === 'Normal').length;
|
||||
const sous = actifs.filter(r => classify(r) === 'Sous Avancement').length;
|
||||
const dep = actifs.filter(r => classify(r) === 'Dépassement').length;
|
||||
const nd = actifs.filter(r => classify(r) === 'Non déterminé').length;
|
||||
|
||||
const r5 = ws.addRow(['✅ NORMAL', '', '❌ SOUS AVANCEMENT', '', '⚡ DÉPASSEMENT', '', '❓ NON DÉTERMINÉ', '', '📊 TOTAL']);
|
||||
r5.height = 22;
|
||||
r5.eachCell(cell => {
|
||||
cell.fill = fill('FF6366F1');
|
||||
ws.mergeCells('A5:B5'); ws.mergeCells('C5:D5'); ws.mergeCells('E5:F5'); ws.mergeCells('G5:H5');
|
||||
[[1,C.GREEN],[3,'FFDC2626'],[5,C.ORANGE],[7,C.GRAY],[9,C.NAVY]].forEach(([col, argb]) => {
|
||||
r5.getCell(col).fill = fill(argb);
|
||||
r5.getCell(col).font = { color: { argb: C.WHITE }, bold: true, size: 10 };
|
||||
r5.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
});
|
||||
|
||||
const r6 = ws.addRow([normal, '', sous, '', dep, '', nd, '', actifs.length]);
|
||||
r6.height = 20;
|
||||
ws.mergeCells('A6:B6'); ws.mergeCells('C6:D6'); ws.mergeCells('E6:F6'); ws.mergeCells('G6:H6');
|
||||
[1, 3, 5, 7, 9].forEach(col => {
|
||||
r6.getCell(col).font = { bold: true, size: 18 };
|
||||
r6.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||
});
|
||||
|
||||
ws.addRow([]);
|
||||
ws.addRow([]);
|
||||
|
||||
// Column headers
|
||||
const HEADERS2 = ['Référence', 'Projet', 'Entrepreneur', 'Région',
|
||||
'Phy %', 'Fin %', 'Délai', 'Alerte', 'Résultat'];
|
||||
const r9 = ws.addRow(HEADERS2);
|
||||
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);
|
||||
});
|
||||
|
||||
const VERDICT_COLOR = { Normal: C.GREEN, 'Sous Min': 'FFDC2626', Avenant: 'FFEA580C' };
|
||||
const ALERTE_COLOR = { 'critique': 'FFDC2626', 'attention': 'FFEA580C', 'normal': C.GREEN, 'indéterminé': C.GRAY };
|
||||
const RESULT_COLOR = { 'Normal': C.GREEN, 'Sous Avancement': 'FFDC2626', 'Dépassement': C.ORANGE, 'Non déterminé': C.GRAY };
|
||||
|
||||
for (let i = 0; i < actifs.length; i++) {
|
||||
const r = actifs[i];
|
||||
const p = projection(r);
|
||||
const DELAI_CRIT = parseInt(process.env.DELAI_CRITIQUE || 45);
|
||||
const DELAI_ATT = parseInt(process.env.DELAI_ATTENTION || 90);
|
||||
const niveauAlerte = d => d === null ? 'indéterminé' : d <= DELAI_CRIT ? 'critique' : d <= DELAI_ATT ? 'attention' : 'normal';
|
||||
|
||||
const sorted = [...actifs].sort((a, b) => {
|
||||
const ta = parseNum(a.taux_phy || a.avt_phy);
|
||||
const tb = parseNum(b.taux_phy || b.avt_phy);
|
||||
return ta - tb;
|
||||
});
|
||||
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const r = sorted[i];
|
||||
const phyPct = parseNum(r.taux_phy || r.avt_phy);
|
||||
const finPct = parseNum(r.taux_fin);
|
||||
const delai = getDelai(r);
|
||||
const alerte = niveauAlerte(typeof delai === 'number' ? delai : null);
|
||||
const result = classify(r);
|
||||
|
||||
const rd = ws.addRow([
|
||||
buildRef(r),
|
||||
r.id_marche || r.reference || '',
|
||||
r.projet || '',
|
||||
r.entrepreneur || '',
|
||||
p.marche || '',
|
||||
p.minDT || '',
|
||||
p.consomme || '',
|
||||
p.parMois || '',
|
||||
p.projete || '',
|
||||
p.verdict,
|
||||
r.region || '',
|
||||
phyPct / 100,
|
||||
finPct / 100,
|
||||
typeof delai === 'number' ? delai : '-',
|
||||
alerte,
|
||||
result,
|
||||
]);
|
||||
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.getCell(5).numFmt = '0%';
|
||||
rd.getCell(6).numFmt = '0%';
|
||||
rd.getCell(8).font = { color: { argb: ALERTE_COLOR[alerte] || C.GRAY }, bold: true };
|
||||
rd.getCell(9).font = { color: { argb: RESULT_COLOR[result] || C.GRAY }, bold: true };
|
||||
rd.eachCell(cell => {
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } };
|
||||
if (!cell.alignment) cell.alignment = { vertical: 'middle' };
|
||||
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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue