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:
parent
70dd59b03e
commit
c414de04aa
|
|
@ -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
|
||||||
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 d’acte {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.');
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 & 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue