feat(advalo): parité v1 (montants AXA, documents, cumul/reporting) + migration prod

Logique alignée sur advalorem v1 dans le module Advalo de etv:
- Facturation QT550: cotisation HT (Pos=329) + coût d'acte (Pos=1696) une seule
  fois — fin du double comptage (facturerBatch/createPonctuel).
- Documents Avenant/Attestation: valeurs réellement saisies (taux/prime/HT/acte/TTC)
  au lieu des valeurs figées, coordonnées agent propagées, templates reconstruits
  avec tableau de prix + récap transports via docxtemplater (+ _source pristine).
- Cumul/Reporting/Historique: region/dpt/souscripteur enrichis depuis advalo_ref_contrat
  (parité getVarByNumContrat).
- Mode périodique côté front (advalo-module.js) + Modification d'une demande hors
  grille dans l'Historique (parité v1).
- Tests Jest (formules, anti double-comptage, contexte doc, lookup, update).

Migration prod Excel→PocketBase fiabilisée et auto-portable:
- sources embarquées dans scripts/seed-data/ (repli sur repo v1 en dev),
- création idempotente des collections + import complet,
- validé de zéro sur base vierge: 188 users / 938 ref / 57234 déléguée / 23122 hors-grille.

Le binaire pb_data/data.db (38 Mo) sort du suivi git (régénérable via la migration);
le squelette pb_data_backup (admin + collections + régions) reste versionné.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alexis Burnaz 2026-06-10 15:58:33 +02:00
parent 70dd59b03e
commit c414de04aa
39 changed files with 84771 additions and 204 deletions

13
ecole/.env.example Normal file
View File

@ -0,0 +1,13 @@
DB_URL=http://127.0.0.1:8091/
DB_ADMIN=admin@example.local
DB_PASSWORD=change-me
NODE_ENV=development
PORT=8082
# Optional runtime overrides for Advalorem
# ADV_WORKSPACE_ROOT=/absolute/path/to/ecole
# Optional AXA bridge tuning
# AXA_TIMEOUT_MS=65000
# AXA_RETRY_COUNT=1
# AXA_RETRY_DELAY_MS=1200

21
ecole/.gitignore vendored
View File

@ -11,6 +11,14 @@ package-lock.json
logs/ logs/
*.log *.log
# PocketBase runtime data (local only)
src/db/pb_data/*.db
src/db/pb_data/*.db-shm
src/db/pb_data/*.db-wal
# Squelette versionné (data.db/logs.db) mais pas les transients
src/db/pb_data_backup/*.db-shm
src/db/pb_data_backup/*.db-wal
# Environment variables # Environment variables
.env .env
.env.local .env.local
@ -31,3 +39,16 @@ Thumbs.db
dist/ dist/
build/ build/
# Documents Advalo générés (données client) — régénérés à l'exécution
documents/
# Rapports de migration/bench (régénérés)
reports/
scripts/reports/
# Build du helper AXA C# (recompilé via dotnet publish)
src/axa-helper/bin/
src/axa-helper/obj/
# Runtime
.runtime/

22
ecole/README.md Normal file
View File

@ -0,0 +1,22 @@
# EasyTransport / Advalorem Runtime Notes
## Local PocketBase workflow
1. Bootstrap local DB files from tracked backup:
- `npm run db:bootstrap`
- `npm run db:bootstrap:reset` (force overwrite)
2. Start PocketBase on `DB_URL` host/port:
- `npm run db:start`
3. Start app:
- `npm run start`
## Advalorem runtime overrides
- `ADV_WORKSPACE_ROOT`: force workspace root for packaged/runtime environments
- Advalorem APIs (`/advalo/*`) run in PocketBase mode by default.
- No `sqlite3` CLI dependency is required for historique/cumul/reporting/export.
## Notes
- `pb_data/*.db*` are local runtime files and are no longer intended to be versioned.
- `src/db/pb_data_backup/` remains the baseline source for local bootstrap.

113
ecole/package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"axios": "^1.7.2", "axios": "^1.7.2",
"cjs": "^0.0.11", "cjs": "^0.0.11",
"csv-parse": "^5.5.6",
"docxtemplater": "^3.46.1", "docxtemplater": "^3.46.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"ejs": "^3.1.9", "ejs": "^3.1.9",
@ -24,7 +25,8 @@
"pizzip": "^3.1.6", "pizzip": "^3.1.6",
"pocketbase": "^0.15.3", "pocketbase": "^0.15.3",
"winston": "^3.13.0", "winston": "^3.13.0",
"winston-daily-rotate-file": "^4.7.1" "winston-daily-rotate-file": "^4.7.1",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"jest": "^29.6.0", "jest": "^29.6.0",
@ -1210,6 +1212,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@ -1770,6 +1781,19 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -1887,6 +1911,15 @@
"node": ">= 0.12.0" "node": ">= 0.12.0"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": { "node_modules/collect-v8-coverage": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
@ -2055,6 +2088,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-jest": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -2092,6 +2137,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/csv-parse": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "2.6.9", "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -2751,6 +2802,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@ -5987,6 +6047,18 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-trace": { "node_modules/stack-trace": {
"version": "0.0.10", "version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
@ -6681,6 +6753,24 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -6720,6 +6810,27 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
} }
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -5,6 +5,12 @@
"main": "./src/server.js", "main": "./src/server.js",
"scripts": { "scripts": {
"start": "nodemon ./src/server.js", "start": "nodemon ./src/server.js",
"db:start": "node ./scripts/db-start.js",
"db:bootstrap": "node ./scripts/db-bootstrap-local.js",
"db:bootstrap:reset": "node ./scripts/db-bootstrap-local.js --reset",
"advalo:migrate": "node ./scripts/advalo-migrate-v1-to-v2.js --reset",
"advalo:bench": "node ./scripts/advalo-bench.js",
"advalo:axa-smoke": "node ./scripts/axa-smoke.js",
"build": "pkg ./src/server.js -o EasyTransport", "build": "pkg ./src/server.js -o EasyTransport",
"test": "jest" "test": "jest"
}, },
@ -17,6 +23,7 @@
"dependencies": { "dependencies": {
"axios": "^1.7.2", "axios": "^1.7.2",
"cjs": "^0.0.11", "cjs": "^0.0.11",
"csv-parse": "^5.5.6",
"docxtemplater": "^3.46.1", "docxtemplater": "^3.46.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"ejs": "^3.1.9", "ejs": "^3.1.9",
@ -29,6 +36,7 @@
"numeral": "^2.0.6", "numeral": "^2.0.6",
"pizzip": "^3.1.6", "pizzip": "^3.1.6",
"pocketbase": "^0.15.3", "pocketbase": "^0.15.3",
"xlsx": "^0.18.5",
"winston": "^3.13.0", "winston": "^3.13.0",
"winston-daily-rotate-file": "^4.7.1" "winston-daily-rotate-file": "^4.7.1"
}, },

View File

@ -244,6 +244,7 @@ document.addEventListener('DOMContentLoaded', function () {
const panels = document.querySelectorAll('.advalo-panel'); const panels = document.querySelectorAll('.advalo-panel');
const confirmModal = M.Modal.init(document.getElementById('advalo-confirm-modal'), { dismissible: true }); const confirmModal = M.Modal.init(document.getElementById('advalo-confirm-modal'), { dismissible: true });
const batchModal = M.Modal.init(document.getElementById('advalo-batch-modal'), { dismissible: true }); const batchModal = M.Modal.init(document.getElementById('advalo-batch-modal'), { dismissible: true });
const editModal = M.Modal.init(document.getElementById('advalo-edit-modal'), { dismissible: true });
const getSelectedModes = () => [...document.querySelectorAll('.p-mode-check:checked')].map((el) => el.value).filter((v) => TRANSPORT_MODES.includes(v)); const getSelectedModes = () => [...document.querySelectorAll('.p-mode-check:checked')].map((el) => el.value).filter((v) => TRANSPORT_MODES.includes(v));
const syncModes = () => { const syncModes = () => {
@ -265,6 +266,119 @@ document.addEventListener('DOMContentLoaded', function () {
refreshTextFields(); refreshTextFields();
}; };
// Mode "périodique" (parité v1 switchFacturation): pas de saisie ligne à ligne ->
// marchandise/départ/arrivée/mode forcés à "Multiple"/"Divers", taux=0, et masquage
// des champs transport + taux/prime. Le mode "ponctuel" restaure la saisie détaillée.
const PERIODIQUE_HIDDEN_IDS = ['p-marchandise', 'p-depart', 'p-arrivee', 'p-taux', 'p-primeMin'];
const applyTypeFacturation = () => {
const periodique = document.querySelector('input[name="p-typeFacturation"]:checked')?.value === 'periodique';
const modeBlock = document.getElementById('p-mode') ? document.getElementById('p-mode').closest('.col') : null;
if (periodique) {
document.getElementById('p-marchandise').value = 'Divers marchandise(s)';
document.getElementById('p-depart').value = 'Multiple';
document.getElementById('p-arrivee').value = 'Multiple';
document.getElementById('p-taux').value = '0';
document.querySelectorAll('.p-mode-check').forEach((c) => { c.checked = false; });
document.getElementById('p-mode').value = 'Divers mode(s)';
} else {
['p-marchandise', 'p-depart', 'p-arrivee'].forEach((id) => {
const el = document.getElementById(id);
if (el && ['Divers marchandise(s)', 'Multiple'].includes(el.value)) el.value = '';
});
document.getElementById('p-taux').value = '0.3';
syncModes();
}
PERIODIQUE_HIDDEN_IDS.forEach((id) => {
const wrap = document.getElementById(id) ? document.getElementById(id).closest('.input-field') : null;
if (wrap) wrap.style.display = periodique ? 'none' : '';
});
if (modeBlock) modeBlock.style.display = periodique ? 'none' : '';
recalcPonctuelPricing();
refreshTextFields();
};
// Édition d'une demande hors grille non facturée (parité v1 "Modification" de l'Historique).
const cleanNum = (value) => String(value == null ? '' : value).replace(/[^\d.,]/g, '').replace(',', '.');
const getSelectedEditModes = () => [...document.querySelectorAll('.e-mode-check:checked')].map((el) => el.value).filter((v) => TRANSPORT_MODES.includes(v));
const syncEditModes = () => { document.getElementById('e-mode').value = getSelectedEditModes().join(', '); };
const recalcEditPricing = () => {
const capital = parseAmount(document.getElementById('e-capital').value);
const taux = parseAmount(document.getElementById('e-taux').value);
const primeMin = parseAmount(document.getElementById('e-primeMin').value);
const coutActe = parseAmount(document.getElementById('e-coutActe').value);
const cotisationHT = Math.max((capital * taux) / 100, primeMin);
const cotisationTTC = cotisationHT + coutActe;
document.getElementById('e-cotisationHT').value = cotisationHT.toFixed(2);
document.getElementById('e-cotisationTTC').value = cotisationTTC.toFixed(2);
document.getElementById('e-tarif').value = cotisationTTC.toFixed(2);
refreshTextFields();
};
const openEditModal = (typedId, row, details) => {
const d = details || {};
document.getElementById('e-id').value = typedId;
document.getElementById('e-marchandise').value = d.marchandise || row.marchandise || '';
document.getElementById('e-depart').value = d.depart || row.depart || '';
document.getElementById('e-arrivee').value = d.arrivee || row.arrivee || '';
document.getElementById('e-dateDebut').value = row.dateDebut || '';
document.getElementById('e-dateFin').value = row.dateFin || '';
const modes = String(d.mode || row.mode || '').split(',').map((s) => s.trim());
document.querySelectorAll('.e-mode-check').forEach((c) => { c.checked = modes.includes(c.value); });
syncEditModes();
document.getElementById('e-capital').value = cleanNum(d.valeurAssuree || row.capital);
document.getElementById('e-taux').value = d.taux ? cleanNum(d.taux) : '0.3';
document.getElementById('e-primeMin').value = d.primeMinimum ? cleanNum(d.primeMinimum) : '15';
document.getElementById('e-coutActe').value = d.coutActe ? cleanNum(d.coutActe) : '36';
// On préserve les montants existants (pas de recalcul à l'ouverture) ; le recalcul
// ne se déclenche que si l'utilisateur modifie capital/taux/prime/coût d'acte.
const ttc = cleanNum(d.cotisationTTC || row.tarif);
document.getElementById('e-cotisationHT').value = cleanNum(d.cotisationHT || row.tarif);
document.getElementById('e-cotisationTTC').value = ttc;
document.getElementById('e-tarif').value = ttc;
document.getElementById('e-form-error').textContent = '';
refreshTextFields();
initDateFields();
editModal.open();
};
const submitEdit = async () => {
const errorSlot = document.getElementById('e-form-error');
errorSlot.textContent = '';
syncEditModes();
const required = ['e-marchandise', 'e-depart', 'e-arrivee', 'e-dateDebut', 'e-dateFin', 'e-capital', 'e-taux', 'e-primeMin', 'e-cotisationHT', 'e-cotisationTTC'];
if (!requireFields(required)) {
errorSlot.textContent = 'Complète les champs obligatoires.';
return;
}
if (!document.getElementById('e-mode').value.trim()) {
errorSlot.textContent = 'Sélectionne au moins un mode de transport.';
return;
}
const id = document.getElementById('e-id').value;
try {
const payload = {
marchandise: document.getElementById('e-marchandise').value.trim(),
mode: document.getElementById('e-mode').value.trim(),
depart: document.getElementById('e-depart').value.trim(),
arrivee: document.getElementById('e-arrivee').value.trim(),
dateDebut: document.getElementById('e-dateDebut').value.trim(),
dateFin: document.getElementById('e-dateFin').value.trim(),
capital: document.getElementById('e-capital').value.trim(),
taux: document.getElementById('e-taux').value.trim(),
primeMinimum: document.getElementById('e-primeMin').value.trim(),
coutActe: document.getElementById('e-coutActe').value.trim(),
cotisationHT: document.getElementById('e-cotisationHT').value.trim(),
cotisationTTC: document.getElementById('e-cotisationTTC').value.trim(),
tarif: document.getElementById('e-cotisationTTC').value.trim()
};
await api(`/advalo/demande/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify(payload) });
editModal.close();
toast('Demande modifiée.', 'green');
await loadHistorique();
} catch (error) {
errorSlot.textContent = error.message;
toast(error.message);
}
};
const fillContractInfo = (info) => { const fillContractInfo = (info) => {
document.getElementById('p-numContrat').value = info.numContrat || document.getElementById('p-numContrat').value; document.getElementById('p-numContrat').value = info.numContrat || document.getElementById('p-numContrat').value;
document.getElementById('p-numClient').value = info.numClient || ''; document.getElementById('p-numClient').value = info.numClient || '';
@ -310,7 +424,7 @@ document.addEventListener('DOMContentLoaded', function () {
<div class="row" style="margin-bottom:0;"> <div class="row" style="margin-bottom:0;">
<div class="col s12 m4"><b>Acteur:</b> ${(details.actorPrenom || '')} ${(details.actorNom || '')} ${details.actorMatricule ? `(${details.actorMatricule})` : ''}</div> <div class="col s12 m4"><b>Acteur:</b> ${(details.actorPrenom || '')} ${(details.actorNom || '')} ${details.actorMatricule ? `(${details.actorMatricule})` : ''}</div>
<div class="col s12 m8 right-align"> <div class="col s12 m8 right-align">
${row.source === 'hors_grille' && String(row.statutFacturation || '').toLowerCase().includes('non') ? `<button class="btn red darken-3 advalo-h-delete" data-id="${typedId}">Supprimer</button>` : ''} ${row.source === 'hors_grille' && String(row.statutFacturation || '').toLowerCase().includes('non') ? `<button class="btn green darken-2 advalo-h-edit" data-id="${typedId}">Modifier</button> <button class="btn red darken-3 advalo-h-delete" data-id="${typedId}">Supprimer</button>` : ''}
</div> </div>
</div> </div>
</div> </div>
@ -397,6 +511,9 @@ document.addEventListener('DOMContentLoaded', function () {
} }
}); });
}); });
document.querySelectorAll('.advalo-h-edit').forEach((editBtn) => {
editBtn.addEventListener('click', () => openEditModal(editBtn.dataset.id, row || {}, details));
});
} catch (error) { } catch (error) {
toast(error.message); toast(error.message);
} }
@ -690,11 +807,21 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
document.querySelectorAll('.p-mode-check').forEach((input) => input.addEventListener('change', () => { syncModes(); refreshTextFields(); })); document.querySelectorAll('.p-mode-check').forEach((input) => input.addEventListener('change', () => { syncModes(); refreshTextFields(); }));
document.querySelectorAll('input[name="p-typeFacturation"]').forEach((radio) => radio.addEventListener('change', applyTypeFacturation));
['p-capital', 'p-taux', 'p-primeMin', 'p-coutActe'].forEach((id) => { ['p-capital', 'p-taux', 'p-primeMin', 'p-coutActe'].forEach((id) => {
const input = document.getElementById(id); const input = document.getElementById(id);
if (input) input.addEventListener('input', recalcPonctuelPricing); if (input) input.addEventListener('input', recalcPonctuelPricing);
}); });
// Modal d'édition (Historique) : recalcul + sauvegarde.
document.querySelectorAll('.e-mode-check').forEach((input) => input.addEventListener('change', () => { syncEditModes(); refreshTextFields(); }));
['e-capital', 'e-taux', 'e-primeMin', 'e-coutActe'].forEach((id) => {
const input = document.getElementById(id);
if (input) input.addEventListener('input', recalcEditPricing);
});
const editSaveBtn = document.getElementById('advalo-edit-save');
if (editSaveBtn) editSaveBtn.addEventListener('click', submitEdit);
document.getElementById('p-numContrat').addEventListener('blur', async () => { document.getElementById('p-numContrat').addEventListener('blur', async () => {
const value = String(document.getElementById('p-numContrat').value || '').trim(); const value = String(document.getElementById('p-numContrat').value || '').trim();
if (value.length === 16) { if (value.length === 16) {
@ -763,6 +890,7 @@ document.addEventListener('DOMContentLoaded', function () {
document.getElementById('p-taux').value = '0.3'; document.getElementById('p-taux').value = '0.3';
document.getElementById('p-primeMin').value = '15'; document.getElementById('p-primeMin').value = '15';
document.querySelector('input[name="p-typeFacturation"][value="ponctuel"]').checked = true; document.querySelector('input[name="p-typeFacturation"][value="ponctuel"]').checked = true;
applyTypeFacturation();
recalcPonctuelPricing(); recalcPonctuelPricing();
refreshTextFields(); refreshTextFields();
state.loaded.historique = false; state.loaded.historique = false;
@ -948,6 +1076,7 @@ document.addEventListener('DOMContentLoaded', function () {
initDateFields(); initDateFields();
initSelects(document); initSelects(document);
syncModes(); syncModes();
applyTypeFacturation();
recalcPonctuelPricing(); recalcPonctuelPricing();
refreshTextFields(); refreshTextFields();

View File

@ -0,0 +1,148 @@
#!/usr/bin/env node
/* eslint-disable no-console */
require('dotenv').config();
const http = require('http');
const fs = require('fs');
const path = require('path');
const BASE_URL = process.env.BENCH_BASE_URL || `http://127.0.0.1:${process.env.PORT || 8082}`;
const SAMPLES = Number(process.env.BENCH_SAMPLES || 15);
const CONCURRENCY = Number(process.env.BENCH_CONCURRENCY || 3);
const MATRICULE = process.env.BENCH_MATRICULE || 'S601153';
const REPORT_DIR = process.env.BENCH_REPORT_DIR
? path.resolve(process.env.BENCH_REPORT_DIR)
: path.resolve(__dirname, 'reports');
function request(method, path, token, body = null, responseType = 'json') {
return new Promise((resolve, reject) => {
const url = new URL(path, BASE_URL);
const payload = body ? JSON.stringify(body) : null;
const req = http.request({
method,
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {})
}
}, (res) => {
let raw = '';
res.on('data', (chunk) => { raw += chunk.toString(); });
res.on('end', () => {
if (res.statusCode >= 400) {
return reject(new Error(`${method} ${path} failed (${res.statusCode}) ${raw.slice(0, 300)}`));
}
if (responseType === 'text') {
return resolve(raw);
}
if (responseType === 'json') {
try {
const parsed = raw ? JSON.parse(raw) : {};
return resolve(parsed);
} catch (error) {
return reject(new Error(`Invalid JSON response for ${method} ${path}: ${error.message}`));
}
}
return reject(new Error(`Unsupported responseType '${responseType}' for ${method} ${path}`));
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
async function getToken() {
if (process.env.BENCH_TOKEN) return process.env.BENCH_TOKEN;
const auth = await request('GET', `/auth/verifyMatricule/${encodeURIComponent(MATRICULE)}`, null);
if (!auth.valid || !auth.token) {
throw new Error(`Unable to get token for matricule ${MATRICULE}`);
}
return auth.token;
}
function percentile(values, p) {
if (!values.length) return 0;
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.ceil((p / 100) * sorted.length) - 1;
return sorted[Math.max(0, Math.min(sorted.length - 1, idx))];
}
async function benchmarkEndpoint(token, endpoint) {
const durations = [];
const runOne = async () => {
const started = process.hrtime.bigint();
await request(
endpoint.method,
endpoint.path,
token,
endpoint.body || null,
endpoint.responseType || 'json'
);
const ended = process.hrtime.bigint();
durations.push(Number(ended - started) / 1_000_000);
};
await runOne(); // warm-up
let running = [];
for (let i = 0; i < SAMPLES; i += 1) {
running.push(runOne());
if (running.length >= CONCURRENCY) {
await Promise.all(running);
running = [];
}
}
if (running.length) await Promise.all(running);
return {
endpoint: endpoint.path,
samples: durations.length,
p50Ms: Number(percentile(durations, 50).toFixed(2)),
p95Ms: Number(percentile(durations, 95).toFixed(2)),
avgMs: Number((durations.reduce((acc, x) => acc + x, 0) / durations.length).toFixed(2)),
maxMs: Number(Math.max(...durations).toFixed(2))
};
}
async function main() {
const token = await getToken();
const endpoints = [
{ method: 'GET', path: '/advalo/historique?page=1&pageSize=20' },
{ method: 'GET', path: '/advalo/cumul?page=1&pageSize=20' },
{ method: 'GET', path: '/advalo/reporting?page=1&pageSize=20' },
{ method: 'GET', path: '/advalo/export?page=1&pageSize=100', responseType: 'text' }
];
const results = [];
for (const endpoint of endpoints) {
console.log(`Benchmarking ${endpoint.path} ...`);
const result = await benchmarkEndpoint(token, endpoint);
results.push(result);
}
const summary = {
baseUrl: BASE_URL,
samples: SAMPLES,
concurrency: CONCURRENCY,
generatedAt: new Date().toISOString(),
results,
acceptance: {
allP95Lt2000: results.every((item) => item.p95Ms < 2000)
}
};
fs.mkdirSync(REPORT_DIR, { recursive: true });
const reportPath = path.join(REPORT_DIR, `advalo-bench-${Date.now()}.json`);
fs.writeFileSync(reportPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.log(JSON.stringify(summary, null, 2));
console.log(`Report saved: ${reportPath}`);
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});

View File

@ -0,0 +1,186 @@
#!/usr/bin/env node
/**
* Reconstruit les templates Avenant Advalo pour la parité v1.
*
* Les .docx d'origine (lettre AXA stylée) n'ont que l'en-tête agent/client. La v1
* (golang/bordereau.go) construit par CODE : un tableau de prix (CAPITAUX | TAUX |
* COTISATION | COUT D'ACTE | A PERCEVOIR), la phrase "à percevoir", et pour le
* périodique avec liste un tableau récapitulatif des transports.
*
* Ce script:
* 1. sauvegarde la version pristine de chaque template dans _source/ (au 1er passage);
* 2. convertit les tokens bruts (nomAgent, ) en tags docxtemplater {token} (runs isolés);
* 3. injecte le tableau de prix + la phrase + (Avenant) la boucle {#avecListe}/{#listeTransports};
* 4. réécrit le .docx live. Idempotent : retransforme toujours depuis _source/.
*
* Usage: node scripts/advalo-build-templates.js
*/
const fs = require('fs');
const path = require('path');
const PizZip = require('pizzip');
const TEMPLATE_DIR = path.resolve(__dirname, '..', 'src', 'templates', 'advalo');
const SOURCE_DIR = path.join(TEMPLATE_DIR, '_source');
const COMMON_TOKENS = [
'nomAgent', 'adresseAgent', 'postalAgent', 'telAgent', 'faxAgent',
'nomClient', 'adresseClient', 'codePostal', 'numContrat', 'numClient',
'intervalle', 'numAgent'
];
const BLUE = '254E9B';
function escAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Paragraphe simple. `text` peut contenir des tags docxtemplater {…}.
function para(text, { bold = false, color = null, size = null, align = null } = {}) {
const rpr = [];
if (bold) rpr.push('<w:b/>');
if (color) rpr.push(`<w:color w:val="${color}"/>`);
if (size) rpr.push(`<w:sz w:val="${size}"/><w:szCs w:val="${size}"/>`);
const rPr = rpr.length ? `<w:rPr>${rpr.join('')}</w:rPr>` : '';
const pPrParts = [];
if (align) pPrParts.push(`<w:jc w:val="${align}"/>`);
if (rpr.length) pPrParts.push(`<w:rPr>${rpr.join('')}</w:rPr>`);
const pPr = pPrParts.length ? `<w:pPr>${pPrParts.join('')}</w:pPr>` : '';
return `<w:p>${pPr}<w:r>${rPr}<w:t xml:space="preserve">${text}</w:t></w:r></w:p>`;
}
// Cellule de tableau. `text` peut contenir des tags docxtemplater.
function cell(text, width, { bold = false, color = null, fill = null } = {}) {
const rpr = [];
if (bold) rpr.push('<w:b/>');
if (color) rpr.push(`<w:color w:val="${color}"/>`);
rpr.push('<w:sz w:val="18"/><w:szCs w:val="18"/>');
const rPr = `<w:rPr>${rpr.join('')}</w:rPr>`;
const shd = fill ? `<w:shd w:val="clear" w:color="auto" w:fill="${fill}"/>` : '';
return `<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${shd}<w:vAlign w:val="center"/></w:tcPr>`
+ `<w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r>${rPr}<w:t xml:space="preserve">${text}</w:t></w:r></w:p></w:tc>`;
}
function tableOpen(widths) {
const borders = ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']
.map((b) => `<w:${b} w:val="single" w:sz="4" w:space="0" w:color="auto"/>`)
.join('');
const grid = widths.map((w) => `<w:gridCol w:w="${w}"/>`).join('');
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders>${borders}</w:tblBorders></w:tblPr><w:tblGrid>${grid}</w:tblGrid>`;
}
function row(cells) {
return `<w:tr>${cells.join('')}</w:tr>`;
}
// Tableau de prix à une ligne (parité v1: CAPITAUX | TAUX | COTISATION | COUT D'ACTE | A PERCEVOIR).
function pricingTable() {
const W = [1900, 1500, 1900, 1900, 1900];
const headers = ['CAPITAUX', 'TAUX', 'COTISATION', "COUT D'ACTE", 'A PERCEVOIR'];
const headerRow = row(headers.map((h, i) => cell(h, W[i], { bold: true, color: 'FFFFFF', fill: BLUE })));
const dataRow = row([
cell('{capitauxField} €', W[0]),
cell('{tauxField} %', W[1]),
cell('{cotisationField} €', W[2]),
cell('{coutActeField} €', W[3]),
cell('{cotisationTTC} €', W[4])
]);
return tableOpen(W) + headerRow + dataRow + '</w:tbl>';
}
// Tableau récapitulatif des transports (boucle docxtemplater sur {listeTransports}).
function recapTable() {
const W = [1300, 1300, 1300, 1300, 1300, 1500, 1100];
const headers = ['N° Demande', 'Date demande', 'Mode', 'Capital', 'Départ', 'Arrivée', 'Transport', 'Tarif'];
const W8 = [1150, 1150, 1100, 1150, 1100, 1100, 1300, 1000];
const headerRow = row(headers.map((h, i) => cell(h, W8[i], { bold: true, color: 'FFFFFF', fill: BLUE })));
const loopRow = row([
cell('{#listeTransports}{numDemande}', W8[0]),
cell('{dateDemande}', W8[1]),
cell('{mode}', W8[2]),
cell('{capital} €', W8[3]),
cell('{depart}', W8[4]),
cell('{arrivee}', W8[5]),
cell('{dateTransport}', W8[6]),
cell('{tarif} €{/listeTransports}', W8[7])
]);
return tableOpen(W8) + headerRow + loopRow + '</w:tbl>';
}
const EMPTY_P = '<w:p/>';
function buildAvenantBlock() {
// Tableau prix + phrase "à percevoir" + récap conditionnel (parité v1 remplissageAvenant).
return [
EMPTY_P,
pricingTable(),
EMPTY_P,
para('La cotisation à percevoir à la signature du présent avenant est de {cotisationTTC} €.', { bold: true }),
EMPTY_P,
para('{#avecListe}', {}),
para("RECAPITULATIF DES TRANSPORTS SAISIS DANS L'OUTIL AD VALOREM", { bold: true, color: BLUE }),
recapTable(),
para('{/avecListe}', {}),
EMPTY_P
].join('');
}
function buildPonctuelBlock() {
// Détails transport + tableau prix + phrase "Il est perçu" (parité v1 remplissageAvenantPonctuel).
return [
EMPTY_P,
para('Transport assuré du {dateDebut} au {dateFin}.'),
para('Nature de la marchandise : {typeMarchandise}.'),
para('Trajet : {depart} → {arrivee}.'),
para('Mode(s) de transport : {modes}.'),
EMPTY_P,
pricingTable(),
EMPTY_P,
para('Il est perçu la somme de {cotisationTTC} € TTC '
+ '(dont cotisation HT {cotisationField} € et coût dacte {coutActeField} €).', { bold: true }),
EMPTY_P
].join('');
}
function convertTokens(xml, tokens) {
let out = xml;
tokens.forEach((tok) => {
out = out.replace(new RegExp(`(<w:t[^>]*>)${tok}(</w:t>)`, 'g'), `$1{${tok}}$2`);
});
return out;
}
function insertBeforeSectPr(xml, block) {
const idx = xml.lastIndexOf('<w:sectPr');
if (idx === -1) {
return xml.replace('</w:body>', `${block}</w:body>`);
}
return xml.slice(0, idx) + block + xml.slice(idx);
}
function rebuild(name, blockBuilder, { stripCapitauxAnchor = false } = {}) {
const livePath = path.join(TEMPLATE_DIR, `${name}.docx`);
const sourcePath = path.join(SOURCE_DIR, `${name}.docx`);
fs.mkdirSync(SOURCE_DIR, { recursive: true });
if (!fs.existsSync(sourcePath)) {
fs.copyFileSync(livePath, sourcePath);
console.log(`[source] sauvegarde pristine -> ${path.relative(process.cwd(), sourcePath)}`);
}
const zip = new PizZip(fs.readFileSync(sourcePath));
let xml = zip.file('word/document.xml').asText();
xml = convertTokens(xml, COMMON_TOKENS);
if (stripCapitauxAnchor) {
// L'ancre inline `capitauxField` de la v1 est remplacée par le vrai tableau (ci-dessous).
xml = xml.replace(/(<w:t[^>]*>)capitauxField(<\/w:t>)/g, '$1$2');
}
xml = insertBeforeSectPr(xml, blockBuilder());
zip.file('word/document.xml', xml);
fs.writeFileSync(livePath, zip.generate({ type: 'nodebuffer', compression: 'DEFLATE' }));
console.log(`[build] ${name}.docx reconstruit (${xml.length} octets de document.xml)`);
}
rebuild('Avenant', buildAvenantBlock, { stripCapitauxAnchor: true });
rebuild('Avenant_Ponctuel', buildPonctuelBlock);
console.log('Templates Avenant reconstruits.');

View File

@ -0,0 +1,703 @@
#!/usr/bin/env node
/* eslint-disable no-console */
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { parse } = require('csv-parse/sync');
const XLSX = require('xlsx');
const { connect, db } = require('../src/db/db-connect');
const VALID_AUTH_GROUPS = new Set(['SOUSCRIPTEUR', 'MANAGER', 'ADMIN', 'REVOQUE']);
// Sources embarquées dans le repo (prod) ; repli sur le repo v1 advalorem (dev local).
const BUNDLED_SEED_ROOT = path.resolve(__dirname, 'seed-data');
const LEGACY_V1_ROOT = path.resolve(__dirname, '..', '..', 'advalorem', 'test');
const DEFAULT_V1_ROOT = fs.existsSync(path.join(BUNDLED_SEED_ROOT, 'bdd', 'bordereau.csv'))
? BUNDLED_SEED_ROOT
: LEGACY_V1_ROOT;
const DEFAULT_REPORT_DIR = path.resolve(__dirname, '..', 'reports');
const PAGE_SIZE = 500;
function textField(name, { required = false, unique = false } = {}) {
return {
name,
type: 'text',
required,
unique,
options: { min: null, max: null, pattern: '' }
};
}
function numberField(name, { required = false, unique = false } = {}) {
return {
name,
type: 'number',
required,
unique,
options: { min: null, max: null }
};
}
function boolField(name, { required = false, unique = false } = {}) {
return {
name,
type: 'bool',
required,
unique,
options: {}
};
}
function jsonField(name, { required = false, unique = false } = {}) {
return {
name,
type: 'json',
required,
unique,
options: {}
};
}
function selectField(name, values, { required = false, unique = false } = {}) {
return {
name,
type: 'select',
required,
unique,
options: {
maxSelect: 1,
values
}
};
}
const REQUIRED_ADVALO_COLLECTIONS = [
{
name: 'advalo_deleguee',
schema: [
textField('numDemande', { required: true, unique: true }),
textField('numClient'),
textField('nomClient'),
textField('numContrat'),
textField('dateDemande'),
textField('marchandise'),
textField('mode'),
textField('capital'),
textField('depart'),
textField('arrivee'),
textField('dateDebut'),
textField('dateFin'),
textField('dateDebutIso'),
textField('dateFinIso'),
textField('nomDevis'),
textField('proprietaire'),
textField('tarif'),
textField('statutCommande'),
textField('statutFacturation'),
textField('souscripteur'),
textField('numPortefeuille')
]
},
{
name: 'advalo_demande',
schema: [
textField('sourceType'),
textField('numDemande', { required: true, unique: true }),
textField('numClient'),
textField('nomClient'),
textField('numContrat'),
textField('dateDemande'),
textField('marchandise'),
textField('mode'),
textField('capital'),
textField('depart'),
textField('arrivee'),
textField('dateDebut'),
textField('dateFin'),
textField('dateDebutIso'),
textField('dateFinIso'),
textField('nomDevis'),
textField('proprietaire'),
textField('tarif'),
textField('statutCommande'),
textField('statutFacturation'),
boolField('isDeleted'),
textField('createdBy'),
textField('region'),
textField('dpt'),
textField('souscripteur'),
textField('numPortefeuille')
]
},
{
name: 'advalo_ref_contrat',
schema: [
textField('numContrat', { required: true, unique: true }),
textField('numContratBrut'),
textField('type'),
textField('nomClient'),
textField('matricule'),
textField('region'),
textField('dpt')
]
},
{
name: 'advalo_audit',
schema: [
textField('eventType', { required: true }),
textField('createdAt'),
jsonField('data')
]
},
{
name: 'advalo_facturation_batch',
schema: [
textField('numContrat'),
textField('dateDebut'),
textField('dateFin'),
textField('sourceMode'),
jsonField('demandeIds'),
numberField('totalCapitaux'),
numberField('totalCotisation'),
textField('fingerprint', { unique: true }),
selectField('status', ['IN_PROGRESS', 'DONE', 'FAILED'], { required: true }),
textField('createdBy'),
textField('createdAt'),
textField('facturedAt'),
textField('errorMessage')
]
},
{
name: 'advalo_document',
schema: [
textField('type'),
textField('path'),
textField('sha256'),
textField('demandeId'),
textField('batchId'),
textField('createdAt')
]
}
];
function parseArgs(argv) {
const out = {
reset: false,
v1Root: DEFAULT_V1_ROOT,
reportDir: DEFAULT_REPORT_DIR
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--reset') {
out.reset = true;
continue;
}
if (arg === '--v1-root' && argv[i + 1]) {
out.v1Root = path.resolve(argv[++i]);
continue;
}
if (arg === '--report-dir' && argv[i + 1]) {
out.reportDir = path.resolve(argv[++i]);
continue;
}
}
return out;
}
function parseCsvFile(filePath) {
const raw = fs.readFileSync(filePath, 'utf8');
return parse(raw, {
delimiter: ';',
columns: true,
skip_empty_lines: true,
relax_quotes: true,
bom: true
});
}
function normalizeContract(raw) {
const digits = String(raw || '').replace(/\D/g, '');
if (!digits) return '';
return digits.padStart(16, '0').slice(-16);
}
function parseFrDateToIso(dateFr) {
const m = String(dateFr || '').trim().match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]} 00:00:00.000Z`;
}
function parseFrDate(dateFr) {
const m = String(dateFr || '').trim().match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!m) return null;
const date = new Date(`${m[3]}-${m[2]}-${m[1]}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function uniqueByLast(rows, keySelector) {
const map = new Map();
rows.forEach((row) => {
const key = keySelector(row);
if (!key) return;
map.set(key, row);
});
return [...map.values()];
}
async function listAllIds(collectionName) {
const ids = [];
let page = 1;
let totalPages = 1;
do {
const list = await db.records.getList(collectionName, page, PAGE_SIZE, {
fields: 'id'
});
ids.push(...list.items.map((item) => item.id));
totalPages = Number(list.totalPages || 1);
page += 1;
} while (page <= totalPages);
return ids;
}
async function deleteCollectionRecords(collectionName, report) {
const ids = await listAllIds(collectionName);
report.purged[collectionName] = ids.length;
if (!ids.length) return;
const chunkSize = 20;
for (let i = 0; i < ids.length; i += chunkSize) {
const chunk = ids.slice(i, i + chunkSize);
await Promise.all(chunk.map((id) => db.records.delete(collectionName, id)));
if ((i + chunk.length) % 2000 === 0 || i + chunk.length === ids.length) {
console.log(`[purge:${collectionName}] ${i + chunk.length}/${ids.length}`);
}
}
}
async function insertRows(collectionName, rows, report) {
const chunkSize = 20;
let inserted = 0;
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
try {
const created = await Promise.all(chunk.map((row) => db.records.create(collectionName, row)));
inserted += created.length;
} catch (chunkError) {
for (const row of chunk) {
try {
await db.records.create(collectionName, row);
inserted += 1;
} catch (rowError) {
const details = rowError?.data || rowError?.details || {};
const diagnostic = {
collection: collectionName,
index: i,
row,
details
};
report.anomalies.push({
type: 'insert_error',
...diagnostic
});
const err = new Error(`Insert failed for ${collectionName}: ${JSON.stringify(diagnostic)}`);
err.originalError = rowError;
throw err;
}
}
}
if (inserted % 2000 === 0 || inserted === rows.length) {
console.log(`[insert:${collectionName}] ${inserted}/${rows.length}`);
}
}
report.inserted[collectionName] = inserted;
}
function splitName(fullName) {
const cleaned = String(fullName || '').trim().replace(/\s+/g, ' ');
if (!cleaned) return { nom: '', prenom: '' };
const parts = cleaned.split(' ');
const nom = parts.shift() || '';
const prenom = parts.join(' ');
return { nom, prenom };
}
function normalizeMail(value) {
const mail = String(value || '').trim();
if (!mail.includes('@')) return '';
return mail;
}
function parseClasse(value) {
const parsed = Number(String(value || '').replace(',', '.'));
return Number.isFinite(parsed) ? parsed : 0;
}
async function loadRegionMap() {
const regions = [];
let page = 1;
let totalPages = 1;
do {
const list = await db.records.getList('region', page, PAGE_SIZE);
regions.push(...list.items);
totalPages = Number(list.totalPages || 1);
page += 1;
} while (page <= totalPages);
const map = new Map();
regions.forEach((region) => {
map.set(String(region.nom || '').trim().toUpperCase(), region.id);
});
return map;
}
function normalizeAuthGroup(value) {
const out = String(value || '').trim().toUpperCase();
if (VALID_AUTH_GROUPS.has(out)) return out;
return 'SOUSCRIPTEUR';
}
function normalizeFacturationStatus(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) return 'unknown';
if (raw.includes('non') && raw.includes('factur')) return 'non_facture';
if (raw.includes('factur')) return 'facture';
return 'unknown';
}
function trackQualityForCommonRow(row, report, kind) {
const numDemande = String(row['N° Demande'] || '').trim();
const numContrat = normalizeContract(row['N° du contrat']);
if (numContrat.length !== 16) {
report.quality.invalidContracts += 1;
report.anomalies.push({
type: `${kind}_invalid_numContrat`,
numDemande,
numContratRaw: String(row['N° du contrat'] || '')
});
}
const dateDebut = parseFrDate(row['Date de début du transport']);
const dateFin = parseFrDate(row['Date de fin du transport']);
if (dateDebut && dateFin && dateDebut > dateFin) {
report.quality.invalidDateRanges += 1;
report.anomalies.push({
type: `${kind}_invalid_date_range`,
numDemande,
dateDebut: String(row['Date de début du transport'] || ''),
dateFin: String(row['Date de fin du transport'] || '')
});
}
const statusClass = normalizeFacturationStatus(row['Statut de la facturation']);
if (statusClass === 'unknown') {
report.quality.incoherentFacturationStatus += 1;
report.anomalies.push({
type: `${kind}_incoherent_facturation_status`,
numDemande,
statutFacturation: String(row['Statut de la facturation'] || '')
});
}
}
function buildUsersRows(usersSheetRows, regionMap, report) {
const normalized = usersSheetRows.map((row) => {
const matricule = String(row.Matricule || '').trim().toUpperCase();
const regionName = String(row.Region || '').trim().toUpperCase();
const regionId = regionMap.get(regionName) || '';
const { nom, prenom } = splitName(row.Nom_Prenom);
if (!matricule) {
report.anomalies.push({ type: 'user_missing_matricule', row });
return null;
}
if (!regionId) {
report.anomalies.push({ type: 'user_region_not_found', matricule, region: regionName });
}
return {
matricule,
matriculeIT: matricule,
nom,
prenom,
mail: normalizeMail(row.Mail),
classe: parseClasse(row.Classe),
region: regionId || '',
authGroupe: normalizeAuthGroup(row.Auth_Groupe)
};
}).filter(Boolean);
const deduped = uniqueByLast(normalized, (row) => row.matricule);
if (deduped.length !== normalized.length) {
report.anomalies.push({
type: 'user_duplicate_matricule',
sourceRows: normalized.length,
dedupedRows: deduped.length
});
}
return deduped;
}
function buildDelegueeRows(csvRows, report) {
const normalized = csvRows.map((row) => {
const numDemande = String(row['N° Demande'] || '').trim();
if (!numDemande) {
report.quality.missingNumDemande += 1;
report.anomalies.push({ type: 'deleguee_missing_numDemande', row });
return null;
}
const numContrat = normalizeContract(row['N° du contrat']);
trackQualityForCommonRow(row, report, 'deleguee');
return {
numDemande,
numClient: String(row['N° Client'] || '').trim(),
nomClient: String(row['Nom de client'] || '').trim(),
numContrat,
dateDemande: String(row['Date de la demande'] || '').trim(),
marchandise: String(row['Nature de la marchandise'] || '').trim(),
mode: String(row['Mode de transports'] || '').trim(),
capital: String(row['Valeur de marchandise'] || '').trim(),
depart: String(row['Zone de départ'] || '').trim(),
arrivee: String(row["Zone d'arrivée"] || '').trim(),
dateDebut: String(row['Date de début du transport'] || '').trim(),
dateFin: String(row['Date de fin du transport'] || '').trim(),
dateDebutIso: parseFrDateToIso(row['Date de début du transport']),
dateFinIso: parseFrDateToIso(row['Date de fin du transport']),
nomDevis: String(row['Nom du devis'] || '').trim(),
proprietaire: String(row['Propriétaire de la marchandise'] || '').trim(),
tarif: String(row.Tarif || '').trim(),
statutCommande: String(row['Statut de la commande'] || '').trim(),
statutFacturation: String(row['Statut de la facturation'] || '').trim()
};
}).filter(Boolean);
return uniqueByLast(normalized, (row) => row.numDemande);
}
function buildDemandeRows(csvRows, report) {
const normalized = csvRows.map((row) => {
const numDemande = String(row['N° Demande'] || '').trim();
if (!numDemande) {
report.quality.missingNumDemande += 1;
report.anomalies.push({ type: 'demande_missing_numDemande', row });
return null;
}
const numContrat = normalizeContract(row['N° du contrat']);
trackQualityForCommonRow(row, report, 'demande');
return {
sourceType: 'hors_grille',
numDemande,
numClient: String(row['N° Client'] || '').trim(),
nomClient: String(row['Nom de client'] || '').trim(),
numContrat,
dateDemande: String(row['Date de la demande'] || '').trim(),
marchandise: String(row['Nature de la marchandise'] || '').trim(),
mode: String(row['Mode de transports'] || '').trim(),
capital: String(row['Valeur de marchandise'] || '').trim(),
depart: String(row['Zone de départ'] || '').trim(),
arrivee: String(row["Zone d'arrivée"] || '').trim(),
dateDebut: String(row['Date de début du transport'] || '').trim(),
dateFin: String(row['Date de fin du transport'] || '').trim(),
dateDebutIso: parseFrDateToIso(row['Date de début du transport']),
dateFinIso: parseFrDateToIso(row['Date de fin du transport']),
nomDevis: String(row['Nom du devis'] || '').trim(),
proprietaire: String(row['Propriétaire de la marchandise'] || '').trim(),
tarif: String(row.Tarif || '').trim(),
statutCommande: String(row['Statut de la commande'] || '').trim(),
statutFacturation: String(row['Statut de la facturation'] || '').trim(),
isDeleted: String(row.SUPPRIME || '').toUpperCase().includes('SUPPRIME'),
createdBy: '',
region: ''
};
}).filter(Boolean);
return uniqueByLast(normalized, (row) => row.numDemande);
}
function buildRefContratRows(refWorkbookPath, report) {
const workbook = XLSX.readFile(refWorkbookPath);
const sheet = workbook.Sheets.BaseContratRegionCumulAdvalo;
if (!sheet) {
throw new Error(`Sheet BaseContratRegionCumulAdvalo missing in ${refWorkbookPath}`);
}
const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' });
const normalized = rows.map((row) => {
const numContrat = normalizeContract(row.N_contrat || row.N_contrat_brut || '');
if (!numContrat) return null;
return {
numContrat,
numContratBrut: String(row.N_contrat_brut || '').trim(),
type: String(row.Type || 'Inconnu').trim() || 'Inconnu',
nomClient: String(row.Nom_client || 'Inconnu').trim() || 'Inconnu',
matricule: String(row.Matricule || 'Inconnu').trim() || 'Inconnu',
region: String(row.Region || 'Inconnu').trim() || 'Inconnu',
dpt: String(row.Dpt || 'Inconnu').trim() || 'Inconnu'
};
}).filter(Boolean);
const deduped = uniqueByLast(normalized, (row) => row.numContrat);
if (deduped.length !== 938) {
report.anomalies.push({
type: 'ref_contrat_unexpected_count',
expected: 938,
actual: deduped.length
});
}
return deduped;
}
async function getCollectionCount(collectionName) {
const list = await db.records.getList(collectionName, 1, 1);
return Number(list.totalItems || 0);
}
async function ensureAdvaloCollections(report) {
const existing = await db.collections.getFullList(200, { sort: '+name' });
const byName = new Map(existing.map((collection) => [String(collection.name || ''), collection]));
const created = [];
const alreadyPresent = [];
for (const definition of REQUIRED_ADVALO_COLLECTIONS) {
if (byName.has(definition.name)) {
alreadyPresent.push(definition.name);
continue;
}
await db.collections.create({
name: definition.name,
type: 'base',
listRule: null,
viewRule: null,
createRule: null,
updateRule: null,
deleteRule: null,
schema: definition.schema
});
created.push(definition.name);
}
report.collectionSetup = {
created,
alreadyPresent
};
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const report = {
generatedAt: new Date().toISOString(),
options: opts,
sourcePaths: {},
purged: {},
inserted: {},
postCheck: {},
quality: {
invalidContracts: 0,
missingNumDemande: 0,
invalidDateRanges: 0,
incoherentFacturationStatus: 0
},
anomalies: [],
stats: {}
};
const sourceFiles = {
bordereauDeleguee: path.join(opts.v1Root, 'bdd', 'bordereau.csv'),
bordereauDemande: path.join(opts.v1Root, 'bdd', 'bordereau_hors_grille.csv'),
usersExcel: path.join(opts.v1Root, 'bdd', 'xl_utilisateur.xlsx'),
refContratExcel: path.join(opts.v1Root, 'bdd', 'archives', 'xl_ref_contrat_region_cumul_advalo.xlsx')
};
report.sourcePaths = sourceFiles;
Object.entries(sourceFiles).forEach(([label, filePath]) => {
if (!fs.existsSync(filePath)) {
throw new Error(`Missing source file (${label}): ${filePath}`);
}
});
await connect();
await ensureAdvaloCollections(report);
const regionMap = await loadRegionMap();
const delegueeRaw = parseCsvFile(sourceFiles.bordereauDeleguee);
const demandeRaw = parseCsvFile(sourceFiles.bordereauDemande);
const usersWorkbook = XLSX.readFile(sourceFiles.usersExcel);
const usersSheet = usersWorkbook.Sheets.Users;
if (!usersSheet) {
throw new Error(`Sheet Users missing in ${sourceFiles.usersExcel}`);
}
const usersRaw = XLSX.utils.sheet_to_json(usersSheet, { defval: '' });
const delegueeRows = buildDelegueeRows(delegueeRaw, report);
const demandeRows = buildDemandeRows(demandeRaw, report);
const usersRows = buildUsersRows(usersRaw, regionMap, report);
const refContratRows = buildRefContratRows(sourceFiles.refContratExcel, report);
report.stats = {
sourceRows: {
utilisateur: usersRaw.length,
advalo_deleguee: delegueeRaw.length,
advalo_demande: demandeRaw.length
},
dedupRows: {
utilisateur: usersRows.length,
advalo_deleguee: delegueeRows.length,
advalo_demande: demandeRows.length
}
};
report.expected = {
utilisateur: usersRows.length,
advalo_ref_contrat: refContratRows.length,
advalo_deleguee: delegueeRows.length,
advalo_demande: demandeRows.length
};
console.log('Expected rows:', report.expected);
if (opts.reset) {
for (const collection of ['advalo_deleguee', 'advalo_demande', 'advalo_ref_contrat', 'utilisateur']) {
await deleteCollectionRecords(collection, report);
}
}
await insertRows('utilisateur', usersRows, report);
await insertRows('advalo_ref_contrat', refContratRows, report);
await insertRows('advalo_deleguee', delegueeRows, report);
await insertRows('advalo_demande', demandeRows, report);
report.postCheck = {
utilisateur: await getCollectionCount('utilisateur'),
advalo_ref_contrat: await getCollectionCount('advalo_ref_contrat'),
advalo_deleguee: await getCollectionCount('advalo_deleguee'),
advalo_demande: await getCollectionCount('advalo_demande')
};
report.acceptance = {
utilisateur_is_188: report.postCheck.utilisateur === 188,
advalo_ref_contrat_is_938: report.postCheck.advalo_ref_contrat === 938,
advalo_data_loaded: report.postCheck.advalo_deleguee > 0 && report.postCheck.advalo_demande > 0,
quality_numContrat_16_digits: report.quality.invalidContracts === 0,
quality_numDemande_non_empty: report.quality.missingNumDemande === 0,
quality_facturation_status_coherent: report.quality.incoherentFacturationStatus === 0
};
fs.mkdirSync(opts.reportDir, { recursive: true });
const reportPath = path.join(opts.reportDir, `advalo-migration-report-${Date.now()}.json`);
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
console.log(JSON.stringify({
reportPath,
expected: report.expected,
postCheck: report.postCheck,
acceptance: report.acceptance,
anomalies: report.anomalies.length
}, null, 2));
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});

View File

@ -0,0 +1,98 @@
#!/usr/bin/env node
/* eslint-disable no-console */
require('dotenv').config();
const axaBridge = require('../src/services/axaBridgeService');
function parseArgs(argv) {
const out = {
matricule: process.env.AXA_SMOKE_MATRICULE || 'SYSTEM',
numContrat: process.env.AXA_SMOKE_NUM_CONTRAT || '',
runQt550: false,
totalCotisation: process.env.AXA_SMOKE_TOTAL_COTISATION || '',
dateDebut: process.env.AXA_SMOKE_DATE_DEBUT || '',
dateFin: process.env.AXA_SMOKE_DATE_FIN || ''
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--matricule' && argv[i + 1]) {
out.matricule = String(argv[++i]).trim();
continue;
}
if (arg === '--num-contrat' && argv[i + 1]) {
out.numContrat = String(argv[++i]).trim();
continue;
}
if (arg === '--run-qt550') {
out.runQt550 = true;
continue;
}
if (arg === '--total-cotisation' && argv[i + 1]) {
out.totalCotisation = String(argv[++i]).trim();
continue;
}
if (arg === '--date-debut' && argv[i + 1]) {
out.dateDebut = String(argv[++i]).trim();
continue;
}
if (arg === '--date-fin' && argv[i + 1]) {
out.dateFin = String(argv[++i]).trim();
continue;
}
}
return out;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const health = await axaBridge.getHealth({ matricule: opts.matricule });
console.log(JSON.stringify({
step: 'health',
ok: health.ok,
helperReady: health.helperReady,
errors: health.errors
}, null, 2));
if (!health.ok) {
throw new Error('AXA health check failed.');
}
if (opts.numContrat) {
const lookup = await axaBridge.lookupContract({
matricule: opts.matricule,
numContrat: opts.numContrat
});
console.log(JSON.stringify({
step: 'lookup',
numContrat: lookup.numContrat,
numClient: lookup.numClient,
nomClient: lookup.nomClient,
numAgent: lookup.numAgent
}, null, 2));
}
if (opts.runQt550) {
if (!opts.numContrat || !opts.totalCotisation || !opts.dateDebut || !opts.dateFin) {
throw new Error('Missing required options for --run-qt550 (--num-contrat, --total-cotisation, --date-debut, --date-fin).');
}
await axaBridge.runQt550({
matricule: opts.matricule,
numContrat: opts.numContrat,
totalCotisation: opts.totalCotisation,
dateDebut: opts.dateDebut,
dateFin: opts.dateFin
});
console.log(JSON.stringify({
step: 'qt550',
ok: true
}, null, 2));
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});

View File

@ -0,0 +1,59 @@
const fs = require('fs');
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const backupDir = path.join(rootDir, 'src', 'db', 'pb_data_backup');
const targetDir = path.join(rootDir, 'src', 'db', 'pb_data');
const forceReset = process.argv.includes('--reset');
const filesToCopy = ['data.db', 'logs.db'];
const transientFiles = ['data.db-shm', 'data.db-wal', 'logs.db-shm', 'logs.db-wal'];
function exists(filePath) {
return fs.existsSync(filePath);
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function copyFile(source, target) {
fs.copyFileSync(source, target);
}
function removeIfExists(filePath) {
if (exists(filePath)) fs.unlinkSync(filePath);
}
function bootstrap() {
ensureDir(targetDir);
if (forceReset) {
filesToCopy.concat(transientFiles).forEach((name) => removeIfExists(path.join(targetDir, name)));
}
filesToCopy.forEach((fileName) => {
const sourcePath = path.join(backupDir, fileName);
const targetPath = path.join(targetDir, fileName);
if (!exists(sourcePath)) {
throw new Error(`Backup manquant: ${sourcePath}`);
}
if (!exists(targetPath) || forceReset) {
copyFile(sourcePath, targetPath);
process.stdout.write(`copied ${fileName}\n`);
} else {
process.stdout.write(`kept ${fileName}\n`);
}
});
process.stdout.write(`PocketBase bootstrap terminé (${forceReset ? 'reset' : 'safe'})\n`);
}
try {
bootstrap();
} catch (error) {
process.stderr.write(`db-bootstrap-local error: ${error.message}\n`);
process.exit(1);
}

47
ecole/scripts/db-start.js Normal file
View File

@ -0,0 +1,47 @@
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const rootDir = path.resolve(__dirname, '..');
const dbDir = path.join(rootDir, 'src', 'db');
const pbDataDir = path.join(dbDir, 'pb_data');
const fallbackUrl = 'http://127.0.0.1:8091/';
const dbUrl = process.env.DB_URL || fallbackUrl;
const url = (() => {
try {
return new URL(dbUrl);
} catch (_error) {
return new URL(fallbackUrl);
}
})();
const host = `${url.hostname}:${url.port || '8091'}`;
const binaryPath = process.platform === 'win32'
? path.join(dbDir, 'Pocketbase_0.7.5.exe')
: path.join(dbDir, 'pocketbase');
if (!fs.existsSync(binaryPath)) {
process.stderr.write(`PocketBase binaire introuvable: ${binaryPath}\n`);
process.exit(1);
}
if (!fs.existsSync(pbDataDir)) {
process.stderr.write(`Répertoire pb_data introuvable: ${pbDataDir}\n`);
process.stderr.write('Lance d\'abord: npm run db:bootstrap\n');
process.exit(1);
}
const child = spawn(binaryPath, ['serve', '--http', host, '--dir', pbDataDir], {
cwd: dbDir,
stdio: 'inherit'
});
child.on('exit', (code) => {
process.exit(code || 0);
});
child.on('error', (error) => {
process.stderr.write(`Erreur lancement PocketBase: ${error.message}\n`);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PlatformTarget>x86</PlatformTarget>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,549 @@
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
internal static class Program
{
private const uint APPCMD_CLIENTONLY = 0x00000010;
private const uint CP_WINANSI = 1004;
private const uint XTYP_EXECUTE = 0x4050;
private const uint CF_TEXT = 1;
private static int Main(string[] args)
{
try
{
var options = CliOptions.Parse(args);
var runner = new DdeScriptRunner(options.Workdir, options.TimeoutMs);
var result = runner.ExecuteScript(options.ScriptPath);
Console.WriteLine(JsonSerializer.Serialize(result));
return result.Ok ? 0 : 1;
}
catch (Exception ex)
{
var payload = new ScriptResult
{
Ok = false,
ReturnCode = 0,
ErrorCode = "AXA_HELPER_RUNTIME",
Message = ex.Message
};
Console.WriteLine(JsonSerializer.Serialize(payload));
return 1;
}
}
}
internal sealed class CliOptions
{
public string ScriptPath { get; private set; } = string.Empty;
public string Workdir { get; private set; } = string.Empty;
public int TimeoutMs { get; private set; } = 65000;
public static CliOptions Parse(string[] args)
{
var options = new CliOptions();
for (var i = 0; i < args.Length; i += 1)
{
var current = args[i];
if (current.Equals("--script", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
options.ScriptPath = args[++i];
continue;
}
if (current.Equals("--workdir", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
options.Workdir = args[++i];
continue;
}
if (current.Equals("--timeout-ms", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
if (int.TryParse(args[++i], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) && parsed > 0)
{
options.TimeoutMs = parsed;
}
continue;
}
}
if (string.IsNullOrWhiteSpace(options.ScriptPath))
{
throw new InvalidOperationException("Missing required argument: --script");
}
if (string.IsNullOrWhiteSpace(options.Workdir))
{
throw new InvalidOperationException("Missing required argument: --workdir");
}
options.ScriptPath = Path.GetFullPath(options.ScriptPath);
options.Workdir = Path.GetFullPath(options.Workdir);
return options;
}
}
internal sealed class ScriptResult
{
public bool Ok { get; set; }
public int ReturnCode { get; set; }
public string ErrorCode { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
}
internal sealed class DdeScriptRunner
{
private readonly string _workdir;
private readonly int _timeoutMs;
private readonly string _screenReaderExe;
public DdeScriptRunner(string workdir, int timeoutMs)
{
_workdir = workdir;
_timeoutMs = timeoutMs;
_screenReaderExe = Path.Combine(_workdir, "ECFDDEViva.exe");
}
public ScriptResult ExecuteScript(string scriptPath)
{
if (!File.Exists(scriptPath))
{
throw new FileNotFoundException($"Script file not found: {scriptPath}");
}
if (!Directory.Exists(_workdir))
{
throw new DirectoryNotFoundException($"Workdir not found: {_workdir}");
}
if (!File.Exists(_screenReaderExe))
{
throw new FileNotFoundException($"Missing AXA screen reader binary: {_screenReaderExe}");
}
using var session = new DdeSession(_timeoutMs);
session.Connect("Viva", "Vivaserveur");
var returnCode = 0;
var lines = File.ReadAllLines(scriptPath, Encoding.UTF8);
foreach (var rawLine in lines)
{
var line = rawLine?.Trim();
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
if (!line.StartsWith("[", StringComparison.Ordinal) || !line.Contains(']'))
{
continue;
}
var command = line.Substring(1, line.IndexOf(']') - 1).Trim().ToUpperInvariant();
switch (command)
{
case "SENDFONC":
{
var fonc = ExtractRequired(line, "FONC='", "';");
var key = MapFunctionKey(fonc);
session.SendKey(key, 9999);
break;
}
case "SEND":
{
var posRaw = ExtractRequiredByRegex(line, @"Pos=(?<value>\d+);");
var data = ExtractRequired(line, "Data='", "';");
var pos = int.Parse(posRaw, CultureInfo.InvariantCulture);
session.SendKey(data, pos);
break;
}
case "WAITBUSY":
{
Thread.Sleep(100);
break;
}
case "ECRAN":
{
var posRaw = ExtractRequiredByRegex(line, @"Pos=(?<value>\d+);");
var stopRaw = ExtractRequiredByRegex(line, @"Stop=(?<value>\d+);");
var targetPathRaw = ExtractRequired(line, "Data='", "';");
var targetPath = ResolvePath(targetPathRaw);
var pos = int.Parse(posRaw, CultureInfo.InvariantCulture);
var stop = int.Parse(stopRaw, CultureInfo.InvariantCulture);
var screen = ReadScreen();
var fragment = SliceScreen(screen, pos, stop);
var dir = Path.GetDirectoryName(targetPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
File.AppendAllText(targetPath, fragment + Environment.NewLine, Encoding.GetEncoding(1252));
break;
}
case "END":
{
var parRaw = ExtractRequiredByRegex(line, @"Par=(?<value>-?\d+);");
returnCode = int.Parse(parRaw, CultureInfo.InvariantCulture);
return new ScriptResult
{
Ok = true,
ReturnCode = returnCode,
Message = "Script executed successfully."
};
}
default:
break;
}
}
return new ScriptResult
{
Ok = true,
ReturnCode = returnCode,
Message = "Script executed without END marker."
};
}
private string ResolvePath(string value)
{
if (Path.IsPathRooted(value))
{
return value;
}
return Path.GetFullPath(Path.Combine(_workdir, value));
}
private string ReadScreen()
{
var tempFile = Path.Combine(_workdir, $"lu_{Guid.NewGuid():N}.txt");
var psi = new ProcessStartInfo
{
FileName = _screenReaderExe,
Arguments = $"\"{tempFile}\"",
WorkingDirectory = _workdir,
CreateNoWindow = true,
UseShellExecute = false
};
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Unable to start ECFDDEViva.exe");
if (!process.WaitForExit(Math.Min(_timeoutMs, 10000)))
{
try
{
process.Kill(true);
}
catch
{
// no-op
}
throw new TimeoutException("ECFDDEViva.exe timeout.");
}
if (!File.Exists(tempFile))
{
throw new InvalidOperationException($"ECFDDEViva output file missing: {tempFile}");
}
string content;
try
{
content = File.ReadAllText(tempFile, Encoding.GetEncoding(1252));
}
finally
{
try
{
File.Delete(tempFile);
}
catch
{
// no-op
}
}
var line = content
.Replace("\0", string.Empty)
.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None)
.FirstOrDefault() ?? string.Empty;
return line;
}
private static string SliceScreen(string screen, int pos, int stop)
{
var start = Math.Max(pos - 1, 0);
var length = Math.Max(stop - pos, 0);
if (start >= screen.Length || length <= 0)
{
return string.Empty;
}
var safeLength = Math.Min(length, screen.Length - start);
return screen.Substring(start, safeLength);
}
private static string MapFunctionKey(string value)
{
return value.Trim().ToUpperInvariant() switch
{
"ENTER" => "@E",
"CLEAR" => "@C",
"PF1" => "@1",
"PF2" => "@2",
"PF3" => "@3",
"PF4" => "@4",
"PF5" => "@5",
"PF6" => "@6",
"PF7" => "@7",
"PF8" => "@8",
"PF9" => "@9",
"PF10" => "@a",
"PF11" => "@b",
"PF12" => "@c",
"PA1" => "@x",
"PA2" => "@y",
"PA3" => "@z",
"PA4" => "@+",
"TAB" => "@T",
"BTAB" => "@B",
"HOME" => "@0",
"RESET" => "@R",
_ => throw new InvalidOperationException($"Unknown FONC key: {value}")
};
}
private static string ExtractRequired(string line, string startToken, string endToken)
{
var start = line.IndexOf(startToken, StringComparison.OrdinalIgnoreCase);
if (start < 0)
{
throw new InvalidOperationException($"Token not found: {startToken} in '{line}'");
}
start += startToken.Length;
var end = line.IndexOf(endToken, start, StringComparison.OrdinalIgnoreCase);
if (end < 0)
{
throw new InvalidOperationException($"Token not found: {endToken} in '{line}'");
}
return line.Substring(start, end - start);
}
private static string ExtractRequiredByRegex(string line, string pattern)
{
var match = Regex.Match(line, pattern, RegexOptions.IgnoreCase);
if (!match.Success)
{
throw new InvalidOperationException($"Pattern not found: {pattern} in '{line}'");
}
return match.Groups["value"].Value;
}
}
internal sealed class DdeSession : IDisposable
{
private readonly int _timeoutMs;
private uint _instanceId;
private IntPtr _conversation = IntPtr.Zero;
private IntPtr _serviceHandle = IntPtr.Zero;
private IntPtr _topicHandle = IntPtr.Zero;
private readonly DdeCallback _callbackDelegate;
public DdeSession(int timeoutMs)
{
_timeoutMs = timeoutMs;
_callbackDelegate = Callback;
var initResult = DdeInitializeA(out _instanceId, _callbackDelegate, APPCMD_CLIENTONLY, 0);
if (initResult != 0)
{
throw new InvalidOperationException($"DDE initialize failed: {initResult}");
}
}
public void Connect(string service, string topic)
{
_serviceHandle = DdeCreateStringHandleA(_instanceId, service, CP_WINANSI);
_topicHandle = DdeCreateStringHandleA(_instanceId, topic, CP_WINANSI);
if (_serviceHandle == IntPtr.Zero || _topicHandle == IntPtr.Zero)
{
throw new InvalidOperationException("DDE string handle creation failed.");
}
_conversation = DdeConnect(_instanceId, _serviceHandle, _topicHandle, IntPtr.Zero);
if (_conversation == IntPtr.Zero)
{
var errorCode = DdeGetLastError(_instanceId);
throw new InvalidOperationException($"DDE connect failed (service={service}, topic={topic}, code={errorCode}).");
}
}
public void SendKey(string value, int pos)
{
if (_conversation == IntPtr.Zero)
{
throw new InvalidOperationException("DDE conversation is not connected.");
}
if (pos != 9999)
{
var posRaw = pos.ToString("0000", CultureInfo.InvariantCulture);
Execute($"[SetPosition({posRaw})]");
}
Execute($"[SendKey({value})]");
}
private void Execute(string command)
{
var payload = Encoding.ASCII.GetBytes(command + '\0');
uint result;
var dataHandle = DdeClientTransaction(
payload,
(uint)payload.Length,
_conversation,
IntPtr.Zero,
CF_TEXT,
XTYP_EXECUTE,
(uint)_timeoutMs,
out result
);
if (dataHandle == IntPtr.Zero)
{
var errorCode = DdeGetLastError(_instanceId);
throw new InvalidOperationException($"DDE execute failed (cmd={command}, code={errorCode}).");
}
}
public void Dispose()
{
if (_conversation != IntPtr.Zero)
{
try
{
DdeDisconnect(_conversation);
}
catch
{
// no-op
}
_conversation = IntPtr.Zero;
}
if (_serviceHandle != IntPtr.Zero)
{
try
{
DdeFreeStringHandle(_instanceId, _serviceHandle);
}
catch
{
// no-op
}
_serviceHandle = IntPtr.Zero;
}
if (_topicHandle != IntPtr.Zero)
{
try
{
DdeFreeStringHandle(_instanceId, _topicHandle);
}
catch
{
// no-op
}
_topicHandle = IntPtr.Zero;
}
if (_instanceId != 0)
{
try
{
DdeUninitialize(_instanceId);
}
catch
{
// no-op
}
_instanceId = 0;
}
}
private static IntPtr Callback(
uint uType,
uint uFmt,
IntPtr hconv,
IntPtr hsz1,
IntPtr hsz2,
IntPtr hdata,
IntPtr dwData1,
IntPtr dwData2
) => IntPtr.Zero;
private delegate IntPtr DdeCallback(
uint uType,
uint uFmt,
IntPtr hconv,
IntPtr hsz1,
IntPtr hsz2,
IntPtr hdata,
IntPtr dwData1,
IntPtr dwData2
);
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
private static extern uint DdeInitializeA(
out uint pidInst,
DdeCallback pfnCallback,
uint afCmd,
uint ulRes
);
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
private static extern IntPtr DdeCreateStringHandleA(
uint idInst,
string psz,
uint iCodePage
);
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
private static extern bool DdeFreeStringHandle(
uint idInst,
IntPtr hsz
);
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
private static extern IntPtr DdeConnect(
uint idInst,
IntPtr hszService,
IntPtr hszTopic,
IntPtr pCC
);
[DllImport("user32.dll")]
private static extern bool DdeDisconnect(
IntPtr hConv
);
[DllImport("user32.dll")]
private static extern bool DdeUninitialize(
uint idInst
);
[DllImport("user32.dll", CharSet = CharSet.Ansi)]
private static extern IntPtr DdeClientTransaction(
byte[] pData,
uint cbData,
IntPtr hConv,
IntPtr hszItem,
uint wFmt,
uint wType,
uint dwTimeout,
out uint pdwResult
);
[DllImport("user32.dll")]
private static extern uint DdeGetLastError(
uint idInst
);
}

View File

@ -0,0 +1,122 @@
const express = require('express');
const request = require('supertest');
jest.mock('jsonwebtoken', () => ({
verify: jest.fn()
}));
jest.mock('../../utils/logger', () => ({
log: jest.fn()
}));
jest.mock('../../services/advaloService', () => ({
lookupContract: jest.fn(),
getHistorique: jest.fn(),
getHistoriqueDetail: jest.fn(),
getCumul: jest.fn(),
createPonctuel: jest.fn(),
softDeleteDemande: jest.fn(),
facturerBatch: jest.fn(),
generateDemandeDocument: jest.fn(),
generateBatchAvenant: jest.fn(),
getReporting: jest.fn(),
exportHistorique: jest.fn(),
getAxaHealth: jest.fn(),
getCacheStatus: jest.fn(),
rebuildCache: jest.fn()
}));
const jwt = require('jsonwebtoken');
const advaloService = require('../../services/advaloService');
const advaloRouter = require('../advaloController');
function createApp() {
const app = express();
app.use(express.json());
app.use('/advalo', advaloRouter);
return app;
}
describe('advaloController technical endpoints', () => {
beforeEach(() => {
jest.clearAllMocks();
jwt.verify.mockReturnValue({
userMatricule: 'S601153',
userAuthGroupe: 'ADMIN',
userLastName: 'TEST',
userFirstName: 'Admin'
});
});
test('GET /advalo/health/axa returns service payload', async () => {
advaloService.getAxaHealth.mockResolvedValue({
ok: true,
helperReady: true
});
const app = createApp();
const res = await request(app)
.get('/advalo/health/axa')
.set('Authorization', 'Bearer token');
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.ok).toBe(true);
expect(advaloService.getAxaHealth).toHaveBeenCalledTimes(1);
});
test('GET /advalo/cache/status returns cache status', async () => {
advaloService.getCacheStatus.mockResolvedValue({
loaded: true,
version: 4,
counts: { merged: 42 }
});
const app = createApp();
const res = await request(app)
.get('/advalo/cache/status')
.set('Authorization', 'Bearer token');
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.loaded).toBe(true);
expect(res.body.version).toBe(4);
expect(advaloService.getCacheStatus).toHaveBeenCalledTimes(1);
});
test('POST /advalo/cache/rebuild returns rebuild status', async () => {
advaloService.rebuildCache.mockResolvedValue({
loaded: true,
version: 5,
counts: { merged: 100 }
});
const app = createApp();
const res = await request(app)
.post('/advalo/cache/rebuild')
.set('Authorization', 'Bearer token')
.send({});
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.version).toBe(5);
expect(advaloService.rebuildCache).toHaveBeenCalledTimes(1);
});
test('errors are normalized with status + code', async () => {
const err = new Error('forbidden');
err.status = 403;
err.code = 'FORBIDDEN';
advaloService.getCacheStatus.mockRejectedValue(err);
const app = createApp();
const res = await request(app)
.get('/advalo/cache/status')
.set('Authorization', 'Bearer token');
expect(res.status).toBe(403);
expect(res.body.valid).toBe(false);
expect(res.body.code).toBe('FORBIDDEN');
expect(res.body.message).toBe('forbidden');
});
});

View File

@ -19,13 +19,16 @@ function getActor(req) {
function handleError(res, error) { function handleError(res, error) {
logger.log('error', `Advalo error: ${error.message}`, { logger.log('error', `Advalo error: ${error.message}`, {
code: error.code || '',
status: error.status || 500, status: error.status || 500,
stack: error.stack, stack: error.stack,
data: error.data, data: error.data,
details: error.details,
originalError: error.originalError ? String(error.originalError) : undefined originalError: error.originalError ? String(error.originalError) : undefined
}); });
return res.status(error.status || 500).json({ return res.status(error.status || 500).json({
valid: false, valid: false,
code: error.code || 'INTERNAL_ERROR',
message: error.message || 'Erreur serveur Advalorem' message: error.message || 'Erreur serveur Advalorem'
}); });
} }
@ -93,6 +96,16 @@ router.post('/ponctuel', async (req, res) => {
} }
}); });
router.put('/demande/:id', async (req, res) => {
try {
const actor = getActor(req);
const row = await advaloService.updateDemande(req.params.id, req.body, actor);
return res.json({ valid: true, row });
} catch (error) {
return handleError(res, error);
}
});
router.delete('/demande/:id', async (req, res) => { router.delete('/demande/:id', async (req, res) => {
try { try {
const actor = getActor(req); const actor = getActor(req);
@ -159,6 +172,36 @@ router.get('/reporting', async (req, res) => {
} }
}); });
router.get('/health/axa', async (req, res) => {
try {
const actor = getActor(req);
const health = await advaloService.getAxaHealth(actor);
return res.json({ valid: true, ...health });
} catch (error) {
return handleError(res, error);
}
});
router.get('/cache/status', async (req, res) => {
try {
const actor = getActor(req);
const status = await advaloService.getCacheStatus(actor);
return res.json({ valid: true, ...status });
} catch (error) {
return handleError(res, error);
}
});
router.post('/cache/rebuild', async (req, res) => {
try {
const actor = getActor(req);
const status = await advaloService.rebuildCache(actor);
return res.json({ valid: true, ...status });
} catch (error) {
return handleError(res, error);
}
});
router.get('/export', async (req, res) => { router.get('/export', async (req, res) => {
try { try {
const actor = getActor(req); const actor = getActor(req);

View File

@ -0,0 +1 @@

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2,6 +2,7 @@
const app = require('./app'); const app = require('./app');
const logger = require('./utils/logger'); const logger = require('./utils/logger');
const database = require('./db/db-connect'); const database = require('./db/db-connect');
const advaloService = require('./services/advaloService');
// Port par défaut ou port fourni par les variables d'environnement // Port par défaut ou port fourni par les variables d'environnement
let port = process.env.PORT || 3000; let port = process.env.PORT || 3000;
@ -32,6 +33,14 @@ async function startServer() {
process.exit(1); process.exit(1);
} }
try {
await advaloService.preloadCache();
} catch (error) {
logger.log('warn', 'Préchargement cache Advalo échoué, fallback lazy', {
message: error.message
});
}
// Tentative de démarrage du serveur // Tentative de démarrage du serveur
while (true) { while (true) {
try { try {

View File

@ -0,0 +1,205 @@
// Mocks DB + pont AXA AVANT le require du service (jest hoiste les jest.mock).
const mockRecords = {
getList: jest.fn(),
getOne: jest.fn(),
create: jest.fn(),
update: jest.fn()
};
const mockRunQt550 = jest.fn().mockResolvedValue({ ok: true });
jest.mock('../../db/db-connect', () => ({
db: { baseUrl: 'http://localhost:8090/', records: mockRecords }
}));
jest.mock('../axaBridgeService', () => ({
runQt550: (...args) => mockRunQt550(...args),
lookupContract: jest.fn(),
getHealth: jest.fn()
}));
const path = require('path');
const PizZip = require('pizzip');
const advaloService = require('../advaloService');
const { buildCommonDocContext, normalizeLookupInfo, normalizeMergedFallbackRow, renderAdvaloDocx, advaloCache } = advaloService.__private;
function docxText(buffer) {
const xml = new PizZip(buffer).file('word/document.xml').asText();
return (xml.match(/<w:t[^>]*>(.*?)<\/w:t>/g) || []).map((t) => t.replace(/<[^>]+>/g, '')).join(' ');
}
const actor = { userMatricule: 'A123BC', userAuthGroupe: 'MANAGER', userFirstName: 'Jean', userLastName: 'Dupont' };
describe('Advalo — parité v1 du calcul documentaire', () => {
test('buildCommonDocContext injecte les prix réellement saisis (pas les valeurs figées)', () => {
const row = { numContrat: '0000022126873304', capital: '100000', tarif: '336,00', dateDebut: '01/04/2026', dateFin: '02/04/2026', mode: 'Terrestre', marchandise: 'Moteurs' };
const contractInfo = { nomClient: 'DURAND & FILS', adresseAgent: '1 rue de Paris', postalAgent: '75001 PARIS', telAgent: '0102030405', faxAgent: '0102030406', numAgent: '0009', nomAgent: 'AGENCE' };
const pricing = { valeurAssuree: '100000', taux: '0.5', primeMinimum: '20', cotisationHT: '500', coutActe: '36', cotisationTTC: '536' };
const ctx = buildCommonDocContext(row, contractInfo, actor, pricing);
expect(ctx.tauxField).toBe('0,50');
expect(ctx.primeMinimum).toBe('20,00');
expect(ctx.cotisationField).toBe('500,00');
expect(ctx.coutActeField).toBe('36,00');
expect(ctx.cotisationTTC).toBe('536,00');
// Coordonnées agent propagées dans l'en-tête (parité v1 Avenant).
expect(ctx.adresseAgent).toBe('1 rue de Paris');
expect(ctx.telAgent).toBe('0102030405');
expect(ctx.faxAgent).toBe('0102030406');
// Valeurs brutes (pas de double-échappement XML) — docxtemplater échappera.
expect(ctx.nomClient).toBe('DURAND & FILS');
});
test('rendu réel Avenant_Ponctuel.docx (docxtemplater) contient les prix saisis + phrase TTC', () => {
const row = { numContrat: '0000022126873304', capital: '100000', tarif: '536,00', dateDebut: '01/04/2026', dateFin: '02/04/2026', mode: 'Terrestre, Aérien', marchandise: 'Moteurs', depart: 'Paris', arrivee: 'Lyon' };
const contractInfo = { nomClient: 'DURAND & FILS', adresseAgent: '1 rue de Paris' };
const pricing = { valeurAssuree: '100000', taux: '0.5', primeMinimum: '20', cotisationHT: '500', coutActe: '36', cotisationTTC: '536' };
const ctx = buildCommonDocContext(row, contractInfo, actor, pricing);
const tplPath = path.resolve(__dirname, '..', '..', 'templates', 'advalo', 'Avenant_Ponctuel.docx');
const text = docxText(renderAdvaloDocx(tplPath, ctx));
expect(text).toContain('500,00'); // cotisation HT
expect(text).toContain('536,00'); // TTC
expect(text).toContain('Il est perçu');
expect(text).toContain('Moteurs');
expect(text).toContain('DURAND &amp; FILS'); // échappé une seule fois
});
test('buildCommonDocContext: défauts v1 (0,30 / 15 / 36) seulement à défaut de pricing, TTC = HT + acte', () => {
const row = { numContrat: '0000022126873304', capital: '3000', tarif: '15,00' };
const ctx = buildCommonDocContext(row, {}, actor, {});
expect(ctx.tauxField).toBe('0,30');
expect(ctx.primeMinimum).toBe('15,00');
expect(ctx.cotisationField).toBe('15,00'); // HT = tarif (grille déléguée)
expect(ctx.coutActeField).toBe('36,00');
expect(ctx.cotisationTTC).toBe('51,00'); // 15 + 36
});
});
describe('Advalo — lookup contrat (coordonnées intermédiaire)', () => {
test('normalizeLookupInfo conserve adresseAgent/postalAgent/telAgent/faxAgent', () => {
const raw = {
numContrat: '0000022126873304', numClient: '0858406820', nomClient: 'CLIENT',
nomAgent: 'AGENCE', numAgent: '0009876543',
adresseAgent: '1 rue de Paris', postalAgent: '75001 PARIS', telAgent: '0102030405', faxAgent: '0102030406'
};
const info = normalizeLookupInfo(raw, '0000022126873304');
expect(info.adresseAgent).toBe('1 rue de Paris');
expect(info.postalAgent).toBe('75001 PARIS');
expect(info.telAgent).toBe('0102030405');
expect(info.faxAgent).toBe('0102030406');
});
});
describe('Advalo — enrichissement Cumul/Reporting via référentiel contrat', () => {
test('normalizeMergedFallbackRow enrichit region/dpt/souscripteur depuis le ref (parité v1 getVarByNumContrat)', () => {
advaloCache.refContratById.set('0000022126873304', {
nomClient: 'KANGOUROUBOX', region: 'ILE DE FRANCE', dpt: '75', souscripteur: 'Z999ZZ'
});
const delegueeRow = normalizeMergedFallbackRow(
{ id: 'g1', numContrat: '0000022126873304', tarif: '15 €', statutFacturation: 'Non facturé' },
'deleguee'
);
expect(delegueeRow.region).toBe('ILE DE FRANCE');
expect(delegueeRow.dpt).toBe('75');
expect(delegueeRow.souscripteur).toBe('Z999ZZ');
expect(delegueeRow.nomClient).toBe('KANGOUROUBOX');
advaloCache.refContratById.delete('0000022126873304');
});
});
describe('Advalo — modification demande hors-grille (parité v1 "Modification")', () => {
beforeEach(() => {
Object.values(mockRecords).forEach((fn) => fn.mockReset());
advaloCache.loaded = false;
});
test('updateDemande met à jour les champs et journalise le pricing (event update)', async () => {
mockRecords.getOne.mockResolvedValue({
id: 'dem9', numContrat: '0000022126873304', numClient: '0858406820',
statutFacturation: 'Non facturé', isDeleted: false
});
mockRecords.update.mockImplementation(async (collection, id, patch) => ({ id, ...patch }));
mockRecords.create.mockResolvedValue({ id: 'aud9' });
const updated = await advaloService.updateDemande('d:dem9', {
marchandise: 'Pièces auto', mode: 'Terrestre, Maritime', depart: 'Lyon', arrivee: 'Gênes',
dateDebut: '05/04/2026', dateFin: '06/04/2026', capital: '50000',
taux: '0.4', primeMinimum: '15', coutActe: '36', cotisationHT: '200', cotisationTTC: '236'
}, actor);
expect(updated.marchandise).toBe('Pièces auto');
expect(updated.mode).toBe('Terrestre, Maritime');
expect(updated.tarif).toBe('236');
const audit = mockRecords.create.mock.calls.find((c) => c[0] === 'advalo_audit');
expect(audit[1].eventType).toBe('update');
expect(audit[1].data.pricing.cotisationHT).toBe('200');
});
test('updateDemande refuse une demande déjà facturée', async () => {
mockRecords.getOne.mockResolvedValue({ id: 'dem8', numContrat: '0000022126873304', statutFacturation: 'Facturé 01/04/2026', isDeleted: false });
await expect(advaloService.updateDemande('d:dem8', { marchandise: 'x', mode: 'Terrestre', depart: 'a', arrivee: 'b', dateDebut: '01/04/2026', dateFin: '02/04/2026', capital: '1', taux: '0.3', primeMinimum: '15', cotisationHT: '15', cotisationTTC: '51' }, actor))
.rejects.toThrow(/facturée ne peut pas être modifiée/);
});
});
describe('Advalo — facturation QT550 (parité v1: HT à Pos=329, coût d acte une seule fois)', () => {
const realPlatform = process.platform;
beforeEach(() => {
Object.values(mockRecords).forEach((fn) => fn.mockReset());
mockRunQt550.mockClear().mockResolvedValue({ ok: true });
// Cache vide forcé à chaque test pour isoler.
advaloCache.loaded = false;
advaloCache.loadingPromise = null;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
});
test('facturerBatch envoie la cotisation HT (audit) et le coût d acte 36 une seule fois — pas de double comptage', async () => {
const demande = {
id: 'dem1', numContrat: '0000022126873304', numClient: '0858406820', nomClient: 'CLIENT',
capital: '100000', tarif: '336,00', // TTC stocké (HT 300 + 36)
dateDebut: '01/04/2026', dateFin: '02/04/2026',
statutFacturation: 'Non facturé', isDeleted: false
};
const auditRec = {
eventType: 'create', created: '2026-04-01T10:00:00.000Z',
data: { demandeId: 'dem1', pricing: { cotisationHT: '300,00', coutActe: '36,00', cotisationTTC: '336,00' } }
};
mockRecords.getOne.mockImplementation(async (collection, id) => {
if (collection === 'advalo_demande' && id === 'dem1') return { ...demande };
throw new Error(`unexpected getOne ${collection} ${id}`);
});
mockRecords.getList.mockImplementation(async (collection) => {
if (collection === 'advalo_audit') return { items: [auditRec], totalItems: 1, totalPages: 1 };
if (collection === 'advalo_facturation_batch') return { items: [], totalItems: 0, totalPages: 1 };
return { items: [], totalItems: 0, totalPages: 1 };
});
mockRecords.create.mockImplementation(async (collection, payload) => ({ id: `${collection}-1`, ...payload }));
mockRecords.update.mockImplementation(async (collection, id, patch) => ({ id, ...patch }));
const result = await advaloService.facturerBatch(
{ demandeIds: ['d:dem1'], sourceMode: 'hors_grille', includeTransportDetails: true },
actor
);
expect(result.idempotent).toBe(false);
// QT550 reçoit le HT (300), PAS le TTC (336), et coutActe '36' une seule fois.
expect(mockRunQt550).toHaveBeenCalledTimes(1);
const qtArgs = mockRunQt550.mock.calls[0][0];
expect(qtArgs.totalCotisation).toBe(300);
expect(qtArgs.coutActe).toBe('36');
// Le lot persiste le total HT + l acte séparé.
const batchCreate = mockRecords.create.mock.calls.find((c) => c[0] === 'advalo_facturation_batch');
expect(batchCreate).toBeDefined();
expect(batchCreate[1].totalCotisation).toBe(300);
expect(batchCreate[1].totalActe).toBe(advaloService.__private.COUT_ACTE);
});
});

View File

@ -0,0 +1,80 @@
const axaBridgeService = require('../axaBridgeService');
function withAt(screen, pos, value) {
const chars = screen.split('');
const start = Math.max(0, pos - 1);
for (let i = 0; i < String(value).length; i += 1) {
chars[start + i] = String(value)[i];
}
return chars.join('');
}
function emptyScreen(length = 2200) {
return ''.padEnd(length, ' ');
}
describe('axaBridgeService parsing', () => {
test('parsePa025Screen extracts expected offsets', () => {
let screen = emptyScreen();
screen = withAt(screen, 81, 'CLIENT TEST');
screen = withAt(screen, 161, 'SA');
screen = withAt(screen, 283, '0012345678');
screen = withAt(screen, 241, '12 RUE DES TESTS');
screen = withAt(screen, 321, 'PARIS');
screen = withAt(screen, 401, '75001');
screen = withAt(screen, 407, 'PARIS CENTRE');
screen = withAt(screen, 726, '31122');
screen = withAt(screen, 203, '0009876543');
screen = withAt(screen, 443, 'RC123');
const parsed = axaBridgeService.__private.parsePa025Screen(screen);
expect(parsed.nomClient).toBe('CLIENT TEST SA');
expect(parsed.numClient).toBe('0012345678');
expect(parsed.adresseClient).toBe('12 RUE DES TESTS PARIS');
expect(parsed.codePostal).toBe('75001 PARIS CENTRE');
expect(parsed.dateFin).toBe('31122');
expect(parsed.numAgent).toBe('0009876543');
expect(parsed.codeProduit).toBe('RC123');
});
test('parseCl063Screen extracts expected offsets', () => {
let screen = emptyScreen();
screen = withAt(screen, 149, '0011122233');
screen = withAt(screen, 243, 'SARL ');
screen = withAt(screen, 249, 'EXEMPLE CLIENT');
screen = withAt(screen, 323, 'LIGNE 1');
screen = withAt(screen, 403, 'LIGNE 2');
screen = withAt(screen, 483, 'VILLE TEST');
screen = withAt(screen, 563, '69000');
screen = withAt(screen, 569, 'LYON');
screen = withAt(screen, 810, '123');
screen = withAt(screen, 814, '456');
screen = withAt(screen, 818, '789');
screen = withAt(screen, 822, '00012');
const parsed = axaBridgeService.__private.parseCl063Screen(screen);
expect(parsed.nomClient).toBe('SARL EXEMPLE CLIENT');
expect(parsed.adresseClient).toBe('LIGNE 1 LIGNE 2 VILLE TEST');
expect(parsed.codePostal).toBe('69000 LYON');
expect(parsed.numAgent).toBe('0011122233');
expect(parsed.numAgentPortfolio).toBe('011122233');
expect(parsed.siren).toBe('12345678900012');
});
test('parseAc800Screen extracts expected offsets', () => {
let screen = emptyScreen();
screen = withAt(screen, 824, 'AGENT NOM');
screen = withAt(screen, 891, 'ADRESSE A');
screen = withAt(screen, 924, '75015');
screen = withAt(screen, 931, 'PARIS');
screen = withAt(screen, 971, '0102030405');
screen = withAt(screen, 1025, '0504030201');
const parsed = axaBridgeService.__private.parseAc800Screen(screen, true);
expect(parsed.nomAgent).toBe('AGENT NOM');
expect(parsed.adresseAgent).toBe('ADRESSE A 75015 PARIS');
expect(parsed.telAgent).toBe('0102030405');
expect(parsed.faxAgent).toBe('0504030201');
expect(parsed.postalAgent).toBe('75015 PARIS');
});
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,514 @@
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const runtimePaths = require('../utils/runtimePaths');
const logger = require('../utils/logger');
const AXA_ROOT = runtimePaths.resolveWorkspacePath('vbs');
const HELPER_PROJECT_DIR = runtimePaths.resolveWorkspacePath('src', 'axa-helper');
const HELPER_OUTPUT_DIR = runtimePaths.resolveWorkspacePath('src', 'axa-helper', 'bin', 'publish');
const HELPER_EXE_PATH = process.env.AXA_DDE_HELPER_EXE
? path.resolve(process.env.AXA_DDE_HELPER_EXE)
: path.join(HELPER_OUTPUT_DIR, 'AxaDdeBridge.exe');
const HELPER_TIMEOUT_MS = Number(process.env.AXA_TIMEOUT_MS || 65000);
const lockMap = new Map();
let helperBuildPromise = null;
function ensureWindows() {
if (process.platform !== 'win32') {
const err = new Error('Pont AXA indisponible sur cet environnement (Windows requis).');
err.code = 'AXA_WINDOWS_REQUIRED';
err.status = 502;
throw err;
}
}
function sanitizeFilePart(value) {
return String(value || '')
.replace(/[^a-zA-Z0-9-_]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '');
}
function padLeft(value, len, char = '0') {
const text = String(value || '');
if (text.length >= len) return text;
return `${char.repeat(len - text.length)}${text}`;
}
function trimLeadingZeros(value) {
const digits = String(value || '').replace(/\D/g, '');
if (!digits) return '';
return digits.replace(/^0+(?=\d)/, '');
}
function screenSlice(screen, start, len) {
const src = String(screen || '');
const startIndex = Math.max(Number(start || 1) - 1, 0);
const size = Math.max(Number(len || 0), 0);
if (startIndex >= src.length || size <= 0) return '';
return src.slice(startIndex, startIndex + size);
}
function collapseSpaces(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function parseAgentPortfolio(value) {
return String(value || '').replace(/\D/g, '').slice(-9);
}
function parsePa025Screen(screen) {
return {
nomClient: collapseSpaces(screenSlice(screen, 81, 30) + screenSlice(screen, 161, 30)),
numClient: String(screenSlice(screen, 283, 10) || '').replace(/\D/g, ''),
adresseClient: collapseSpaces(screenSlice(screen, 241, 30) + screenSlice(screen, 321, 30)),
codePostal: collapseSpaces(screenSlice(screen, 401, 5) + ' ' + screenSlice(screen, 407, 24)),
dateFin: collapseSpaces(screenSlice(screen, 726, 5)),
numAgent: String(screenSlice(screen, 203, 10) || '').replace(/\D/g, ''),
codeProduit: collapseSpaces(screenSlice(screen, 443, 5))
};
}
function parseCl063Screen(screen) {
const rawNumAgent = String(screenSlice(screen, 149, 10) || '').replace(/\D/g, '');
return {
nomClient: collapseSpaces(screenSlice(screen, 243, 5) + screenSlice(screen, 249, 30)),
adresseClient: collapseSpaces(screenSlice(screen, 323, 30) + screenSlice(screen, 403, 26) + ' ' + screenSlice(screen, 483, 30)),
codePostal: collapseSpaces(screenSlice(screen, 563, 5) + ' ' + screenSlice(screen, 569, 30)),
numAgent: rawNumAgent,
numAgentPortfolio: parseAgentPortfolio(rawNumAgent),
siren: String(
screenSlice(screen, 810, 3)
+ screenSlice(screen, 814, 3)
+ screenSlice(screen, 818, 3)
+ screenSlice(screen, 822, 5)
).replace(/\D/g, '')
};
}
function parseAc800Screen(screen, includePostal = false) {
const out = {
nomAgent: collapseSpaces(screenSlice(screen, 824, 32)),
adresseAgent: collapseSpaces(screenSlice(screen, 891, 30) + screenSlice(screen, 924, 30)),
telAgent: collapseSpaces(screenSlice(screen, 971, 15)),
faxAgent: collapseSpaces(screenSlice(screen, 1025, 15))
};
if (includePostal) {
out.postalAgent = collapseSpaces(screenSlice(screen, 924, 5) + ' ' + screenSlice(screen, 931, 23));
}
return out;
}
function runProcess(command, args, { cwd, timeoutMs = HELPER_TIMEOUT_MS } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
windowsHide: true
});
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
child.kill('SIGTERM');
const err = new Error(`Process timeout: ${command}`);
err.code = 'AXA_PROCESS_TIMEOUT';
err.status = 504;
reject(err);
}, timeoutMs);
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
child.on('exit', (code) => {
clearTimeout(timer);
if (code !== 0) {
const err = new Error(`Process failed (${command}) code=${code}`);
err.code = 'AXA_PROCESS_FAILED';
err.status = 502;
err.details = { code, stdout, stderr };
return reject(err);
}
return resolve({ stdout, stderr });
});
});
}
async function ensureHelperBuilt() {
if (fs.existsSync(HELPER_EXE_PATH)) return HELPER_EXE_PATH;
if (helperBuildPromise) return helperBuildPromise;
helperBuildPromise = (async () => {
ensureWindows();
const projectFile = path.join(HELPER_PROJECT_DIR, 'AxaDdeBridge.csproj');
if (!fs.existsSync(projectFile)) {
const err = new Error(`Projet helper AXA introuvable: ${projectFile}`);
err.code = 'AXA_HELPER_PROJECT_MISSING';
err.status = 500;
throw err;
}
fs.mkdirSync(HELPER_OUTPUT_DIR, { recursive: true });
try {
await runProcess('dotnet', [
'publish',
projectFile,
'-c',
'Release',
'-r',
'win-x86',
'--self-contained',
'false',
'-o',
HELPER_OUTPUT_DIR
], {
cwd: HELPER_PROJECT_DIR,
timeoutMs: 180000
});
} catch (error) {
logger.log('error', 'AXA helper publish failed', {
message: error.message,
details: error.details
});
const err = new Error('Impossible de compiler le helper AXA DDE (dotnet publish).');
err.code = 'AXA_HELPER_BUILD_FAILED';
err.status = 500;
throw err;
}
if (!fs.existsSync(HELPER_EXE_PATH)) {
const err = new Error(`Binaire helper AXA introuvable après build: ${HELPER_EXE_PATH}`);
err.code = 'AXA_HELPER_EXE_MISSING';
err.status = 500;
throw err;
}
return HELPER_EXE_PATH;
})();
try {
return await helperBuildPromise;
} finally {
helperBuildPromise = null;
}
}
function createScriptFile(scriptDir, matricule, lines, prefix) {
const safeMatricule = sanitizeFilePart(matricule || 'SYSTEM');
const filePath = path.join(
scriptDir,
'config',
`${prefix}_${safeMatricule}_${Date.now()}_${Math.random().toString(16).slice(2)}.txt`
);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${lines.join('\r\n')}\r\n`, 'utf8');
return filePath;
}
function readCaptureLine(filePath) {
if (!fs.existsSync(filePath)) return '';
const raw = fs.readFileSync(filePath, 'latin1');
const lines = raw
.split(/\r?\n/)
.map((line) => line.replace(/\0/g, '').trimEnd())
.filter((line) => line.length > 0);
return lines.length ? lines[lines.length - 1] : '';
}
async function executeAxaScript({ scriptDir, matricule, lines, label }) {
ensureWindows();
const helperExe = await ensureHelperBuilt();
const scriptPath = createScriptFile(scriptDir, matricule, lines, `runtime_${label}`);
try {
const { stdout } = await runProcess(helperExe, [
'--script',
scriptPath,
'--workdir',
scriptDir,
'--timeout-ms',
String(HELPER_TIMEOUT_MS)
], {
cwd: scriptDir,
timeoutMs: HELPER_TIMEOUT_MS
});
const parsedLine = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reverse()
.find((line) => line.startsWith('{') && line.endsWith('}'));
if (!parsedLine) {
const err = new Error(`Réponse helper AXA invalide (${label}).`);
err.code = 'AXA_HELPER_INVALID_OUTPUT';
err.status = 502;
throw err;
}
const result = JSON.parse(parsedLine);
if (!result.ok) {
const err = new Error(result.message || `Helper AXA en échec (${label}).`);
err.code = result.errorCode || 'AXA_HELPER_FAILED';
err.status = 502;
throw err;
}
return result;
} finally {
try {
fs.unlinkSync(scriptPath);
} catch (_error) {
// no-op
}
}
}
async function withMatriculeLock(matricule, fn) {
const key = sanitizeFilePart(matricule || 'SYSTEM') || 'SYSTEM';
const previous = lockMap.get(key) || Promise.resolve();
let release;
const current = new Promise((resolve) => {
release = resolve;
});
lockMap.set(key, current);
try {
await previous;
return await fn();
} finally {
release();
if (lockMap.get(key) === current) {
lockMap.delete(key);
}
}
}
function buildCapturePath(scriptDir, matricule, prefix) {
const safeMatricule = sanitizeFilePart(matricule || 'SYSTEM');
return path.join(scriptDir, 'config', `${prefix}_${safeMatricule}.txt`);
}
async function runCl063(matricule, numClient) {
const scriptDir = path.join(AXA_ROOT, 'script_cl063');
const capturePath = buildCapturePath(scriptDir, matricule, 'capture_runtime_cl063');
fs.mkdirSync(path.dirname(capturePath), { recursive: true });
fs.writeFileSync(capturePath, '', 'latin1');
const lines = [
"[SENDFONC] FONC='CLEAR';",
'[WAITBUSY]',
`[SEND] Pos=0162; Data='cl063 /${String(numClient || '').replace(/\D/g, '')}';`,
"[SENDFONC] FONC='ENTER';",
`[ECRAN] Pos=1; Stop=1900; Data='${capturePath}';`,
'[END] Par=100;'
];
await executeAxaScript({ scriptDir, matricule, lines, label: 'cl063' });
const screen = readCaptureLine(capturePath);
const info = parseCl063Screen(screen);
return {
...info,
rawScreen: screen
};
}
async function runPa025(matricule, numContrat) {
const scriptDir = path.join(AXA_ROOT, 'script_pa025');
const capturePath = buildCapturePath(scriptDir, matricule, 'capture_runtime_pa025');
fs.mkdirSync(path.dirname(capturePath), { recursive: true });
fs.writeFileSync(capturePath, '', 'latin1');
const safeContrat = trimLeadingZeros(numContrat);
const lines = [
"[SENDFONC] FONC='CLEAR';",
'[WAITBUSY]',
`[SEND] Pos=0162; Data='pa025 ${safeContrat}';`,
"[SENDFONC] FONC='ENTER';",
`[ECRAN] Pos=1; Stop=1900; Data='${capturePath}';`,
'[END] Par=100;'
];
await executeAxaScript({ scriptDir, matricule, lines, label: 'pa025' });
const screen = readCaptureLine(capturePath);
const info = parsePa025Screen(screen);
return {
...info,
rawScreen: screen
};
}
async function runAc800(matricule, numPortefeuille, { includePostal = false } = {}) {
const scriptDir = path.join(AXA_ROOT, 'script_cl063');
const capturePath = buildCapturePath(scriptDir, matricule, 'capture_runtime_ac800');
fs.mkdirSync(path.dirname(capturePath), { recursive: true });
fs.writeFileSync(capturePath, '', 'latin1');
const portfolio = parseAgentPortfolio(numPortefeuille);
const lines = [
"[SENDFONC] FONC='CLEAR';",
'[WAITBUSY]',
`[SEND] Pos=0162; Data='AC800 ${portfolio}';`,
"[SENDFONC] FONC='ENTER';",
`[ECRAN] Pos=1; Stop=1900; Data='${capturePath}';`,
"[SENDFONC] FONC='CLEAR';",
'[WAITBUSY]',
'[END] Par=100;'
];
await executeAxaScript({ scriptDir, matricule, lines, label: 'ac800' });
const screen = readCaptureLine(capturePath);
return {
...parseAc800Screen(screen, includePostal),
rawScreen: screen
};
}
async function lookupContract({ matricule, numContrat }) {
ensureWindows();
const safeMatricule = sanitizeFilePart(matricule || 'SYSTEM');
return withMatriculeLock(safeMatricule, async () => {
const pa025 = await runPa025(safeMatricule, numContrat);
const agentDetails = await runAc800(safeMatricule, pa025.numAgent, { includePostal: true });
return {
numContrat: String(numContrat || '').replace(/\D/g, '').padStart(16, '0').slice(-16),
numClient: pa025.numClient || '',
nomClient: pa025.nomClient || '',
adresseClient: pa025.adresseClient || '',
codePostal: pa025.codePostal || '',
numAgent: pa025.numAgent || '',
codeProduit: pa025.codeProduit || '',
nomAgent: agentDetails.nomAgent || '',
adresseAgent: agentDetails.adresseAgent || '',
telAgent: agentDetails.telAgent || '',
faxAgent: agentDetails.faxAgent || '',
postalAgent: agentDetails.postalAgent || '',
source: 'axa_dde'
};
});
}
function formatQtDate(value) {
const raw = String(value || '').trim();
const match = raw.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (!match) return raw;
return `${match[1]}/${match[2]}/${match[3].slice(2)}`;
}
function formatDecimal(value) {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.replace(/\./g, ',');
}
async function runQt550({ matricule, numContrat, totalCotisation, coutActe = '36', dateDebut, dateFin }) {
ensureWindows();
const safeMatricule = sanitizeFilePart(matricule || 'SYSTEM');
return withMatriculeLock(safeMatricule, async () => {
const scriptDir = path.join(AXA_ROOT, 'script_qt550');
const safeContrat = trimLeadingZeros(numContrat);
const debut = formatQtDate(dateDebut).split('/');
const fin = formatQtDate(dateFin).split('/');
const dd1 = padLeft(debut[0] || '', 2);
const mm1 = padLeft(debut[1] || '', 2);
const yy1 = padLeft((debut[2] || '').slice(-2), 2);
const dd2 = padLeft(fin[0] || '', 2);
const mm2 = padLeft(fin[1] || '', 2);
const yy2 = padLeft((fin[2] || '').slice(-2), 2);
const lines = [
"[SENDFONC] FONC='CLEAR';",
'[WAITBUSY]',
`[SEND] Pos=0162; Data='pa025 ${safeContrat}';`,
"[SENDFONC] FONC='ENTER';",
"[SEND] Pos=1809; Data='QT550';",
"[SENDFONC] FONC='ENTER';",
'[WAITBUSY]',
"[SEND] Pos=736; Data='E';",
"[SEND] Pos=896; Data='O';",
"[SEND] Pos=1296; Data='M';",
"[SEND] Pos=1348; Data='P';",
"[SEND] Pos=1456; Data='1';",
"[SEND] Pos=1616; Data='7';",
`[SEND] Pos=1696; Data='${formatDecimal(coutActe)}';`,
`[SEND] Pos=766; Data='${dd1}';`,
`[SEND] Pos=769; Data='${mm1}';`,
`[SEND] Pos=772; Data='${yy1}';`,
`[SEND] Pos=791; Data='${dd2}';`,
`[SEND] Pos=794; Data='${mm2}';`,
`[SEND] Pos=797; Data='${yy2}';`,
"[SENDFONC] FONC='ENTER';",
'[WAITBUSY]',
"[SEND] Pos=325; Data='44';",
`[SEND] Pos=329; Data='${formatDecimal(totalCotisation)}';`,
"[SENDFONC] FONC='ENTER';",
'[WAITBUSY]',
'[END] Par=100;'
];
await executeAxaScript({ scriptDir, matricule: safeMatricule, lines, label: 'qt550' });
return { ok: true };
});
}
async function getHealth({ matricule } = {}) {
const health = {
ok: true,
platform: process.platform,
helperPath: HELPER_EXE_PATH,
helperReady: false,
scriptRoots: {
cl063: path.join(AXA_ROOT, 'script_cl063'),
pa025: path.join(AXA_ROOT, 'script_pa025'),
qt550: path.join(AXA_ROOT, 'script_qt550')
},
checks: [],
errors: []
};
try {
ensureWindows();
await ensureHelperBuilt();
health.helperReady = fs.existsSync(HELPER_EXE_PATH);
Object.entries(health.scriptRoots).forEach(([name, root]) => {
const ok = fs.existsSync(root) && fs.existsSync(path.join(root, 'ECFDDEViva.exe'));
health.checks.push({
target: name,
root,
ok
});
if (!ok) {
health.errors.push(`Missing AXA script root or ECFDDEViva.exe for ${name}: ${root}`);
}
});
if (matricule) {
health.lockKey = sanitizeFilePart(matricule);
health.lockBusy = lockMap.has(health.lockKey);
}
} catch (error) {
health.ok = false;
health.errors.push(error.message);
}
if (health.errors.length) health.ok = false;
return health;
}
module.exports = {
lookupContract,
runQt550,
getHealth,
__private: {
parsePa025Screen,
parseCl063Screen,
parseAc800Screen,
screenSlice
}
};

Binary file not shown.

View File

@ -41,9 +41,51 @@ if (process.env.NODE_ENV !== 'production') {
logger.level = 'debug'; logger.level = 'debug';
} }
function toSerializableError(error) {
if (!error || typeof error !== 'object') return null;
const out = {
name: error.name,
message: error.message,
code: error.code,
status: error.status,
stack: error.stack,
details: error.details,
data: error.data
};
return Object.fromEntries(Object.entries(out).filter(([, value]) => value !== undefined));
}
function toSerializableMeta(meta) {
if (meta === null || meta === undefined) return null;
if (meta instanceof Error) return toSerializableError(meta);
if (typeof meta === 'object') return meta;
return { value: String(meta) };
}
function safeStringify(value) {
try {
return JSON.stringify(value);
} catch (_error) {
return String(value);
}
}
module.exports = { module.exports = {
log: function (level, message, error = null, matricule = null) { log: function (level, message, meta = null, matricule = null) {
const formattedMessage = error ? `${message} : ${error.message}` : message; const baseError = message instanceof Error ? toSerializableError(message) : null;
const extraMeta = toSerializableMeta(meta);
const mergedMeta = baseError || extraMeta ? { ...(baseError || {}), ...(extraMeta || {}) } : null;
let baseMessage = '';
if (typeof message === 'string') baseMessage = message;
else if (message instanceof Error) baseMessage = message.message || message.name || 'Erreur';
else if (message && typeof message === 'object') baseMessage = 'Log';
else if (message !== undefined && message !== null) baseMessage = String(message);
else baseMessage = 'Log';
const formattedMessage = mergedMeta && Object.keys(mergedMeta).length
? `${baseMessage} | ${safeStringify(mergedMeta)}`
: baseMessage;
logger.log({ logger.log({
level, level,

View File

@ -0,0 +1,32 @@
const fs = require('fs');
const path = require('path');
function resolveWorkspaceRoot() {
if (process.env.ADV_WORKSPACE_ROOT) {
return path.resolve(process.env.ADV_WORKSPACE_ROOT);
}
if (process.pkg) {
const exeRoot = path.dirname(process.execPath);
const nestedEcoleRoot = path.join(exeRoot, 'ecole');
if (!fs.existsSync(path.join(exeRoot, 'src')) && fs.existsSync(path.join(nestedEcoleRoot, 'src'))) {
return nestedEcoleRoot;
}
return exeRoot;
}
return path.resolve(__dirname, '..', '..');
}
const workspaceRoot = resolveWorkspaceRoot();
function resolveWorkspacePath(...parts) {
return path.resolve(workspaceRoot, ...parts);
}
module.exports = {
workspaceRoot,
resolveWorkspacePath
};

View File

@ -324,3 +324,46 @@
<a href="#!" id="advalo-batch-close" class="modal-close waves-effect btn-flat">Fermer</a> <a href="#!" id="advalo-batch-close" class="modal-close waves-effect btn-flat">Fermer</a>
</div> </div>
</div> </div>
<div id="advalo-edit-modal" class="modal modal-fixed-footer">
<div class="modal-content">
<h5>Modifier la demande hors grille</h5>
<input type="hidden" id="e-id">
<div class="row" style="margin-top:12px;">
<div class="input-field col s12 m6"><input id="e-marchandise"><label for="e-marchandise">Marchandise *</label></div>
<div class="col s12 m6">
<label class="rc-field-label">Mode(s) de transport *</label>
<p>
<label><input type="checkbox" class="filled-in e-mode-check" value="Terrestre"><span>Terrestre</span></label>
<label style="margin-left:16px;"><input type="checkbox" class="filled-in e-mode-check" value="Aérien"><span>Aérien</span></label>
<label style="margin-left:16px;"><input type="checkbox" class="filled-in e-mode-check" value="Fluvial"><span>Fluvial</span></label>
<label style="margin-left:16px;"><input type="checkbox" class="filled-in e-mode-check" value="Maritime"><span>Maritime</span></label>
<label style="margin-left:16px;"><input type="checkbox" class="filled-in e-mode-check" value="Postal"><span>Postal</span></label>
</p>
<input id="e-mode" type="hidden">
</div>
</div>
<div class="row">
<div class="input-field col s12 m6"><input id="e-depart"><label for="e-depart">Lieu de départ *</label></div>
<div class="input-field col s12 m6"><input id="e-arrivee"><label for="e-arrivee">Lieu d'arrivée *</label></div>
</div>
<div class="row">
<div class="input-field col s12 m6"><input id="e-dateDebut" class="advalo-date" autocomplete="off"><label for="e-dateDebut">Date début *</label></div>
<div class="input-field col s12 m6"><input id="e-dateFin" class="advalo-date" autocomplete="off"><label for="e-dateFin">Date fin *</label></div>
</div>
<div class="row">
<div class="input-field col s12 m2"><input id="e-capital" required><label for="e-capital">Valeur assurée *</label></div>
<div class="input-field col s12 m2"><input id="e-taux" required><label for="e-taux">Taux (%) *</label></div>
<div class="input-field col s12 m2"><input id="e-primeMin" required><label for="e-primeMin">Prime mini *</label></div>
<div class="input-field col s12 m2"><input id="e-coutActe"><label for="e-coutActe">Coût acte</label></div>
<div class="input-field col s12 m2"><input id="e-cotisationHT" required><label for="e-cotisationHT">Cotisation HT *</label></div>
<div class="input-field col s12 m2"><input id="e-cotisationTTC" required><label for="e-cotisationTTC">Cotisation TTC *</label></div>
<input id="e-tarif" type="hidden">
</div>
<span id="e-form-error" class="helper-text error" style="display:block;"></span>
</div>
<div class="modal-footer">
<a href="#!" id="advalo-edit-save" class="waves-effect waves-green btn">Valider</a>
<a href="#!" class="modal-close waves-effect btn-flat">Annuler</a>
</div>
</div>