diff --git a/ecole/public/css/global.css b/ecole/public/css/global.css index af6c9276..eb8fe6fb 100644 --- a/ecole/public/css/global.css +++ b/ecole/public/css/global.css @@ -240,6 +240,63 @@ hr.form { background: #26a69a; } +#advaloNavSelect .active a { + background: #26a69a; +} + +#advaloNavSelect li a { + font-weight: 500; + letter-spacing: 0.2px; +} + +.advalo-panel .input-field > label { + color: #1a237e !important; + transition: transform .18s ease, color .18s ease, font-size .18s ease; +} + +.advalo-panel .input-field > label.active { + transform: translateY(-24px) scale(0.82) !important; + transform-origin: 0 0; +} + +.advalo-loader-wrap { + min-height: 46px; +} + +.advalo-ring-loader { + width: 34px; + height: 34px; + border: 3px solid rgba(0, 0, 139, 0.18); + border-top-color: #00008b; + border-right-color: #26a69a; + border-radius: 50%; + margin: 0 auto; + animation: advalo-spin 0.9s cubic-bezier(0.5, 0.1, 0.5, 0.9) infinite; +} + +.advalo-cumul-hist, +.advalo-cumul-fact { + margin-right: 6px; + margin-bottom: 6px; +} + +.advalo-panel .btn, +.advalo-panel .btn-flat { + color: #fff !important; +} + +@keyframes advalo-spin { + 0% { + transform: rotate(0deg) scale(1); + } + 40% { + transform: rotate(180deg) scale(1.04); + } + 100% { + transform: rotate(360deg) scale(1); + } +} + .border { border-radius: 10px !important; } diff --git a/ecole/public/js/advalo-module.js b/ecole/public/js/advalo-module.js new file mode 100644 index 00000000..210dcecc --- /dev/null +++ b/ecole/public/js/advalo-module.js @@ -0,0 +1,955 @@ +document.addEventListener('DOMContentLoaded', function () { + if (window.location.pathname !== '/advalo') return; + + const token = localStorage.getItem('jwtToken'); + const pageSize = 20; + const state = { + historique: { page: 1, totalPages: 1 }, + reporting: { page: 1, totalPages: 1 }, + cumul: { page: 1, totalPages: 1 }, + loaded: { historique: false, reporting: false, cumul: false }, + confirmation: { demandId: '', summary: '' }, + batchConfirmation: { batchId: '', summary: '' }, + facturationRows: [], + removedFacturationIds: new Set(), + openHistoriqueId: null + }; + const requestControllers = {}; + const loadingState = {}; + const TRANSPORT_MODES = ['Terrestre', 'Aérien', 'Fluvial', 'Maritime', 'Postal']; + + const toast = (message, classes = 'red') => M.toast({ html: message, classes }); + + const authHeaders = () => { + if (!token) throw new Error('Session expirée.'); + return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }; + }; + + const authOnlyHeaders = () => { + if (!token) throw new Error('Session expirée.'); + return { Authorization: `Bearer ${token}` }; + }; + + const isAbortError = (error) => error && (error.name === 'AbortError' || String(error.message || '').toLowerCase().includes('aborted')); + + const api = async (url, options = {}) => { + const res = await fetch(url, { ...options, headers: options.headers || authHeaders() }); + const contentType = res.headers.get('content-type') || ''; + if (contentType.includes('text/csv')) { + if (!res.ok) throw new Error('Erreur export CSV'); + return res.text(); + } + const data = await res.json().catch(() => ({})); + if (!res.ok || data.valid === false) throw new Error(data.message || 'Erreur serveur'); + return data; + }; + + const apiLatest = async (key, url, options = {}) => { + if (requestControllers[key]) requestControllers[key].abort(); + const controller = new AbortController(); + requestControllers[key] = controller; + try { + return await api(url, { ...options, signal: controller.signal }); + } finally { + if (requestControllers[key] === controller) delete requestControllers[key]; + } + }; + + const abortDataRequests = () => { + ['historique', 'reporting', 'cumul', 'facturation'].forEach((k) => { + if (requestControllers[k]) requestControllers[k].abort(); + }); + }; + + const parseAmount = (value) => { + const n = Number(String(value || '').replace(',', '.').replace(/\s/g, '')); + return Number.isFinite(n) ? n : 0; + }; + + const fmt = (n) => Number(n || 0).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + + const sourceLabel = (source) => (source === 'deleguee' ? 'Grille déléguée' : 'Hors grille'); + + const setIndicator = (id, page, totalPages, totalRows) => { + const el = document.getElementById(id); + if (!el) return; + const pages = Math.max(1, Number(totalPages || 1)); + const current = Math.min(Math.max(1, Number(page || 1)), pages); + el.textContent = `Page ${current} / ${pages} - ${Number(totalRows || 0)} lignes`; + }; + + const setLoading = (section, isLoading) => { + const el = document.getElementById(`advalo-loading-${section}`); + if (!el) return; + if (isLoading) { + const marker = Symbol(section); + loadingState[section] = { marker, startedAt: Date.now() }; + el.style.display = 'block'; + return; + } + const current = loadingState[section]; + if (!current) { + el.style.display = 'none'; + return; + } + const elapsed = Date.now() - current.startedAt; + setTimeout(() => { + const latest = loadingState[section]; + if (latest && latest.marker === current.marker) { + el.style.display = 'none'; + delete loadingState[section]; + } + }, Math.max(0, 280 - elapsed)); + }; + + const syncTextFields = (scope = document) => { + if (window.M && typeof window.M.updateTextFields === 'function') window.M.updateTextFields(); + const root = scope && scope.querySelectorAll ? scope : document; + const fields = root.querySelectorAll('.advalo-panel .input-field input:not([type="hidden"]), .advalo-panel .input-field textarea'); + fields.forEach((field) => { + if (!field.id) return; + const label = document.querySelector(`label[for="${field.id}"]`); + if (!label) return; + const shouldFloat = String(field.value || '').trim().length > 0 || document.activeElement === field; + label.classList.toggle('active', shouldFloat); + }); + }; + + const refreshTextFields = (scope = document) => { + syncTextFields(scope); + requestAnimationFrame(() => syncTextFields(scope)); + setTimeout(() => syncTextFields(scope), 60); + }; + + const initSelects = (scope = document) => { + const selects = scope.querySelectorAll('select'); + selects.forEach((select) => { + const instance = M.FormSelect.getInstance(select); + if (instance) instance.destroy(); + M.FormSelect.init(select); + }); + }; + + const formatDateInput = (raw) => { + const digits = String(raw || '').replace(/\D/g, '').slice(0, 8); + if (digits.length <= 2) return digits; + if (digits.length <= 4) return `${digits.slice(0, 2)}/${digits.slice(2)}`; + return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`; + }; + + const initDateFields = () => { + document.querySelectorAll('input.advalo-date').forEach((input) => { + if (input.dataset.dateMaskBound === '1') return; + input.addEventListener('input', () => { + const formatted = formatDateInput(input.value); + if (formatted !== input.value) input.value = formatted; + refreshTextFields(input.closest('.advalo-panel') || document); + }); + input.dataset.dateMaskBound = '1'; + }); + }; + + const downloadDocx = async (url, options = {}, fallbackName = 'document_advalo.docx') => { + const res = await fetch(url, { ...options, headers: { ...authOnlyHeaders(), ...(options.headers || {}) } }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload.message || 'Erreur génération document'); + } + const disposition = res.headers.get('content-disposition') || ''; + const filenameMatch = disposition.match(/filename=\"?([^\";]+)\"?/i); + const filename = filenameMatch ? filenameMatch[1] : fallbackName; + const blob = await res.blob(); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); + }; + + const ensureContract16 = (value, { required = false } = {}) => { + const digits = String(value || '').replace(/\D/g, ''); + if (!digits) return required ? null : ''; + if (digits.length !== 16) return null; + return digits; + }; + + const validateContractField = (id, required = false) => { + const input = document.getElementById(id); + if (!input) return true; + const v = ensureContract16(input.value, { required }); + if (v === null) { + input.classList.add('invalid'); + return false; + } + if (v) input.value = v; + input.classList.remove('invalid'); + return true; + }; + + const requireFields = (ids) => { + let ok = true; + ids.forEach((id) => { + const input = document.getElementById(id); + if (!input) return; + if (!String(input.value || '').trim()) { + input.classList.add('invalid'); + ok = false; + } else { + input.classList.remove('invalid'); + } + }); + return ok; + }; + + const applyNumericGuards = () => { + const integerOnlyIds = ['p-numContrat', 'p-numClient', 'p-numAgent', 'h-numClient', 'h-numContrat', 'c-numContrat', 'c-numClient', 'f-numContrat', 'r-numClient', 'r-numContrat', 'r-actorMatricule']; + integerOnlyIds.forEach((id) => { + const input = document.getElementById(id); + if (!input) return; + input.addEventListener('input', () => { + const cleaned = String(input.value || '').replace(/\D/g, ''); + if (cleaned !== input.value) input.value = cleaned; + }); + }); + + const decimalIds = ['p-capital', 'p-taux', 'p-primeMin', 'p-coutActe', 'p-cotisationHT', 'p-cotisationTTC']; + decimalIds.forEach((id) => { + const input = document.getElementById(id); + if (!input) return; + input.addEventListener('input', () => { + let cleaned = String(input.value || '').replace(/[^0-9.,]/g, ''); + const comma = cleaned.indexOf(','); + const dot = cleaned.indexOf('.'); + const splitAt = comma >= 0 ? comma : dot; + if (splitAt >= 0) cleaned = cleaned.slice(0, splitAt + 1) + cleaned.slice(splitAt + 1).replace(/[.,]/g, ''); + if (cleaned !== input.value) input.value = cleaned; + }); + }); + }; + + const bindFloatingLabels = () => { + const inputs = document.querySelectorAll('#advalo-tab-ponctuel input, #advalo-tab-facturation input, #advalo-tab-historique input, #advalo-tab-cumul input, #advalo-tab-reporting input'); + inputs.forEach((input) => { + if (input.dataset.floatBound === '1') return; + const resync = () => requestAnimationFrame(() => refreshTextFields(input.closest('.advalo-panel') || document)); + input.addEventListener('focus', resync); + input.addEventListener('input', resync); + input.addEventListener('change', resync); + input.addEventListener('blur', resync); + input.dataset.floatBound = '1'; + }); + }; + + const navLinks = document.querySelectorAll('#advaloNavSelect a[data-target]'); + const panels = document.querySelectorAll('.advalo-panel'); + 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 getSelectedModes = () => [...document.querySelectorAll('.p-mode-check:checked')].map((el) => el.value).filter((v) => TRANSPORT_MODES.includes(v)); + const syncModes = () => { + const modes = getSelectedModes(); + document.getElementById('p-mode').value = modes.join(', '); + }; + + const recalcPonctuelPricing = () => { + const capital = parseAmount(document.getElementById('p-capital').value); + const taux = parseAmount(document.getElementById('p-taux').value); + const primeMin = parseAmount(document.getElementById('p-primeMin').value); + const coutActe = parseAmount(document.getElementById('p-coutActe').value); + if (!capital && !taux && !primeMin) return; + const cotisationHT = Math.max((capital * taux) / 100, primeMin); + const cotisationTTC = cotisationHT + coutActe; + document.getElementById('p-cotisationHT').value = cotisationHT.toFixed(2); + document.getElementById('p-cotisationTTC').value = cotisationTTC.toFixed(2); + document.getElementById('p-tarif').value = cotisationTTC.toFixed(2); + refreshTextFields(); + }; + + const fillContractInfo = (info) => { + document.getElementById('p-numContrat').value = info.numContrat || document.getElementById('p-numContrat').value; + document.getElementById('p-numClient').value = info.numClient || ''; + document.getElementById('p-nomClient').value = info.nomClient || ''; + document.getElementById('p-numAgent').value = info.numAgent || ''; + document.getElementById('p-nomAgent').value = info.nomAgent || ''; + refreshTextFields(); + }; + + const lookupContract = async () => { + if (!validateContractField('p-numContrat', true)) { + toast('N° contrat invalide: 16 chiffres requis.'); + return; + } + const contract = document.getElementById('p-numContrat').value.trim(); + const params = new URLSearchParams({ numContrat: contract }); + const data = await api(`/advalo/lookup-contract?${params.toString()}`, { method: 'GET' }); + fillContractInfo(data.info || {}); + if (data.info?.source && data.info.source !== 'none') toast(`Informations chargées (${data.info.source}).`, 'green'); + else toast('Aucune donnée trouvée pour ce contrat. Saisie manuelle possible.', 'orange'); + }; + + const renderHistoriqueDetail = (typedId, row, details) => { + const detailId = `h-detail-${typedId.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + return ` + + +
+
+
Marchandise: ${details.marchandise || '-'}
+
Modes: ${details.mode || '-'}
+
Départ: ${details.depart || '-'}
+
Arrivée: ${details.arrivee || '-'}
+
+
+
Valeur assurée: ${details.valeurAssuree || '-'}
+
Taux: ${details.taux || '-'}
+
Prime mini: ${details.primeMinimum || '-'}
+
Cotisation HT: ${details.cotisationHT || '-'}
+
Coût acte: ${details.coutActe || '-'}
+
Cotisation TTC: ${details.cotisationTTC || '-'}
+
+
+
Acteur: ${(details.actorPrenom || '')} ${(details.actorNom || '')} ${details.actorMatricule ? `(${details.actorMatricule})` : ''}
+
+ ${row.source === 'hors_grille' && String(row.statutFacturation || '').toLowerCase().includes('non') ? `` : ''} +
+
+
+ + `; + }; + + const loadHistorique = async () => { + setLoading('historique', true); + try { + const statut = document.getElementById('h-statut').value || 'all'; + const params = new URLSearchParams({ + numClient: document.getElementById('h-numClient').value || '', + numContrat: document.getElementById('h-numContrat').value || '', + dateDebut: document.getElementById('h-dateDebut').value || '', + dateFin: document.getElementById('h-dateFin').value || '', + sourceType: document.getElementById('h-sourceType').value || 'all', + statutFacturation: statut === 'all' ? '' : statut, + page: String(state.historique.page), + pageSize: String(pageSize), + sort: 'dateDebutIso', + order: 'desc' + }); + const data = await apiLatest('historique', `/advalo/historique?${params.toString()}`, { method: 'GET' }); + const body = document.getElementById('historique-body'); + body.innerHTML = ''; + + (data.rows || []).forEach((row) => { + const typedId = `${row.source === 'deleguee' ? 'g' : 'd'}:${row.id}`; + body.insertAdjacentHTML('beforeend', ` + + + ${sourceLabel(row.source)} + ${row.numDemande || ''} + ${row.numClient || ''} + ${row.numContrat || ''} + ${row.dateDebut || ''} + ${row.dateFin || ''} + ${row.tarif || ''} + ${row.statutFacturation || ''} + + + + + + `); + }); + + document.querySelectorAll('.advalo-h-expand').forEach((btn) => { + btn.addEventListener('click', async () => { + const typedId = btn.dataset.id; + const existing = document.getElementById(`h-detail-${typedId.replace(/[^a-zA-Z0-9_-]/g, '-')}`); + if (existing) { + existing.remove(); + state.openHistoriqueId = null; + btn.textContent = '▸'; + return; + } + + if (state.openHistoriqueId) { + const old = document.getElementById(`h-detail-${state.openHistoriqueId.replace(/[^a-zA-Z0-9_-]/g, '-')}`); + if (old) old.remove(); + const oldBtn = document.querySelector(`.advalo-h-expand[data-id="${state.openHistoriqueId}"]`); + if (oldBtn) oldBtn.textContent = '▸'; + } + + try { + const detailData = await api(`/advalo/historique/${encodeURIComponent(typedId)}`, { method: 'GET' }); + const row = (data.rows || []).find((r) => `${r.source === 'deleguee' ? 'g' : 'd'}:${r.id}` === typedId); + const details = detailData.row?.details || row?.details || {}; + const currentRow = btn.closest('tr'); + currentRow.insertAdjacentHTML('afterend', renderHistoriqueDetail(typedId, row || {}, details)); + state.openHistoriqueId = typedId; + btn.textContent = '▾'; + document.querySelectorAll('.advalo-h-delete').forEach((deleteBtn) => { + deleteBtn.addEventListener('click', async () => { + if (!confirm('Supprimer cette demande hors grille ?')) return; + try { + await api(`/advalo/demande/${encodeURIComponent(deleteBtn.dataset.id)}`, { method: 'DELETE' }); + toast('Demande supprimée.', 'green'); + await loadHistorique(); + } catch (error) { + toast(error.message); + } + }); + }); + } catch (error) { + toast(error.message); + } + }); + }); + + document.querySelectorAll('.advalo-doc-avenant').forEach((button) => { + button.addEventListener('click', async () => { + try { + await downloadDocx(`/advalo/demande/${encodeURIComponent(button.dataset.id)}/avenant`, { method: 'POST' }, 'Avenant_Advalo.docx'); + toast('Avenant généré.', 'green'); + } catch (error) { + toast(error.message); + } + }); + }); + + document.querySelectorAll('.advalo-doc-attestation').forEach((button) => { + button.addEventListener('click', async () => { + try { + await downloadDocx(`/advalo/demande/${encodeURIComponent(button.dataset.id)}/attestation`, { method: 'POST' }, 'Attestation_Advalo.docx'); + toast('Attestation générée.', 'green'); + } catch (error) { + toast(error.message); + } + }); + }); + + state.historique.totalPages = Number(data.meta?.totalPages || 1); + state.historique.page = Number(data.meta?.page || 1); + setIndicator('h-page-indicator', state.historique.page, state.historique.totalPages, data.meta?.totalRows || 0); + refreshTextFields(); + } catch (error) { + if (isAbortError(error)) return; + throw error; + } finally { + setLoading('historique', false); + } + }; + + const updateFacturationInfo = () => { + const selected = [...document.querySelectorAll('.advalo-fact-check')].filter((input) => input.checked).length; + const total = [...document.querySelectorAll('.advalo-fact-check')].length; + document.getElementById('f-selection-info').textContent = `${selected} / ${total} ligne(s) sélectionnée(s)`; + }; + + const renderFacturationRows = () => { + const body = document.getElementById('facturation-body'); + body.innerHTML = ''; + state.facturationRows.forEach((row) => { + const typedId = `${row.source === 'deleguee' ? 'g' : 'd'}:${row.id}`; + body.insertAdjacentHTML('beforeend', ` + + + ${sourceLabel(row.source)} + ${row.numDemande || ''} + ${row.numClient || ''} + ${row.numContrat || ''} + ${row.dateDebut || ''} + ${row.tarif || ''} + ${row.statutFacturation || ''} + + `); + }); + document.querySelectorAll('.advalo-fact-check').forEach((input) => input.addEventListener('change', updateFacturationInfo)); + updateFacturationInfo(); + + const first = state.facturationRows[0]; + document.getElementById('facturation-client-agent-row').style.display = first ? 'block' : 'none'; + document.getElementById('f-client-recap').textContent = first ? `${first.nomClient || '-'} (${first.numClient || '-'})` : '-'; + document.getElementById('f-agent-recap').textContent = first ? (first.souscripteur || '-') : '-'; + }; + + const loadFacturationCandidates = async () => { + if (!validateContractField('f-numContrat', false)) { + toast('N° contrat invalide: 16 chiffres requis.'); + return; + } + setLoading('facturation', true); + try { + const mode = document.getElementById('f-sourceMode').value || 'hors_grille'; + const params = new URLSearchParams({ + numContrat: document.getElementById('f-numContrat').value || '', + dateDebut: document.getElementById('f-dateDebut').value || '', + dateFin: document.getElementById('f-dateFin').value || '', + facture: 'false', + nonFacture: 'true', + deleguee: mode === 'mixte' ? 'true' : 'false', + nonDeleguee: 'true', + sourceType: mode === 'mixte' ? 'all' : 'hors_grille', + page: '1', + pageSize: '200', + sort: 'dateDebutIso', + order: 'desc' + }); + const data = await apiLatest('facturation', `/advalo/historique?${params.toString()}`, { method: 'GET' }); + state.facturationRows = (data.rows || []); + state.removedFacturationIds = new Set(); + renderFacturationRows(); + if ((data.meta?.totalRows || 0) === 0) toast('Aucune ligne non facturée sur la période/contrat.', 'orange'); + else toast(`${data.meta?.totalRows || 0} ligne(s) facturable(s).`, 'green'); + } catch (error) { + if (isAbortError(error)) return; + throw error; + } finally { + setLoading('facturation', false); + } + }; + + const loadCumul = async () => { + setLoading('cumul', true); + try { + if (!validateContractField('c-numContrat', false)) { + toast('N° contrat invalide: 16 chiffres requis.'); + return; + } + const params = new URLSearchParams({ + numContrat: document.getElementById('c-numContrat').value || '', + numClient: document.getElementById('c-numClient').value || '', + dateDebut: document.getElementById('c-dateDebut').value || '', + dateFin: document.getElementById('c-dateFin').value || '', + page: String(state.cumul.page), + pageSize: String(pageSize), + sort: 'totalNonFacture', + order: 'desc' + }); + const data = await apiLatest('cumul', `/advalo/cumul?${params.toString()}`, { method: 'GET' }); + document.getElementById('k-total-advalo').textContent = fmt(data.totalAdvalo); + document.getElementById('k-total-facture').textContent = fmt(data.totalFacture); + document.getElementById('k-total-nonfacture').textContent = fmt(data.totalNonFacture); + document.getElementById('k-total-lignes').textContent = String(data.totalLignes || 0); + + const body = document.getElementById('cumul-body'); + body.innerHTML = ''; + (data.rows || []).forEach((row) => { + body.insertAdjacentHTML('beforeend', ` + + ${row.numContrat || ''} + ${row.nomClient || ''} + ${row.region || ''} + ${row.dpt || ''} + ${row.souscripteur || ''} + ${fmt(row.totalAdvalo)} + ${fmt(row.totalFacture)} + ${fmt(row.totalNonFacture)} + + + + + + `); + }); + + state.cumul.totalPages = Number(data.meta?.totalPages || 1); + state.cumul.page = Number(data.meta?.page || 1); + setIndicator('c-page-indicator', state.cumul.page, state.cumul.totalPages, data.meta?.totalRows || 0); + + document.querySelectorAll('.advalo-cumul-hist').forEach((btn) => { + btn.addEventListener('click', async () => { + document.getElementById('h-numContrat').value = btn.dataset.contrat || ''; + state.historique.page = 1; + state.loaded.historique = true; + await activatePanel('advalo-tab-historique'); + await loadHistorique(); + }); + }); + + document.querySelectorAll('.advalo-cumul-fact').forEach((btn) => { + btn.addEventListener('click', async () => { + document.getElementById('f-numContrat').value = btn.dataset.contrat || ''; + await activatePanel('advalo-tab-facturation'); + refreshTextFields(); + }); + }); + refreshTextFields(); + } catch (error) { + if (isAbortError(error)) return; + throw error; + } finally { + setLoading('cumul', false); + } + }; + + const loadReporting = async () => { + setLoading('reporting', true); + try { + if (!validateContractField('r-numContrat', false)) { + toast('N° contrat invalide: 16 chiffres requis.'); + return; + } + const statut = document.getElementById('r-statut').value || 'all'; + const params = new URLSearchParams({ + numClient: document.getElementById('r-numClient').value || '', + numContrat: document.getElementById('r-numContrat').value || '', + souscripteur: document.getElementById('r-souscripteur').value || '', + region: document.getElementById('r-region').value || '', + dateDebut: document.getElementById('r-dateDebut').value || '', + dateFin: document.getElementById('r-dateFin').value || '', + actorMatricule: document.getElementById('r-actorMatricule').value || '', + actorNom: document.getElementById('r-actorNom').value || '', + actionType: document.getElementById('r-actionType').value || '', + sourceType: document.getElementById('r-sourceType').value || 'all', + statutFacturation: statut === 'all' ? '' : statut, + sort: document.getElementById('r-sort').value || 'totalAdvalo', + order: document.getElementById('r-order').value || 'desc', + page: String(state.reporting.page), + pageSize: String(pageSize) + }); + const data = await apiLatest('reporting', `/advalo/reporting?${params.toString()}`, { method: 'GET' }); + document.getElementById('r-total-advalo').textContent = fmt(data.totaux?.totalAdvalo || 0); + document.getElementById('r-total-facture').textContent = fmt(data.totaux?.totalFacture || 0); + document.getElementById('r-total-nonfacture').textContent = fmt(data.totaux?.totalNonFacture || 0); + document.getElementById('r-total-lignes').textContent = String(data.totaux?.totalLignes || 0); + + const body = document.getElementById('reporting-body'); + body.innerHTML = ''; + (data.rows || []).forEach((row) => { + body.insertAdjacentHTML('beforeend', ` + + ${row.numContrat || ''} + ${row.nomClient || ''} + ${row.region || ''} + ${row.souscripteur || ''} + ${fmt(row.totalAdvalo)} + ${fmt(row.totalFacture)} + ${fmt(row.totalNonFacture)} + ${row.totalLignes || 0} + `); + }); + + const actorBody = document.getElementById('reporting-actors-body'); + actorBody.innerHTML = ''; + (data.actorStats || []).forEach((a) => { + actorBody.insertAdjacentHTML('beforeend', ` + + ${a.actorMatricule || ''} + ${a.actorName || ''} + ${a.actionType || ''} + ${a.actionsCount || 0} + + `); + }); + + state.reporting.totalPages = Number(data.meta?.totalPages || 1); + state.reporting.page = Number(data.meta?.page || 1); + setIndicator('r-page-indicator', state.reporting.page, state.reporting.totalPages, data.meta?.totalRows || 0); + refreshTextFields(); + } catch (error) { + if (isAbortError(error)) return; + throw error; + } finally { + setLoading('reporting', false); + } + }; + + const activatePanel = async (targetId) => { + abortDataRequests(); + panels.forEach((panel) => { panel.style.display = panel.id === targetId ? 'block' : 'none'; }); + navLinks.forEach((link) => { + const li = link.parentElement; + if (!li) return; + if (link.dataset.target === targetId) li.classList.add('active'); + else li.classList.remove('active'); + }); + + initDateFields(); + refreshTextFields(); + + if (targetId === 'advalo-tab-historique' && !state.loaded.historique) { + state.historique.page = 1; + await loadHistorique(); + state.loaded.historique = true; + } + if (targetId === 'advalo-tab-reporting' && !state.loaded.reporting) { + state.reporting.page = 1; + await loadReporting(); + state.loaded.reporting = true; + } + if (targetId === 'advalo-tab-cumul' && !state.loaded.cumul) { + state.cumul.page = 1; + await loadCumul(); + state.loaded.cumul = true; + } + }; + + navLinks.forEach((link) => { + link.addEventListener('click', async (event) => { + event.preventDefault(); + try { await activatePanel(link.dataset.target); } catch (error) { toast(error.message); } + }); + }); + + document.querySelectorAll('.p-mode-check').forEach((input) => input.addEventListener('change', () => { syncModes(); refreshTextFields(); })); + ['p-capital', 'p-taux', 'p-primeMin', 'p-coutActe'].forEach((id) => { + const input = document.getElementById(id); + if (input) input.addEventListener('input', recalcPonctuelPricing); + }); + + document.getElementById('p-numContrat').addEventListener('blur', async () => { + const value = String(document.getElementById('p-numContrat').value || '').trim(); + if (value.length === 16) { + try { await lookupContract(); } catch (error) { toast(error.message); } + } + }); + document.getElementById('p-numContrat').addEventListener('keydown', async (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + try { await lookupContract(); } catch (error) { toast(error.message); } + }); + document.getElementById('btn-load-contract').addEventListener('click', async (event) => { + event.preventDefault(); + try { await lookupContract(); } catch (error) { toast(error.message); } + }); + + document.getElementById('advalo-ponctuel-form').addEventListener('submit', async (event) => { + event.preventDefault(); + const errorSlot = document.getElementById('p-form-error'); + if (errorSlot) errorSlot.textContent = ''; + + syncModes(); + const required = ['p-numContrat', 'p-marchandise', 'p-depart', 'p-arrivee', 'p-dateDebut', 'p-dateFin', 'p-capital', 'p-taux', 'p-primeMin', 'p-cotisationHT', 'p-cotisationTTC']; + if (!validateContractField('p-numContrat', true) || !requireFields(required)) { + if (errorSlot) errorSlot.textContent = 'Complète les champs obligatoires (contrat 16 chiffres).'; + toast('Complète les champs obligatoires (contrat 16 chiffres).'); + return; + } + if (!document.getElementById('p-mode').value.trim()) { + if (errorSlot) errorSlot.textContent = 'Sélectionne au moins un mode de transport.'; + toast('Sélectionne au moins un mode de transport.'); + return; + } + + try { + const payload = { + typeFacturation: document.querySelector('input[name="p-typeFacturation"]:checked')?.value || 'ponctuel', + numClient: document.getElementById('p-numClient').value.trim(), + nomClient: document.getElementById('p-nomClient').value.trim(), + numContrat: document.getElementById('p-numContrat').value.trim(), + marchandise: document.getElementById('p-marchandise').value.trim(), + mode: document.getElementById('p-mode').value.trim(), + capital: document.getElementById('p-capital').value.trim(), + depart: document.getElementById('p-depart').value.trim(), + arrivee: document.getElementById('p-arrivee').value.trim(), + dateDebut: document.getElementById('p-dateDebut').value.trim(), + dateFin: document.getElementById('p-dateFin').value.trim(), + taux: document.getElementById('p-taux').value.trim(), + primeMinimum: document.getElementById('p-primeMin').value.trim(), + coutActe: document.getElementById('p-coutActe').value.trim(), + cotisationHT: document.getElementById('p-cotisationHT').value.trim(), + cotisationTTC: document.getElementById('p-cotisationTTC').value.trim(), + tarif: document.getElementById('p-cotisationTTC').value.trim(), + facturer: document.getElementById('p-facturer').checked + }; + const data = await api('/advalo/ponctuel', { method: 'POST', body: JSON.stringify(payload) }); + toast(`Demande créée: ${data.row.numDemande || data.row.id}`, 'green'); + state.confirmation.demandId = `d:${data.row.id}`; + state.confirmation.summary = `Demande ${data.row.numDemande || data.row.id} - Contrat ${data.row.numContrat || ''}`; + const summary = document.getElementById('advalo-confirm-summary'); + if (summary) summary.textContent = state.confirmation.summary; + confirmModal.open(); + event.target.reset(); + document.querySelectorAll('.p-mode-check').forEach((i) => { i.checked = false; }); + document.getElementById('p-coutActe').value = '36'; + document.getElementById('p-taux').value = '0.3'; + document.getElementById('p-primeMin').value = '15'; + document.querySelector('input[name="p-typeFacturation"][value="ponctuel"]').checked = true; + recalcPonctuelPricing(); + refreshTextFields(); + state.loaded.historique = false; + state.loaded.reporting = false; + state.loaded.cumul = false; + } catch (error) { + if (errorSlot) errorSlot.textContent = error.message; + toast(error.message); + } + }); + + document.getElementById('btn-f-load').addEventListener('click', async (event) => { + event.preventDefault(); + try { await loadFacturationCandidates(); } catch (error) { toast(error.message); } + }); + + document.getElementById('btn-f-remove').addEventListener('click', (event) => { + event.preventDefault(); + const selected = [...document.querySelectorAll('.advalo-fact-check:checked')].map((i) => i.value); + if (!selected.length) { + toast('Sélectionne au moins une ligne à retirer.'); + return; + } + selected.forEach((id) => state.removedFacturationIds.add(id)); + state.facturationRows = state.facturationRows.filter((row) => { + const typedId = `${row.source === 'deleguee' ? 'g' : 'd'}:${row.id}`; + return !state.removedFacturationIds.has(typedId); + }); + renderFacturationRows(); + toast('Lignes retirées de la liste.', 'green'); + }); + + document.getElementById('btn-facturer').addEventListener('click', async (event) => { + event.preventDefault(); + const ids = [...document.querySelectorAll('.advalo-fact-check:checked')].map((i) => i.value).filter(Boolean); + if (!ids.length) { + toast('Sélectionne au moins une ligne.'); + return; + } + try { + const sourceMode = document.getElementById('f-sourceMode').value || 'hors_grille'; + const includeTransportDetails = document.querySelector('input[name="f-include-details"]:checked')?.value !== 'false'; + const data = await api('/advalo/facturation/batch', { + method: 'POST', + body: JSON.stringify({ + demandeIds: ids, + sourceMode, + includeTransportDetails, + removedDemandeIds: [...state.removedFacturationIds] + }) + }); + if (data.idempotent) toast(`Lot déjà traité: ${data.batch.id}`, 'blue'); + else toast(`Facturation OK: lot ${data.batch.id}`, 'green'); + state.batchConfirmation.batchId = data.batch.id; + state.batchConfirmation.summary = `Lot ${data.batch.id} - Contrat ${data.batch.numContrat || ''} - ${data.batch.dateDebut || ''} / ${data.batch.dateFin || ''}`; + const batchSummary = document.getElementById('advalo-batch-summary'); + if (batchSummary) batchSummary.textContent = state.batchConfirmation.summary; + batchModal.open(); + await loadFacturationCandidates(); + state.loaded.reporting = false; + state.loaded.cumul = false; + state.loaded.historique = false; + } catch (error) { + toast(error.message); + } + }); + + document.getElementById('btn-h-search').addEventListener('click', async () => { + if (!validateContractField('h-numContrat', false)) { + toast('N° contrat invalide: 16 chiffres requis.'); + return; + } + state.historique.page = 1; + try { await loadHistorique(); toast('Historique chargé.', 'green'); } catch (error) { toast(error.message); } + }); + + document.getElementById('btn-h-export').addEventListener('click', async () => { + if (!validateContractField('h-numContrat', false)) { + toast('N° contrat invalide: 16 chiffres requis.'); + return; + } + try { + const statut = document.getElementById('h-statut').value || 'all'; + const params = new URLSearchParams({ + numClient: document.getElementById('h-numClient').value || '', + numContrat: document.getElementById('h-numContrat').value || '', + dateDebut: document.getElementById('h-dateDebut').value || '', + dateFin: document.getElementById('h-dateFin').value || '', + sourceType: document.getElementById('h-sourceType').value || 'all', + statutFacturation: statut === 'all' ? '' : statut, + sort: 'dateDebutIso', + order: 'desc' + }); + const csv = await api(`/advalo/export?${params.toString()}`, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'advalo_export.csv'; + link.click(); + URL.revokeObjectURL(url); + toast('Export CSV généré.', 'green'); + } catch (error) { + toast(error.message); + } + }); + + document.getElementById('btn-h-prev').addEventListener('click', async () => { + if (state.historique.page <= 1) return; + state.historique.page -= 1; + try { await loadHistorique(); } catch (error) { toast(error.message); } + }); + document.getElementById('btn-h-next').addEventListener('click', async () => { + if (state.historique.page >= state.historique.totalPages) return; + state.historique.page += 1; + try { await loadHistorique(); } catch (error) { toast(error.message); } + }); + + document.getElementById('btn-cumul').addEventListener('click', async (event) => { + event.preventDefault(); + state.cumul.page = 1; + try { await loadCumul(); toast('Cumul calculé.', 'green'); } catch (error) { toast(error.message); } + }); + document.getElementById('btn-c-prev').addEventListener('click', async () => { + if (state.cumul.page <= 1) return; + state.cumul.page -= 1; + try { await loadCumul(); } catch (error) { toast(error.message); } + }); + document.getElementById('btn-c-next').addEventListener('click', async () => { + if (state.cumul.page >= state.cumul.totalPages) return; + state.cumul.page += 1; + try { await loadCumul(); } catch (error) { toast(error.message); } + }); + + document.getElementById('btn-reporting').addEventListener('click', async (event) => { + event.preventDefault(); + state.reporting.page = 1; + try { await loadReporting(); toast('Reporting chargé.', 'green'); } catch (error) { toast(error.message); } + }); + document.getElementById('btn-r-prev').addEventListener('click', async () => { + if (state.reporting.page <= 1) return; + state.reporting.page -= 1; + try { await loadReporting(); } catch (error) { toast(error.message); } + }); + document.getElementById('btn-r-next').addEventListener('click', async () => { + if (state.reporting.page >= state.reporting.totalPages) return; + state.reporting.page += 1; + try { await loadReporting(); } catch (error) { toast(error.message); } + }); + + document.getElementById('advalo-confirm-avenant').addEventListener('click', async () => { + if (!state.confirmation.demandId) return; + try { + await downloadDocx(`/advalo/demande/${encodeURIComponent(state.confirmation.demandId)}/avenant`, { method: 'POST' }, 'Avenant_Advalo.docx'); + toast('Avenant généré.', 'green'); + } catch (error) { + toast(error.message); + } + }); + + document.getElementById('advalo-confirm-attestation').addEventListener('click', async () => { + if (!state.confirmation.demandId) return; + try { + await downloadDocx(`/advalo/demande/${encodeURIComponent(state.confirmation.demandId)}/attestation`, { method: 'POST' }, 'Attestation_Advalo.docx'); + toast('Attestation générée.', 'green'); + } catch (error) { + toast(error.message); + } + }); + + document.getElementById('advalo-batch-avenant').addEventListener('click', async () => { + if (!state.batchConfirmation.batchId) return; + try { + await downloadDocx(`/advalo/facturation/batch/${encodeURIComponent(state.batchConfirmation.batchId)}/avenant`, { method: 'POST' }, 'Avenant_Periodique_Advalo.docx'); + toast('Avenant périodique généré.', 'green'); + } catch (error) { + toast(error.message); + } + }); + + applyNumericGuards(); + bindFloatingLabels(); + initDateFields(); + initSelects(document); + syncModes(); + recalcPonctuelPricing(); + refreshTextFields(); + + activatePanel('advalo-tab-accueil').catch((error) => toast(error.message)); +}); diff --git a/ecole/src/controllers/advaloController.js b/ecole/src/controllers/advaloController.js new file mode 100644 index 00000000..805a80ab --- /dev/null +++ b/ecole/src/controllers/advaloController.js @@ -0,0 +1,174 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const renderPage = require('../utils/renderHelper'); +const advaloService = require('../services/advaloService'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +function getActor(req) { + const authHeader = req.headers.authorization || ''; + const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null; + if (!token) { + const err = new Error('Session invalide, reconnectez-vous.'); + err.status = 401; + throw err; + } + return jwt.verify(token, 'no-mdp'); +} + +function handleError(res, error) { + logger.log('error', `Advalo error: ${error.message}`, { + status: error.status || 500, + stack: error.stack, + data: error.data, + originalError: error.originalError ? String(error.originalError) : undefined + }); + return res.status(error.status || 500).json({ + valid: false, + message: error.message || 'Erreur serveur Advalorem' + }); +} + +router.get('/', (_req, res) => { + renderPage('advalo.ejs', res); +}); + +router.get('/lookup-contract', async (req, res) => { + try { + const actor = getActor(req); + const info = await advaloService.lookupContract(req.query.numContrat, actor); + return res.json({ valid: true, info }); + } catch (error) { + return handleError(res, error); + } +}); + +router.get('/historique', async (req, res) => { + try { + const actor = getActor(req); + const data = await advaloService.getHistorique(req.query, actor); + return res.json({ + valid: true, + rows: data.rows, + meta: { + totalRows: data.totalRows, + totalPages: data.totalPages, + page: data.page, + pageSize: data.pageSize + } + }); + } catch (error) { + return handleError(res, error); + } +}); + +router.get('/historique/:id', async (req, res) => { + try { + const actor = getActor(req); + const data = await advaloService.getHistoriqueDetail(req.params.id, actor); + return res.json({ valid: true, row: data }); + } catch (error) { + return handleError(res, error); + } +}); + +router.get('/cumul', async (req, res) => { + try { + const actor = getActor(req); + const cumul = await advaloService.getCumul(req.query, actor); + return res.json({ valid: true, ...cumul }); + } catch (error) { + return handleError(res, error); + } +}); + +router.post('/ponctuel', async (req, res) => { + try { + const actor = getActor(req); + const row = await advaloService.createPonctuel(req.body, actor); + return res.status(201).json({ valid: true, row }); + } catch (error) { + return handleError(res, error); + } +}); + +router.delete('/demande/:id', async (req, res) => { + try { + const actor = getActor(req); + const row = await advaloService.softDeleteDemande(req.params.id, actor); + return res.json({ valid: true, row }); + } catch (error) { + return handleError(res, error); + } +}); + +router.post('/facturation/batch', async (req, res) => { + try { + const actor = getActor(req); + const result = await advaloService.facturerBatch(req.body, actor); + return res.json({ valid: true, ...result }); + } catch (error) { + return handleError(res, error); + } +}); + +router.post('/demande/:id/avenant', async (req, res) => { + try { + const actor = getActor(req); + const doc = await advaloService.generateDemandeDocument(req.params.id, 'avenant', actor); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + res.setHeader('Content-Disposition', `attachment; filename="${doc.filename}"`); + return res.send(doc.buffer); + } catch (error) { + return handleError(res, error); + } +}); + +router.post('/demande/:id/attestation', async (req, res) => { + try { + const actor = getActor(req); + const doc = await advaloService.generateDemandeDocument(req.params.id, 'attestation', actor); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + res.setHeader('Content-Disposition', `attachment; filename="${doc.filename}"`); + return res.send(doc.buffer); + } catch (error) { + return handleError(res, error); + } +}); + +router.post('/facturation/batch/:id/avenant', async (req, res) => { + try { + const actor = getActor(req); + const doc = await advaloService.generateBatchAvenant(req.params.id, actor); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + res.setHeader('Content-Disposition', `attachment; filename="${doc.filename}"`); + return res.send(doc.buffer); + } catch (error) { + return handleError(res, error); + } +}); + +router.get('/reporting', async (req, res) => { + try { + const actor = getActor(req); + const data = await advaloService.getReporting(req.query, actor); + return res.json({ valid: true, ...data }); + } catch (error) { + return handleError(res, error); + } +}); + +router.get('/export', async (req, res) => { + try { + const actor = getActor(req); + const csv = await advaloService.exportHistorique(req.query, actor); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); + res.setHeader('Content-Disposition', 'attachment; filename="advalo_export.csv"'); + return res.send(csv); + } catch (error) { + return handleError(res, error); + } +}); + +module.exports = router; diff --git a/ecole/src/routes.js b/ecole/src/routes.js index 3b64bb33..f2a08288 100644 --- a/ecole/src/routes.js +++ b/ecole/src/routes.js @@ -21,6 +21,7 @@ const historiqueParcoursController = require('./controllers/historiqueParcoursCo const userController = require('./controllers/userController'); const regionController = require('./controllers/regionController'); const downloadController = require('./controllers/utilsController'); +const advaloController = require('./controllers/advaloController'); // Association des contrôleurs aux routes router.use('/', rootController); @@ -38,5 +39,6 @@ router.use('/historiqueParcours', historiqueParcoursController); router.use('/user', userController); router.use('/region', regionController); router.use('/download', downloadController); +router.use('/advalo', advaloController); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/ecole/src/services/advaloService.js b/ecole/src/services/advaloService.js new file mode 100644 index 00000000..6b6b0a9b --- /dev/null +++ b/ecole/src/services/advaloService.js @@ -0,0 +1,1754 @@ +const crypto = require('crypto'); +const { spawn, execFile } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { promisify } = require('util'); +const PizZip = require('pizzip'); +require('dotenv').config(); +const { db } = require('../db/db-connect'); +const logger = require('../utils/logger'); + +const execFileAsync = promisify(execFile); + +const JWT_ROLES = ['SOUSCRIPTEUR', 'MANAGER', 'ADMIN']; +const TRANSPORT_MODES = ['Terrestre', 'Aérien', 'Fluvial', 'Maritime', 'Postal']; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 100; +const PB_SQLITE_PATH = path.resolve(__dirname, '..', 'db', 'pb_data', 'data.db'); +const ADVALO_TEMPLATE_DIR = path.resolve(__dirname, '..', 'templates', 'advalo'); +const ADVALO_DOCUMENTS_DIR = path.resolve(process.cwd(), 'documents', 'advalorem'); +const AXA_SCRIPTS_ROOT = path.resolve(process.cwd(), 'vbs'); +const AXA_TIMEOUT_MS = Number(process.env.AXA_TIMEOUT_MS || 65000); +const AXA_RETRY_COUNT = Number(process.env.AXA_RETRY_COUNT || 1); +const AXA_RETRY_DELAY_MS = Number(process.env.AXA_RETRY_DELAY_MS || 1200); + +if (!db.baseUrl || db.baseUrl === '/') { + db.baseUrl = process.env.DB_URL || db.baseUrl; +} + +function ensureRole(actor) { + if (!actor || !JWT_ROLES.includes(actor.userAuthGroupe)) { + const err = new Error('Droits insuffisants pour accéder au module Advalorem.'); + err.status = 403; + throw err; + } +} + +function ensureReportingRole(actor) { + ensureRole(actor); + if (!['MANAGER', 'ADMIN'].includes(actor.userAuthGroupe)) { + const err = new Error('Le reporting Advalorem est réservé aux managers/admins.'); + err.status = 403; + throw err; + } +} + +function padContract(value) { + const digits = String(value || '').replace(/\D/g, ''); + if (!digits) return ''; + return digits.padStart(16, '0').slice(-16); +} + +function normalizeContractStrict(value, { required = false } = {}) { + const digits = String(value || '').replace(/\D/g, ''); + if (!digits) { + if (required) { + const err = new Error('Numéro de contrat obligatoire.'); + err.status = 400; + throw err; + } + return ''; + } + if (digits.length !== 16) { + const err = new Error('Le numéro de contrat doit contenir exactement 16 chiffres.'); + err.status = 400; + throw err; + } + return digits; +} + +function parseFrDate(value) { + const v = String(value || '').trim(); + const match = v.match(/^(\d{2})\/(\d{2})\/(\d{4})$/); + if (!match) return null; + const date = new Date(`${match[3]}-${match[2]}-${match[1]}T00:00:00.000Z`); + return Number.isNaN(date.getTime()) ? null : date; +} + +function formatFrDate(date) { + const d = new Date(date); + const day = String(d.getUTCDate()).padStart(2, '0'); + const month = String(d.getUTCMonth() + 1).padStart(2, '0'); + const year = d.getUTCFullYear(); + return `${day}/${month}/${year}`; +} + +function parseAmount(value) { + const normalized = String(value || '') + .replace(/€/g, '') + .replace(/\u202F/g, '') + .replace(/\u00A0/g, '') + .replace(/\s/g, '') + .replace(',', '.'); + const out = Number(normalized); + return Number.isFinite(out) ? out : 0; +} + +function prettyNumber(value) { + const amount = parseAmount(value); + return amount.toLocaleString('fr-FR', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); +} + +function collapseSpaces(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function escapeRegex(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function safeDocValue(value) { + return String(value ?? '') + .trim() + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function ensureDirectory(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function sanitizeFilePart(value) { + return String(value || '') + .replace(/[^a-zA-Z0-9-_]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); +} + +function isAllZeroDigits(value) { + const digits = String(value || '').replace(/\D/g, ''); + return Boolean(digits) && /^0+$/.test(digits); +} + +function buildActorDisplayName(actor = {}) { + const first = String(actor.userFirstName || '').trim(); + const last = String(actor.userLastName || '').trim(); + const full = `${first} ${last}`.trim(); + if (full) return full; + return String(actor.userMatricule || '').trim(); +} + +function withMatriculeSuffix(name, actor = {}) { + const matricule = String(actor.userMatricule || '').trim(); + const safeName = String(name || '').trim(); + if (!matricule) return safeName; + if (!safeName) return matricule; + return `${safeName} (${matricule})`; +} + +function replaceTokens(text, replacements) { + let output = text; + Object.entries(replacements).forEach(([token, value]) => { + const val = safeDocValue(value); + output = output.replace(new RegExp(escapeRegex(token), 'g'), val); + }); + return output; +} + +function buildDocxFromTemplate(templatePath, replacements) { + const content = fs.readFileSync(templatePath); + const zip = new PizZip(content); + const documentXml = zip.file('word/document.xml').asText(); + const replacedXml = replaceTokens(documentXml, replacements); + zip.file('word/document.xml', replacedXml); + return zip.generate({ type: 'nodebuffer' }); +} + +async function saveAdvaloDocumentTrace({ type, filePath, buffer, demandeId = '', batchId = '' }) { + const sha256 = crypto.createHash('sha256').update(buffer).digest('hex'); + await db.records.create('advalo_document', { + type, + path: filePath, + sha256, + demandeId, + batchId, + createdAt: new Date().toISOString() + }); +} + +function toCsv(rows) { + const headers = [ + 'source', + 'numDemande', + 'numClient', + 'nomClient', + 'numContrat', + 'dateDebut', + 'dateFin', + 'tarif', + 'statutFacturation' + ]; + const escape = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`; + const lines = rows.map((row) => headers.map((h) => escape(row[h])).join(';')); + return `${headers.join(';')}\n${lines.join('\n')}`; +} + +function hashFingerprint(payload) { + return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); +} + +function escapeFilterValue(value) { + return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function normalizeStatus(status) { + const v = String(status || '').toLowerCase(); + return v.includes('facturé') && !v.includes('non') ? 'facture' : 'non_facture'; +} + +async function logAudit(eventType, actor = {}, data = {}) { + const payload = { + actorMatricule: String(actor.userMatricule || ''), + actorNom: String(actor.userLastName || ''), + actorPrenom: String(actor.userFirstName || ''), + actorGroupe: String(actor.userAuthGroupe || ''), + timestamp: new Date().toISOString(), + ...data + }; + try { + await db.records.create('advalo_audit', { + eventType, + createdAt: new Date().toISOString(), + data: payload + }); + } catch (error) { + logger.log('warn', 'advalo_audit write skipped', { eventType, error: error.message }); + } +} + +function normalizeTransportModes(value) { + const toTokens = (v) => { + if (Array.isArray(v)) return v; + return String(v || '') + .split(',') + .map((x) => x.trim()) + .filter(Boolean); + }; + const unique = [...new Set(toTokens(value).map((token) => { + const normalized = token.toLowerCase(); + return TRANSPORT_MODES.find((mode) => mode.toLowerCase() === normalized) || null; + }).filter(Boolean))]; + return unique; +} + +function toSourceLabel(source) { + return source === 'deleguee' ? 'Grille déléguée' : 'Hors grille'; +} + +function parsePagination(filters = {}) { + const page = Math.max(1, Number.parseInt(filters.page, 10) || 1); + const requestedPageSize = Number.parseInt(filters.pageSize, 10) || DEFAULT_PAGE_SIZE; + const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedPageSize)); + return { page, pageSize }; +} + +function parseDateRange(filters = {}) { + const rawFrom = String(filters.dateDebut || '').trim(); + const rawTo = String(filters.dateFin || '').trim(); + + if (!rawFrom && !rawTo) { + return { fromIso: null, toIso: null }; + } + + const from = rawFrom ? parseFrDate(rawFrom) : null; + const to = rawTo ? parseFrDate(rawTo) : null; + + if ((rawFrom && !from) || (rawTo && !to)) { + const err = new Error('Format de date invalide (attendu: JJ/MM/AAAA).'); + err.status = 400; + throw err; + } + + if (from && to && from > to) { + const err = new Error('La date de début doit être antérieure à la date de fin.'); + err.status = 400; + throw err; + } + + return { + fromIso: from ? from.toISOString().slice(0, 10) : null, + toIso: to ? to.toISOString().slice(0, 10) : null + }; +} + +function resolveSourceType(filters = {}) { + const value = String(filters.sourceType || '').toLowerCase(); + if (['deleguee', 'grille_deleguee', 'grille'].includes(value)) return 'deleguee'; + if (['hors_grille', 'demande', 'ponctuel'].includes(value)) return 'hors_grille'; + return 'all'; +} + +function sqlQuote(value) { + return `'${String(value || '').replace(/'/g, "''")}'`; +} + +function parseSqlJson(stdout) { + const raw = String(stdout || '').trim(); + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch (_error) { + return []; + } +} + +async function runSqlJson(sql) { + const { stdout } = await execFileAsync('sqlite3', ['-json', PB_SQLITE_PATH, sql], { + maxBuffer: 1024 * 1024 * 30 + }); + return parseSqlJson(stdout); +} + +function sqlTarifNumberExpr(columnName = 'tarif') { + return `CAST(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(COALESCE(${columnName}, ''), '€', ''), char(160), ''), char(8239), ''), ' ', ''), ',', '.') AS REAL)`; +} + +const MERGED_CTE_SQL = ` +WITH merged AS ( + SELECT + id, + 'deleguee' AS source, + numDemande, + numClient, + nomClient, + numContrat, + dateDebut, + dateFin, + dateDebutIso, + dateFinIso, + tarif, + statutFacturation, + marchandise, + mode, + capital, + depart, + arrivee, + '' AS region, + '' AS dpt, + '' AS souscripteur, + '' AS createdBy, + 0 AS isDeleted + FROM advalo_deleguee + + UNION ALL + + SELECT + id, + 'hors_grille' AS source, + numDemande, + numClient, + nomClient, + numContrat, + dateDebut, + dateFin, + dateDebutIso, + dateFinIso, + tarif, + statutFacturation, + marchandise, + mode, + capital, + depart, + arrivee, + COALESCE(region, '') AS region, + '' AS dpt, + '' AS souscripteur, + COALESCE(createdBy, '') AS createdBy, + COALESCE(isDeleted, 0) AS isDeleted + FROM advalo_demande +), +normalized AS ( + SELECT + id, + source, + CASE WHEN source = 'deleguee' THEN 'Grille déléguée' ELSE 'Hors grille' END AS sourceLabel, + COALESCE(numDemande, '') AS numDemande, + COALESCE(numClient, '') AS numClient, + COALESCE(nomClient, '') AS nomClient, + COALESCE(numContrat, '') AS numContrat, + COALESCE(dateDebut, '') AS dateDebut, + COALESCE(dateFin, '') AS dateFin, + COALESCE(dateDebutIso, '') AS dateDebutIso, + COALESCE(dateFinIso, '') AS dateFinIso, + COALESCE(tarif, '') AS tarif, + COALESCE(statutFacturation, '') AS statutFacturation, + COALESCE(marchandise, '') AS marchandise, + COALESCE(mode, '') AS mode, + COALESCE(capital, '') AS capital, + COALESCE(depart, '') AS depart, + COALESCE(arrivee, '') AS arrivee, + CASE + WHEN lower(COALESCE(statutFacturation, '')) LIKE '%factur%' AND lower(COALESCE(statutFacturation, '')) NOT LIKE '%non%' + THEN 'facture' + ELSE 'non_facture' + END AS statutNormalized, + COALESCE(region, '') AS region, + COALESCE(dpt, '') AS dpt, + COALESCE(souscripteur, '') AS souscripteur, + COALESCE(createdBy, '') AS createdBy, + ${sqlTarifNumberExpr('tarif')} AS tarifNum, + COALESCE( + date(substr(COALESCE(dateDebutIso, ''), 1, 10)), + date(substr(COALESCE(dateDebut, ''), 7, 4) || '-' || substr(COALESCE(dateDebut, ''), 4, 2) || '-' || substr(COALESCE(dateDebut, ''), 1, 2)) + ) AS dateDebutSql, + COALESCE( + date(substr(COALESCE(dateFinIso, ''), 1, 10)), + date(substr(COALESCE(dateFin, ''), 7, 4) || '-' || substr(COALESCE(dateFin, ''), 4, 2) || '-' || substr(COALESCE(dateFin, ''), 1, 2)) + ) AS dateFinSql + FROM merged + WHERE source = 'deleguee' OR isDeleted = 0 +) +`; + +function buildCommonWhere(filters = {}, actor) { + const where = []; + const dateRange = parseDateRange(filters); + const contract = normalizeContractStrict(filters.numContrat, { required: false }); + const numClient = String(filters.numClient || '').trim(); + const region = String(filters.region || '').trim(); + const souscripteur = String(filters.souscripteur || '').trim(); + const actorMatricule = String(filters.actorMatricule || '').trim(); + const actorNom = String(filters.actorNom || '').trim(); + const actionType = String(filters.actionType || '').trim().toLowerCase(); + const sourceType = resolveSourceType(filters); + + if (contract) where.push(`numContrat = ${sqlQuote(contract)}`); + if (numClient) where.push(`numClient LIKE ${sqlQuote(`%${numClient}%`)}`); + if (region) where.push(`region LIKE ${sqlQuote(`%${region}%`)}`); + if (souscripteur) where.push(`souscripteur LIKE ${sqlQuote(`%${souscripteur}%`)}`); + + if (dateRange.fromIso) where.push(`dateDebutSql >= date(${sqlQuote(dateRange.fromIso)})`); + if (dateRange.toIso) where.push(`dateDebutSql <= date(${sqlQuote(dateRange.toIso)})`); + + if (sourceType === 'deleguee') where.push(`source = 'deleguee'`); + if (sourceType === 'hors_grille') where.push(`source = 'hors_grille'`); + + const statusFilter = String(filters.statutFacturation || '').toLowerCase(); + if (statusFilter === 'facture') where.push(`statutNormalized = 'facture'`); + if (statusFilter === 'non_facture') where.push(`statutNormalized = 'non_facture'`); + + const facture = String(filters.facture || '').toLowerCase(); + const nonFacture = String(filters.nonFacture || '').toLowerCase(); + const deleguee = String(filters.deleguee || '').toLowerCase(); + const nonDeleguee = String(filters.nonDeleguee || '').toLowerCase(); + + if (facture === 'false') where.push(`statutNormalized != 'facture'`); + if (nonFacture === 'false') where.push(`statutNormalized != 'non_facture'`); + if (deleguee === 'false') where.push(`source != 'deleguee'`); + if (nonDeleguee === 'false') where.push(`source != 'hors_grille'`); + + if (actor?.userAuthGroupe === 'SOUSCRIPTEUR') { + where.push(`(source = 'deleguee' OR createdBy = ${sqlQuote(actor.userMatricule || '')})`); + } + + if (actorMatricule || actorNom || actionType) { + const auditClauses = []; + if (actorMatricule) { + auditClauses.push(`json_extract(a.data, '$.actorMatricule') LIKE ${sqlQuote(`%${actorMatricule}%`)}`); + } + if (actorNom) { + auditClauses.push(`( + json_extract(a.data, '$.actorNom') LIKE ${sqlQuote(`%${actorNom}%`)} + OR json_extract(a.data, '$.actorPrenom') LIKE ${sqlQuote(`%${actorNom}%`)} + )`); + } + if (actionType) { + auditClauses.push(`a.eventType = ${sqlQuote(actionType)}`); + } + where.push(`EXISTS ( + SELECT 1 + FROM advalo_audit a + WHERE json_extract(a.data, '$.numContrat') = normalized.numContrat + ${auditClauses.length ? `AND ${auditClauses.join(' AND ')}` : ''} + )`); + } + + return where; +} + +function normalizeSqlRow(row) { + return { + ...row, + sourceType: row.source, + sourceLabel: row.sourceLabel || toSourceLabel(row.source), + statutNormalized: row.statutNormalized || normalizeStatus(row.statutFacturation), + numContrat: padContract(row.numContrat), + tarif: String(row.tarif ?? ''), + tarifNum: Number(row.tarifNum || 0) + }; +} + +function normalizeSort(sort, fallback, allowList) { + const value = String(sort || fallback); + return allowList.includes(value) ? value : fallback; +} + +function normalizeOrder(order) { + return String(order || 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'; +} + +function buildPaginationMeta(totalRows, page, pageSize) { + const totalPages = Math.max(1, Math.ceil(totalRows / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + return { + totalRows, + totalPages, + page: safePage, + pageSize, + offset: (safePage - 1) * pageSize + }; +} + +async function runQt550Axa(payload) { + if (process.platform !== 'win32') { + const err = new Error('Pont AXA indisponible sur cet environnement (Windows requis).'); + err.status = 502; + throw err; + } + + const matricule = sanitizeFilePart(payload?.matricule || 'SYSTEM'); + const configPath = path.join(AXA_SCRIPTS_ROOT, 'script_qt550', 'config', `bordereau_${matricule}.txt`); + ensureDirectory(path.dirname(configPath)); + const content = Object.entries({ + numContrat: payload?.numContrat || '', + totalCapitaux: payload?.totalCapitaux || payload?.capital || '', + totalCotisation: payload?.totalCotisation || payload?.tarif || '', + dateDebut: payload?.dateDebut || '', + dateFin: payload?.dateFin || '' + }) + .map(([key, value]) => `${key}:${value}`) + .join('##'); + fs.writeFileSync(configPath, content, 'utf8'); + + await runWScriptScript('script_qt550/bordereau.vbs', { payload, errorLabel: 'QT550' }); +} + +function readAxaKeyValueFile(filePath) { + if (!fs.existsSync(filePath)) return {}; + const raw = fs.readFileSync(filePath, 'utf8'); + return raw.split(/\r?\n/).reduce((acc, line) => { + if (!line || !line.includes(':')) return acc; + const [key, ...rest] = line.split(':'); + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) return acc; + acc[normalizedKey] = rest.join(':').trim(); + return acc; + }, {}); +} + +async function runWScriptScript(relativeScriptPath, { payload = {}, errorLabel = 'AXA' } = {}) { + const scriptPath = path.join(AXA_SCRIPTS_ROOT, relativeScriptPath); + if (!fs.existsSync(scriptPath)) { + const err = new Error(`Script AXA manquant: ${relativeScriptPath}`); + err.status = 500; + throw err; + } + + let lastError = null; + for (let attempt = 0; attempt <= AXA_RETRY_COUNT; attempt += 1) { + try { + await new Promise((resolve, reject) => { + const child = spawn('wscript.exe', [scriptPath], { cwd: process.cwd(), stdio: 'ignore' }); + const timeout = setTimeout(() => { + child.kill('SIGTERM'); + const err = new Error(`Timeout lors de l'appel AXA ${errorLabel}.`); + err.status = 504; + reject(err); + }, AXA_TIMEOUT_MS); + + child.on('error', (error) => { + clearTimeout(timeout); + const err = new Error(`Erreur technique AXA ${errorLabel}: ${error.message}`); + err.status = 502; + reject(err); + }); + child.on('exit', (code) => { + clearTimeout(timeout); + if (code === 0) return resolve(); + const err = new Error(`AXA ${errorLabel} en échec (code ${code}).`); + err.status = 502; + return reject(err); + }); + }); + return; + } catch (error) { + lastError = error; + logger.log('warn', `AXA ${errorLabel} failed`, { + attempt: attempt + 1, + retries: AXA_RETRY_COUNT + 1, + error: error.message, + payload + }); + if (attempt < AXA_RETRY_COUNT) await new Promise((resolve) => setTimeout(resolve, AXA_RETRY_DELAY_MS)); + } + } + throw lastError || new Error(`AXA ${errorLabel} failed`); +} + +function normalizeLookupInfo(raw = {}, fallbackContract = '') { + const numContrat = normalizeContractStrict(raw.numContrat || fallbackContract, { required: true }); + return { + numContrat, + numClient: String(raw.numClient || '').replace(/\D/g, '').slice(0, 16), + nomClient: collapseSpaces(raw.nomClient || ''), + adresseClient: collapseSpaces(raw.adresseClient || ''), + codePostalClient: collapseSpaces(raw.codePostal || raw.codePostalClient || ''), + nomAgent: collapseSpaces(raw.nomAgent || ''), + numAgent: String(raw.numAgent || '').replace(/\D/g, '').slice(0, 16) + }; +} + +async function lookupContractFromAxaScripts(numContrat, actor) { + if (process.platform !== 'win32') return null; + const matricule = sanitizeFilePart(actor?.userMatricule || 'SYSTEM'); + const pa025ConfigPath = path.join(AXA_SCRIPTS_ROOT, 'script_pa025', 'config', `client_${matricule}.txt`); + const cl063ConfigPath = path.join(AXA_SCRIPTS_ROOT, 'script_cl063', 'config', `client_${matricule}.txt`); + ensureDirectory(path.dirname(pa025ConfigPath)); + ensureDirectory(path.dirname(cl063ConfigPath)); + + const pa025Payload = [ + `numContrat:${numContrat}`, + 'dateDebutBordereau:', + 'dateFinBordereau:' + ].join('\r\n'); + + fs.writeFileSync(pa025ConfigPath, pa025Payload, 'utf8'); + await runWScriptScript('script_pa025/attestation.vbs', { payload: { numContrat }, errorLabel: 'PA025' }); + const pa025Data = readAxaKeyValueFile(pa025ConfigPath); + + let cl063Data = {}; + const pa025NumClient = String(pa025Data.numClient || '').replace(/\D/g, ''); + if (pa025NumClient) { + fs.writeFileSync(cl063ConfigPath, `numClient:${pa025NumClient}`, 'utf8'); + await runWScriptScript('script_cl063/extract.vbs', { payload: { numClient: pa025NumClient }, errorLabel: 'CL063' }); + cl063Data = readAxaKeyValueFile(cl063ConfigPath); + } + + const merged = { ...pa025Data, ...cl063Data }; + return { + ...normalizeLookupInfo(merged, numContrat), + source: 'axa scripts' + }; +} + +async function lookupContract(numContrat, actor) { + ensureRole(actor); + const formatted = normalizeContractStrict(numContrat, { required: true }); + + try { + const scriptInfo = await lookupContractFromAxaScripts(formatted, actor); + if (scriptInfo?.numContrat) { + await logAudit('lookup_contrat', actor, { + sourceType: 'axa_scripts', + numContrat: scriptInfo.numContrat, + numClient: scriptInfo.numClient || '' + }); + return scriptInfo; + } + } catch (error) { + logger.log('warn', 'lookupContract AXA scripts failed, fallback DB', { + numContrat: formatted, + actor: actor?.userMatricule || '', + error: error.message + }); + } + + const contratList = await db.records.getList('contrat', 1, 1, { + filter: `numContrat = "${escapeFilterValue(formatted)}"`, + expand: 'client,intermediaire' + }); + + if (contratList.totalItems > 0) { + const contrat = contratList.items[0]; + await logAudit('lookup_contrat', actor, { + sourceType: 'base_contrat', + numContrat: formatted, + numClient: contrat?.['@expand']?.client?.numClient || '' + }); + return { + numContrat: contrat.numContrat || formatted, + numClient: contrat?.['@expand']?.client?.numClient || '', + nomClient: contrat?.['@expand']?.client?.nom || '', + adresseClient: contrat?.['@expand']?.client?.adresse || '', + codePostalClient: contrat?.['@expand']?.client?.codePostal || '', + nomAgent: contrat?.['@expand']?.intermediaire?.nom || '', + numAgent: contrat?.['@expand']?.intermediaire?.numPortefeuille || '', + source: 'base contrat' + }; + } + + const delegate = await db.records.getList('advalo_deleguee', 1, 1, { + filter: `numContrat = "${escapeFilterValue(formatted)}"`, + sort: '-dateDebutIso' + }); + if (delegate.totalItems > 0) { + const row = delegate.items[0]; + await logAudit('lookup_contrat', actor, { + sourceType: 'grille_deleguee', + numContrat: formatted, + numClient: row.numClient || '' + }); + return { + numContrat: row.numContrat || formatted, + numClient: row.numClient || '', + nomClient: row.nomClient || '', + adresseClient: '', + codePostalClient: '', + nomAgent: row.souscripteur || '', + numAgent: row.numPortefeuille || '', + source: 'grille déléguée' + }; + } + + const demande = await db.records.getList('advalo_demande', 1, 1, { + filter: `numContrat = "${escapeFilterValue(formatted)}" && isDeleted = false`, + sort: '-dateDebutIso' + }); + if (demande.totalItems > 0) { + const row = demande.items[0]; + await logAudit('lookup_contrat', actor, { + sourceType: 'hors_grille', + numContrat: formatted, + numClient: row.numClient || '' + }); + return { + numContrat: row.numContrat || formatted, + numClient: row.numClient || '', + nomClient: row.nomClient || '', + adresseClient: '', + codePostalClient: '', + nomAgent: row.souscripteur || '', + numAgent: row.numPortefeuille || '', + source: 'hors grille' + }; + } + + const result = { + numContrat: formatted, + numClient: '', + nomClient: '', + adresseClient: '', + codePostalClient: '', + nomAgent: '', + numAgent: '', + source: 'none' + }; + await logAudit('lookup_contrat', actor, { + sourceType: 'none', + numContrat: formatted, + numClient: '' + }); + return result; +} + +async function getHistorique(filters, actor) { + ensureRole(actor); + const { page, pageSize } = parsePagination(filters); + const sort = normalizeSort(filters.sort, 'dateDebutSql', [ + 'dateDebutSql', + 'dateDebutIso', + 'dateDebut', + 'dateFinSql', + 'dateFinIso', + 'dateFin', + 'numContrat', + 'numClient', + 'tarifNum', + 'tarif' + ]); + const sqlSort = ['dateDebut', 'dateDebutIso'].includes(sort) + ? 'dateDebutSql' + : ['dateFin', 'dateFinIso'].includes(sort) + ? 'dateFinSql' + : ['tarif', 'tarifNum'].includes(sort) + ? 'tarifNum' + : sort; + const order = normalizeOrder(filters.order); + + const where = buildCommonWhere(filters, actor); + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + + const countRows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT COUNT(*) AS totalRows FROM normalized ${whereClause}; +`); + const totalRows = Number(countRows[0]?.totalRows || 0); + const meta = buildPaginationMeta(totalRows, page, pageSize); + + const rows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + id, + source, + sourceLabel, + numDemande, + numClient, + nomClient, + numContrat, + dateDebut, + dateFin, + dateDebutIso, + dateFinIso, + marchandise, + mode, + capital, + depart, + arrivee, + tarif, + tarifNum, + statutFacturation, + statutNormalized, + region, + dpt, + souscripteur, + createdBy +FROM normalized +${whereClause} +ORDER BY ${sqlSort} ${order}, id ASC +LIMIT ${meta.pageSize} OFFSET ${meta.offset}; +`); + + const normalizedRows = rows.map(normalizeSqlRow); + const detailRows = normalizedRows.filter((row) => row.source === 'hors_grille'); + let detailById = {}; + if (detailRows.length) { + const idsSql = detailRows.map((row) => sqlQuote(row.id)).join(', '); + const audits = await runSqlJson(` +SELECT + json_extract(data, '$.demandeId') AS demandeId, + json_extract(data, '$.actorMatricule') AS actorMatricule, + json_extract(data, '$.actorNom') AS actorNom, + json_extract(data, '$.actorPrenom') AS actorPrenom, + json_extract(data, '$.pricing.valeurAssuree') AS valeurAssuree, + json_extract(data, '$.pricing.taux') AS taux, + json_extract(data, '$.pricing.primeMinimum') AS primeMinimum, + json_extract(data, '$.pricing.cotisationHT') AS cotisationHT, + json_extract(data, '$.pricing.coutActe') AS coutActe, + json_extract(data, '$.pricing.cotisationTTC') AS cotisationTTC, + created +FROM advalo_audit +WHERE eventType IN ('create','update') + AND json_extract(data, '$.demandeId') IN (${idsSql}) +ORDER BY created DESC; +`); + audits.forEach((audit) => { + const key = String(audit.demandeId || ''); + if (!key || detailById[key]) return; + detailById[key] = { + actorMatricule: String(audit.actorMatricule || ''), + actorNom: String(audit.actorNom || ''), + actorPrenom: String(audit.actorPrenom || ''), + valeurAssuree: String(audit.valeurAssuree || ''), + taux: String(audit.taux || ''), + primeMinimum: String(audit.primeMinimum || ''), + cotisationHT: String(audit.cotisationHT || ''), + coutActe: String(audit.coutActe || ''), + cotisationTTC: String(audit.cotisationTTC || '') + }; + }); + } + + return { + rows: normalizedRows.map((row) => ({ + ...row, + details: { + marchandise: row.marchandise || '', + mode: row.mode || '', + depart: row.depart || '', + arrivee: row.arrivee || '', + valeurAssuree: detailById[row.id]?.valeurAssuree || row.capital || '', + taux: detailById[row.id]?.taux || '', + primeMinimum: detailById[row.id]?.primeMinimum || '', + cotisationHT: detailById[row.id]?.cotisationHT || '', + coutActe: detailById[row.id]?.coutActe || '', + cotisationTTC: detailById[row.id]?.cotisationTTC || row.tarif || '', + actorMatricule: detailById[row.id]?.actorMatricule || row.createdBy || '', + actorNom: detailById[row.id]?.actorNom || '', + actorPrenom: detailById[row.id]?.actorPrenom || '' + } + })), + totalRows: meta.totalRows, + totalPages: meta.totalPages, + page: meta.page, + pageSize: meta.pageSize + }; +} + +async function getCumul(filters, actor) { + ensureRole(actor); + const { page, pageSize } = parsePagination(filters); + const sort = normalizeSort(filters.sort, 'totalNonFacture', [ + 'numContrat', + 'nomClient', + 'region', + 'dpt', + 'souscripteur', + 'totalAdvalo', + 'totalFacture', + 'totalNonFacture', + 'totalLignes' + ]); + const order = normalizeOrder(filters.order || 'desc'); + + const where = buildCommonWhere({ + ...filters, + facture: 'true', + nonFacture: 'true', + deleguee: 'true', + nonDeleguee: 'true' + }, actor); + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + + const totalsRows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + COALESCE(SUM(tarifNum), 0) AS totalAdvalo, + COALESCE(SUM(CASE WHEN statutNormalized = 'facture' THEN tarifNum ELSE 0 END), 0) AS totalFacture, + COALESCE(SUM(CASE WHEN statutNormalized = 'non_facture' THEN tarifNum ELSE 0 END), 0) AS totalNonFacture, + COUNT(*) AS totalLignes +FROM normalized +${whereClause}; +`); + const totals = { + totalAdvalo: Number(totalsRows[0]?.totalAdvalo || 0), + totalFacture: Number(totalsRows[0]?.totalFacture || 0), + totalNonFacture: Number(totalsRows[0]?.totalNonFacture || 0), + totalLignes: Number(totalsRows[0]?.totalLignes || 0) + }; + + const contractsCountRows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT COUNT(*) AS totalRows FROM ( + SELECT numContrat + FROM normalized + ${whereClause} + GROUP BY numContrat +) x; +`); + const totalRows = Number(contractsCountRows[0]?.totalRows || 0); + const meta = buildPaginationMeta(totalRows, page, pageSize); + + const rows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + numContrat, + MAX(nomClient) AS nomClient, + MAX(region) AS region, + MAX(dpt) AS dpt, + MAX(souscripteur) AS souscripteur, + COALESCE(SUM(tarifNum), 0) AS totalAdvalo, + COALESCE(SUM(CASE WHEN statutNormalized = 'facture' THEN tarifNum ELSE 0 END), 0) AS totalFacture, + COALESCE(SUM(CASE WHEN statutNormalized = 'non_facture' THEN tarifNum ELSE 0 END), 0) AS totalNonFacture, + COUNT(*) AS totalLignes +FROM normalized +${whereClause} +GROUP BY numContrat +ORDER BY ${sort} ${order}, numContrat ASC +LIMIT ${meta.pageSize} OFFSET ${meta.offset}; +`); + + return { + ...totals, + rows: rows.map((row) => ({ + numContrat: padContract(row.numContrat), + nomClient: row.nomClient || '', + region: row.region || '', + dpt: row.dpt || '', + souscripteur: row.souscripteur || '', + totalAdvalo: Number(row.totalAdvalo || 0), + totalFacture: Number(row.totalFacture || 0), + totalNonFacture: Number(row.totalNonFacture || 0), + totalLignes: Number(row.totalLignes || 0) + })), + meta: { + totalRows: meta.totalRows, + totalPages: meta.totalPages, + page: meta.page, + pageSize: meta.pageSize + } + }; +} + +function toGroupRows(rows) { + return rows.map((row) => ({ + label: row.label || 'Non renseigné', + totalAdvalo: Number(row.totalAdvalo || 0), + totalFacture: Number(row.totalFacture || 0), + totalNonFacture: Number(row.totalNonFacture || 0), + totalLignes: Number(row.totalLignes || 0) + })); +} + +async function getReporting(filters, actor) { + ensureReportingRole(actor); + + const { page, pageSize } = parsePagination(filters); + const sort = normalizeSort(filters.sort, 'totalAdvalo', [ + 'numContrat', + 'nomClient', + 'region', + 'souscripteur', + 'totalAdvalo', + 'totalFacture', + 'totalNonFacture', + 'totalLignes' + ]); + const order = normalizeOrder(filters.order || 'desc'); + + const where = buildCommonWhere(filters, actor); + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + + const totalsRows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + COALESCE(SUM(tarifNum), 0) AS totalAdvalo, + COALESCE(SUM(CASE WHEN statutNormalized = 'facture' THEN tarifNum ELSE 0 END), 0) AS totalFacture, + COALESCE(SUM(CASE WHEN statutNormalized = 'non_facture' THEN tarifNum ELSE 0 END), 0) AS totalNonFacture, + COUNT(*) AS totalLignes +FROM normalized +${whereClause}; +`); + const totaux = { + totalAdvalo: Number(totalsRows[0]?.totalAdvalo || 0), + totalFacture: Number(totalsRows[0]?.totalFacture || 0), + totalNonFacture: Number(totalsRows[0]?.totalNonFacture || 0), + totalLignes: Number(totalsRows[0]?.totalLignes || 0) + }; + + const contractsCountRows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT COUNT(*) AS totalRows FROM ( + SELECT numContrat + FROM normalized + ${whereClause} + GROUP BY numContrat +) x; +`); + const totalRows = Number(contractsCountRows[0]?.totalRows || 0); + const meta = buildPaginationMeta(totalRows, page, pageSize); + + const rows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + numContrat, + MAX(nomClient) AS nomClient, + MAX(region) AS region, + MAX(souscripteur) AS souscripteur, + COALESCE(SUM(tarifNum), 0) AS totalAdvalo, + COALESCE(SUM(CASE WHEN statutNormalized = 'facture' THEN tarifNum ELSE 0 END), 0) AS totalFacture, + COALESCE(SUM(CASE WHEN statutNormalized = 'non_facture' THEN tarifNum ELSE 0 END), 0) AS totalNonFacture, + COUNT(*) AS totalLignes +FROM normalized +${whereClause} +GROUP BY numContrat +ORDER BY ${sort} ${order}, numContrat ASC +LIMIT ${meta.pageSize} OFFSET ${meta.offset}; +`); + + const groupedClients = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + CASE WHEN trim(nomClient) = '' THEN 'Non renseigné' ELSE trim(nomClient) END AS label, + COALESCE(SUM(tarifNum), 0) AS totalAdvalo, + COALESCE(SUM(CASE WHEN statutNormalized = 'facture' THEN tarifNum ELSE 0 END), 0) AS totalFacture, + COALESCE(SUM(CASE WHEN statutNormalized = 'non_facture' THEN tarifNum ELSE 0 END), 0) AS totalNonFacture, + COUNT(*) AS totalLignes +FROM normalized +${whereClause} +GROUP BY label +ORDER BY totalAdvalo DESC +LIMIT 20; +`); + + const groupedSouscripteurs = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + CASE WHEN trim(souscripteur) = '' THEN 'Non renseigné' ELSE trim(souscripteur) END AS label, + COALESCE(SUM(tarifNum), 0) AS totalAdvalo, + COALESCE(SUM(CASE WHEN statutNormalized = 'facture' THEN tarifNum ELSE 0 END), 0) AS totalFacture, + COALESCE(SUM(CASE WHEN statutNormalized = 'non_facture' THEN tarifNum ELSE 0 END), 0) AS totalNonFacture, + COUNT(*) AS totalLignes +FROM normalized +${whereClause} +GROUP BY label +ORDER BY totalAdvalo DESC +LIMIT 20; +`); + + const groupedRegions = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + CASE WHEN trim(region) = '' THEN 'Non renseigné' ELSE trim(region) END AS label, + COALESCE(SUM(tarifNum), 0) AS totalAdvalo, + COALESCE(SUM(CASE WHEN statutNormalized = 'facture' THEN tarifNum ELSE 0 END), 0) AS totalFacture, + COALESCE(SUM(CASE WHEN statutNormalized = 'non_facture' THEN tarifNum ELSE 0 END), 0) AS totalNonFacture, + COUNT(*) AS totalLignes +FROM normalized +${whereClause} +GROUP BY label +ORDER BY totalAdvalo DESC +LIMIT 20; +`); + + const actorStats = await runSqlJson(` +SELECT + trim( + COALESCE(json_extract(data, '$.actorPrenom'), '') || + CASE WHEN trim(COALESCE(json_extract(data, '$.actorPrenom'), '')) != '' THEN ' ' ELSE '' END || + COALESCE(json_extract(data, '$.actorNom'), '') + ) AS actorName, + COALESCE(json_extract(data, '$.actorMatricule'), '') AS actorMatricule, + COALESCE(eventType, '') AS actionType, + COUNT(*) AS actionsCount +FROM advalo_audit +WHERE eventType IN ('create','update','delete_soft','facturation_batch','doc_avenant','doc_attestation','lookup_contrat') +GROUP BY actorMatricule, actorName, actionType +ORDER BY actionsCount DESC +LIMIT 100; +`); + + return { + rows: rows.map((row) => ({ + numContrat: padContract(row.numContrat), + nomClient: row.nomClient || '', + region: row.region || '', + souscripteur: row.souscripteur || '', + totalAdvalo: Number(row.totalAdvalo || 0), + totalFacture: Number(row.totalFacture || 0), + totalNonFacture: Number(row.totalNonFacture || 0), + totalLignes: Number(row.totalLignes || 0) + })), + meta: { + totalRows: meta.totalRows, + totalPages: meta.totalPages, + page: meta.page, + pageSize: meta.pageSize + }, + totaux, + groupes: { + clients: toGroupRows(groupedClients), + souscripteurs: toGroupRows(groupedSouscripteurs), + regions: toGroupRows(groupedRegions) + }, + actorStats: actorStats.map((row) => ({ + actorMatricule: String(row.actorMatricule || ''), + actorName: String(row.actorName || '').trim() || 'Non renseigné', + actionType: String(row.actionType || ''), + actionsCount: Number(row.actionsCount || 0) + })) + }; +} + +async function createPonctuel(payload, actor) { + ensureRole(actor); + const required = [ + 'typeFacturation', + 'numContrat', + 'marchandise', + 'capital', + 'depart', + 'arrivee', + 'dateDebut', + 'dateFin', + 'tarif', + 'taux', + 'primeMinimum', + 'cotisationHT', + 'cotisationTTC' + ]; + const missing = required.filter((key) => !String(payload[key] || '').trim()); + if (missing.length) { + const err = new Error(`Champs obligatoires manquants: ${missing.join(', ')}`); + err.status = 400; + throw err; + } + + const typeFacturation = String(payload.typeFacturation || '').toLowerCase(); + if (!['ponctuel', 'periodique'].includes(typeFacturation)) { + const err = new Error('Type de facturation invalide (ponctuel|periodique).'); + err.status = 400; + throw err; + } + const numContrat = normalizeContractStrict(payload.numContrat, { required: true }); + const modes = normalizeTransportModes(payload.mode); + if (!modes.length) { + const err = new Error('Sélectionne au moins un mode de transport.'); + err.status = 400; + throw err; + } + + const now = new Date(); + const numDemande = + payload.numDemande || `${actor.userMatricule}_${now.toISOString().slice(0, 19).replace(/[T:]/g, '-')}`; + + const row = { + sourceType: typeFacturation, + numDemande, + numClient: String(payload.numClient || '').trim(), + nomClient: String(payload.nomClient || '').trim(), + numContrat, + dateDemande: formatFrDate(now), + marchandise: String(payload.marchandise).trim(), + mode: modes.join(', '), + capital: String(payload.capital).trim(), + depart: String(payload.depart).trim(), + arrivee: String(payload.arrivee).trim(), + dateDebut: String(payload.dateDebut).trim(), + dateFin: String(payload.dateFin).trim(), + dateDebutIso: parseFrDate(payload.dateDebut)?.toISOString() || null, + dateFinIso: parseFrDate(payload.dateFin)?.toISOString() || null, + nomDevis: payload.nomDevis || 'Pas de nom de devis', + proprietaire: payload.proprietaire || 'Proprietaires multiple', + tarif: String(payload.tarif).trim(), + statutCommande: 'Validé', + statutFacturation: 'Non facturé', + isDeleted: false, + createdBy: actor.userMatricule, + region: actor.userRegion || '' + }; + + if (payload.facturer === true) { + await runQt550Axa({ + matricule: actor.userMatricule, + numContrat: row.numContrat, + capital: row.capital, + tarif: row.tarif, + dateDebut: row.dateDebut, + dateFin: row.dateFin + }); + row.statutFacturation = `Facturé ${formatFrDate(new Date())}`; + } + const created = await db.records.create('advalo_demande', row); + await logAudit('create', actor, { + sourceType: 'hors_grille', + demandeId: created.id, + numContrat: created.numContrat, + numClient: created.numClient || '', + pricing: { + valeurAssuree: String(payload.capital || ''), + taux: String(payload.taux || ''), + primeMinimum: String(payload.primeMinimum || ''), + cotisationHT: String(payload.cotisationHT || ''), + coutActe: String(payload.coutActe || ''), + cotisationTTC: String(payload.cotisationTTC || '') + }, + mode: created.mode, + marchandise: created.marchandise, + depart: created.depart, + arrivee: created.arrivee, + statutFacturation: created.statutFacturation + }); + return created; +} + +function parseSourceId(composedId) { + const raw = String(composedId || ''); + if (raw.startsWith('d:')) return { source: 'advalo_demande', id: raw.slice(2) }; + if (raw.startsWith('g:')) return { source: 'advalo_deleguee', id: raw.slice(2) }; + return { source: 'advalo_demande', id: raw }; +} + +async function getDemandByTypedId(composedId) { + const parsed = parseSourceId(composedId); + const record = await db.records.getOne(parsed.source, parsed.id); + return { + ...record, + id: parsed.id, + source: parsed.source === 'advalo_deleguee' ? 'deleguee' : 'hors_grille', + typedId: `${parsed.source === 'advalo_deleguee' ? 'g' : 'd'}:${parsed.id}` + }; +} + +async function getHistoriqueDetail(composedId, actor) { + ensureRole(actor); + const row = await getDemandByTypedId(composedId); + const [lastAudit] = await runSqlJson(` +SELECT + json_extract(data, '$.actorMatricule') AS actorMatricule, + json_extract(data, '$.actorNom') AS actorNom, + json_extract(data, '$.actorPrenom') AS actorPrenom, + json_extract(data, '$.pricing.valeurAssuree') AS valeurAssuree, + json_extract(data, '$.pricing.taux') AS taux, + json_extract(data, '$.pricing.primeMinimum') AS primeMinimum, + json_extract(data, '$.pricing.cotisationHT') AS cotisationHT, + json_extract(data, '$.pricing.coutActe') AS coutActe, + json_extract(data, '$.pricing.cotisationTTC') AS cotisationTTC +FROM advalo_audit +WHERE json_extract(data, '$.demandeId') = ${sqlQuote(row.id)} + AND eventType IN ('create','update') +ORDER BY created DESC +LIMIT 1; +`); + return { + ...row, + details: { + marchandise: row.marchandise || '', + mode: row.mode || '', + depart: row.depart || '', + arrivee: row.arrivee || '', + valeurAssuree: String(lastAudit?.valeurAssuree || row.capital || ''), + taux: String(lastAudit?.taux || ''), + primeMinimum: String(lastAudit?.primeMinimum || ''), + cotisationHT: String(lastAudit?.cotisationHT || ''), + coutActe: String(lastAudit?.coutActe || ''), + cotisationTTC: String(lastAudit?.cotisationTTC || row.tarif || ''), + actorMatricule: String(lastAudit?.actorMatricule || row.createdBy || ''), + actorNom: String(lastAudit?.actorNom || ''), + actorPrenom: String(lastAudit?.actorPrenom || '') + } + }; +} + +async function softDeleteDemande(composedId, actor) { + ensureRole(actor); + const row = await getDemandByTypedId(composedId); + if (row.source !== 'hors_grille') { + const err = new Error('Suppression autorisée uniquement sur les demandes hors grille.'); + err.status = 400; + throw err; + } + if (normalizeStatus(row.statutFacturation) === 'facture') { + const err = new Error('Une demande facturée ne peut pas être supprimée.'); + err.status = 400; + throw err; + } + const updated = await db.records.update('advalo_demande', row.id, { isDeleted: true }); + await logAudit('delete_soft', actor, { + sourceType: 'hors_grille', + demandeId: row.id, + numContrat: row.numContrat || '', + numClient: row.numClient || '' + }); + return updated; +} + +function getDocDateStamp() { + const now = new Date(); + const yyyy = now.getUTCFullYear(); + const mm = String(now.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(now.getUTCDate()).padStart(2, '0'); + const hh = String(now.getUTCHours()).padStart(2, '0'); + const mi = String(now.getUTCMinutes()).padStart(2, '0'); + const ss = String(now.getUTCSeconds()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}-${hh}-${mi}-${ss}`; +} + +function buildCommonDocContext(row, contractInfo = {}, actor = {}) { + const dateEmission = formatFrDate(new Date()); + const rawContrat = row.numContrat || contractInfo.numContrat || ''; + const normalizedContrat = padContract(rawContrat); + const numContrat = isAllZeroDigits(normalizedContrat) ? '' : normalizedContrat; + const rawClient = String(contractInfo.numClient || row.numClient || '').trim(); + const numClient = safeDocValue(isAllZeroDigits(rawClient) ? '' : rawClient); + const nomClient = safeDocValue(contractInfo.nomClient || row.nomClient); + const adresseClient = safeDocValue(contractInfo.adresseClient || ''); + const codePostal = safeDocValue(contractInfo.codePostalClient || ''); + const actorDisplayName = buildActorDisplayName(actor); + const nomAgentSource = actorDisplayName || contractInfo.nomAgent || row.souscripteur || ''; + const nomAgent = safeDocValue(withMatriculeSuffix(nomAgentSource, actor)); + const numAgent = safeDocValue(contractInfo.numAgent || ''); + + return { + nomAgent, + numAgent, + adresseAgent: '', + postalAgent: '', + telAgent: '', + faxAgent: '', + nomClient, + adresseClient, + codePostal, + numContrat, + numClient, + dateEmission, + dateDebut: safeDocValue(row.dateDebut), + dateFin: safeDocValue(row.dateFin), + capitaux: prettyNumber(row.capital), + capitauxField: prettyNumber(row.capital), + cotisation: prettyNumber(row.tarif), + cotisationField: prettyNumber(row.tarif), + cotisationTTC: prettyNumber(row.tarif), + coutActeField: '36,00', + tauxField: '0,30', + taux: '0,30', + intervalle: `${safeDocValue(row.dateDebut)} au ${safeDocValue(row.dateFin)}`, + typeMarchandise: safeDocValue(row.marchandise), + marchandise: safeDocValue(row.marchandise), + modes: safeDocValue(row.mode), + mode: safeDocValue(row.mode), + depart: safeDocValue(row.depart), + arrivee: safeDocValue(row.arrivee), + primeMinimum: '15,00', + numDemande: safeDocValue(row.numDemande), + matricule: safeDocValue(actor.userMatricule || ''), + prenomSouscripteur: safeDocValue(actor.userFirstName || ''), + nomSouscripteur: safeDocValue(actor.userLastName || ''), + souscripteur: safeDocValue(withMatriculeSuffix(actorDisplayName, actor)), + regionSouscripteur: safeDocValue(actor.userRegion || ''), + listeDemandes: safeDocValue(row.numDemande || '') + }; +} + +async function generateDemandeDocument(composedId, type, actor) { + ensureRole(actor); + const row = await getDemandByTypedId(composedId); + const contractInfo = await lookupContract(row.numContrat, actor); + const context = buildCommonDocContext(row, contractInfo, actor); + const nowStamp = getDocDateStamp(); + const clientSlug = sanitizeFilePart(context.nomClient || 'client'); + const contractSlug = sanitizeFilePart(context.numContrat || 'contrat'); + + let templateName = ''; + let docType = ''; + let outputFolder = ''; + let filename = ''; + let replacements = {}; + + if (type === 'attestation') { + templateName = 'Certificat_Assurance_Advalo.docx'; + docType = 'attestation'; + outputFolder = path.join(ADVALO_DOCUMENTS_DIR, 'attestations'); + filename = `Attestation_Advalo_${clientSlug}_${contractSlug}_${nowStamp}.docx`; + replacements = { + '«numContrat»': context.numContrat, + '«nomClient»': context.nomClient, + '«dateDebut»': context.dateDebut, + '«dateFin»': context.dateFin, + '«capital»': context.capitauxField, + '«mode»': context.modes, + '«depart»': context.depart, + '«arrivee»': context.arrivee, + '«marchandise»': context.marchandise, + '«numDemande»': context.numDemande, + '«dateEmission»': context.dateEmission + }; + } else { + templateName = 'Avenant_Ponctuel.docx'; + docType = 'avenant_ponctuel'; + outputFolder = path.join(ADVALO_DOCUMENTS_DIR, 'ponctuel'); + filename = `Avenant_Advalo_${clientSlug}_${contractSlug}_${nowStamp}.docx`; + replacements = { + nomAgent: context.nomAgent, + numAgent: context.numAgent, + adresseAgent: context.adresseAgent, + postalAgent: context.postalAgent, + telAgent: context.telAgent, + faxAgent: context.faxAgent, + nomClient: context.nomClient, + adresseClient: context.adresseClient, + codePostal: context.codePostal, + numContrat: context.numContrat, + numClient: context.numClient, + typeMarchandise: context.typeMarchandise, + modes: context.modes, + depart: context.depart, + arrivee: context.arrivee, + dateDebut: context.dateDebut, + dateFin: context.dateFin, + capitauxField: context.capitauxField, + tauxField: context.tauxField, + primeMinimum: context.primeMinimum, + cotisationField: context.cotisationField, + coutActeField: context.coutActeField, + cotisationTTC: context.cotisationTTC, + dateEmission: context.dateEmission, + numDemande: context.numDemande + }; + } + + const templatePath = path.join(ADVALO_TEMPLATE_DIR, templateName); + if (!fs.existsSync(templatePath)) { + const err = new Error(`Template introuvable: ${templateName}`); + err.status = 500; + throw err; + } + + ensureDirectory(outputFolder); + const buffer = buildDocxFromTemplate(templatePath, replacements); + const filePath = path.join(outputFolder, filename); + fs.writeFileSync(filePath, buffer); + await saveAdvaloDocumentTrace({ + type: docType, + filePath, + buffer, + demandeId: row.id, + batchId: '' + }); + await logAudit(type === 'attestation' ? 'doc_attestation' : 'doc_avenant', actor, { + sourceType: row.source, + demandeId: row.id, + numContrat: row.numContrat || '', + numClient: row.numClient || '', + filename + }); + + return { filename, filePath, buffer }; +} + +async function generateBatchAvenant(batchId, actor) { + ensureRole(actor); + const batch = await db.records.getOne('advalo_facturation_batch', batchId); + const [batchAudit] = await runSqlJson(` +SELECT json_extract(data, '$.includeTransportDetails') AS includeTransportDetails +FROM advalo_audit +WHERE eventType = 'facturation_batch' + AND json_extract(data, '$.batchId') = ${sqlQuote(batch.id)} +ORDER BY created DESC +LIMIT 1; +`); + const includeTransportDetails = String(batchAudit?.includeTransportDetails || 'true') !== 'false'; + const contractInfo = await lookupContract(batch.numContrat, actor); + const nowStamp = getDocDateStamp(); + const clientSlug = sanitizeFilePart(contractInfo.nomClient || 'client'); + const contractSlug = sanitizeFilePart(batch.numContrat || 'contrat'); + const templatePath = path.join(ADVALO_TEMPLATE_DIR, 'Avenant.docx'); + + if (!fs.existsSync(templatePath)) { + const err = new Error('Template Avenant.docx introuvable.'); + err.status = 500; + throw err; + } + + const outputFolder = path.join(ADVALO_DOCUMENTS_DIR, 'periodique'); + ensureDirectory(outputFolder); + const filename = `Avenant_Periodique_${clientSlug}_${contractSlug}_${nowStamp}.docx`; + const filePath = path.join(outputFolder, filename); + const actorDisplayName = buildActorDisplayName(actor); + const interlocuteur = withMatriculeSuffix( + actorDisplayName || contractInfo.nomAgent || '', + actor + ); + const replacements = { + nomAgent: safeDocValue(interlocuteur), + adresseAgent: '', + postalAgent: '', + telAgent: '', + faxAgent: '', + nomClient: safeDocValue(contractInfo.nomClient || ''), + adresseClient: safeDocValue(contractInfo.adresseClient || ''), + codePostal: safeDocValue(contractInfo.codePostalClient || ''), + numContrat: padContract(batch.numContrat || ''), + numClient: safeDocValue(contractInfo.numClient || ''), + intervalle: `${safeDocValue(batch.dateDebut)} au ${safeDocValue(batch.dateFin)}`, + cotisationTTC: prettyNumber(batch.totalCotisation), + dateEmission: formatFrDate(new Date()), + capitauxField: prettyNumber(batch.totalCapitaux), + tauxField: '0,30', + cotisationField: prettyNumber(batch.totalCotisation), + coutActeField: '36,00', + listeDemandes: includeTransportDetails ? (Array.isArray(batch.demandeIds) ? batch.demandeIds.join(', ') : '') : '' + }; + + const buffer = buildDocxFromTemplate(templatePath, replacements); + fs.writeFileSync(filePath, buffer); + await saveAdvaloDocumentTrace({ + type: 'avenant_periodique', + filePath, + buffer, + demandeId: '', + batchId: batch.id + }); + await logAudit('doc_avenant', actor, { + sourceType: 'batch', + batchId: batch.id, + numContrat: batch.numContrat || '', + filename, + includeTransportDetails + }); + + return { filename, filePath, buffer }; +} + +async function facturerBatch(payload, actor) { + ensureRole(actor); + const sourceMode = String(payload.sourceMode || 'hors_grille').toLowerCase(); + const includeTransportDetails = payload.includeTransportDetails !== false; + const removed = new Set(Array.isArray(payload.removedDemandeIds) ? payload.removedDemandeIds.filter(Boolean) : []); + const ids = (Array.isArray(payload.demandeIds) ? payload.demandeIds.filter(Boolean) : []) + .filter((id) => !removed.has(id)); + + if (!ids.length) { + const err = new Error('Aucune ligne sélectionnée pour la facturation.'); + err.status = 400; + throw err; + } + + const parsedIds = ids.map(parseSourceId); + if (sourceMode === 'hors_grille' && parsedIds.some((item) => item.source !== 'advalo_demande')) { + const err = new Error('Le mode Hors grille ne permet pas de facturer des lignes grille déléguée.'); + err.status = 400; + throw err; + } + + const rows = await Promise.all( + parsedIds.map(async (entry) => { + const row = await db.records.getOne(entry.source, entry.id); + return { ...row, _collection: entry.source, _typedId: `${entry.source === 'advalo_demande' ? 'd' : 'g'}:${entry.id}` }; + }) + ); + + const active = rows.filter((row) => row && !row.isDeleted && normalizeStatus(row.statutFacturation) !== 'facture'); + if (!active.length) { + const err = new Error('Aucune ligne non facturée sur la période/contrat.'); + err.status = 400; + throw err; + } + + const numContrat = padContract(active[0].numContrat); + const dateDebut = active[0].dateDebut; + const dateFin = active[0].dateFin; + const fingerprint = hashFingerprint({ + numContrat, + dateDebut, + dateFin, + sourceMode, + ids: active.map((row) => row._typedId).sort() + }); + + const existing = await db.records.getList('advalo_facturation_batch', 1, 1, { + filter: `fingerprint="${fingerprint}" && status="DONE"` + }); + if (existing.totalItems > 0) { + return { idempotent: true, batch: existing.items[0] }; + } + + const totalCapitaux = active.reduce((acc, row) => acc + parseAmount(row.capital), 0); + const totalCotisation = active.reduce((acc, row) => acc + parseAmount(row.tarif), 0); + + const batch = await db.records.create('advalo_facturation_batch', { + numContrat, + dateDebut, + dateFin, + sourceMode, + demandeIds: active.map((row) => row._typedId), + totalCapitaux, + totalCotisation, + fingerprint, + status: 'IN_PROGRESS', + createdBy: actor.userMatricule, + createdAt: new Date().toISOString() + }); + + try { + await runQt550Axa({ + matricule: actor.userMatricule, + numContrat, + totalCapitaux, + totalCotisation, + dateDebut, + dateFin + }); + const factDate = `Facturé ${formatFrDate(new Date())}`; + + await Promise.all( + active.map((row) => + db.records.update(row._collection, row.id, { + statutFacturation: factDate + }) + ) + ); + + const done = await db.records.update('advalo_facturation_batch', batch.id, { + status: 'DONE', + finishedAt: new Date().toISOString() + }); + await logAudit('facturation_batch', actor, { + sourceType: sourceMode, + batchId: done.id, + numContrat, + numClient: active[0].numClient || '', + includeTransportDetails, + demandeIds: active.map((row) => row._typedId), + totals: { totalCapitaux, totalCotisation } + }); + + return { idempotent: false, batch: done }; + } catch (error) { + await db.records.update('advalo_facturation_batch', batch.id, { + status: 'FAILED', + errorMessage: error.message, + finishedAt: new Date().toISOString() + }); + throw error; + } +} + +async function exportHistorique(filters, actor) { + ensureRole(actor); + const sort = normalizeSort(filters.sort, 'dateDebutSql', [ + 'dateDebutSql', + 'dateDebutIso', + 'dateDebut', + 'dateFinSql', + 'dateFinIso', + 'dateFin', + 'numContrat', + 'numClient', + 'tarifNum', + 'tarif' + ]); + const sqlSort = ['dateDebut', 'dateDebutIso'].includes(sort) + ? 'dateDebutSql' + : ['dateFin', 'dateFinIso'].includes(sort) + ? 'dateFinSql' + : ['tarif', 'tarifNum'].includes(sort) + ? 'tarifNum' + : sort; + const order = normalizeOrder(filters.order); + + const where = buildCommonWhere(filters, actor); + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''; + + const rows = await runSqlJson(` +${MERGED_CTE_SQL} +SELECT + id, + source, + sourceLabel, + numDemande, + numClient, + nomClient, + numContrat, + dateDebut, + dateFin, + dateDebutIso, + dateFinIso, + tarif, + statutFacturation, + statutNormalized +FROM normalized +${whereClause} +ORDER BY ${sqlSort} ${order}, id ASC; +`); + + return toCsv(rows.map(normalizeSqlRow)); +} + +module.exports = { + lookupContract, + getHistorique, + getHistoriqueDetail, + getCumul, + createPonctuel, + softDeleteDemande, + facturerBatch, + generateDemandeDocument, + generateBatchAvenant, + getReporting, + exportHistorique +}; diff --git a/ecole/src/templates/advalo/Avenant.docx b/ecole/src/templates/advalo/Avenant.docx new file mode 100644 index 00000000..e3646f21 Binary files /dev/null and b/ecole/src/templates/advalo/Avenant.docx differ diff --git a/ecole/src/templates/advalo/Avenant_Ponctuel.docx b/ecole/src/templates/advalo/Avenant_Ponctuel.docx new file mode 100644 index 00000000..7d21c6c0 Binary files /dev/null and b/ecole/src/templates/advalo/Avenant_Ponctuel.docx differ diff --git a/ecole/src/templates/advalo/Certificat_Assurance_Advalo.docx b/ecole/src/templates/advalo/Certificat_Assurance_Advalo.docx new file mode 100644 index 00000000..a2c79833 Binary files /dev/null and b/ecole/src/templates/advalo/Certificat_Assurance_Advalo.docx differ diff --git a/ecole/vbs/script_cl063/Connexion.xlsm b/ecole/vbs/script_cl063/Connexion.xlsm new file mode 100644 index 00000000..3bea48df Binary files /dev/null and b/ecole/vbs/script_cl063/Connexion.xlsm differ diff --git a/ecole/vbs/script_cl063/ECFDDEViva.exe b/ecole/vbs/script_cl063/ECFDDEViva.exe new file mode 100644 index 00000000..3abaed46 Binary files /dev/null and b/ecole/vbs/script_cl063/ECFDDEViva.exe differ diff --git a/ecole/vbs/script_cl063/config/.gitkeep b/ecole/vbs/script_cl063/config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ecole/vbs/script_cl063/extract.vbs b/ecole/vbs/script_cl063/extract.vbs new file mode 100644 index 00000000..ca93976b --- /dev/null +++ b/ecole/vbs/script_cl063/extract.vbs @@ -0,0 +1,24 @@ +Option Explicit +On Error Resume Next +ExempleMacroExcel + +Sub ExempleMacroExcel() + + Dim ApplicationExcel + Dim ClasseurExcel + Set ApplicationExcel = CreateObject("Excel.Application") + + Dim WshShell, strCurDir + Set WshShell = CreateObject("WScript.Shell") + strCurDir = WshShell.CurrentDirectory + + + Set ClasseurExcel = ApplicationExcel.Workbooks.Open( strCurDir & "\vbs\script_cl063\Connexion.xlsm") + ApplicationExcel.Visible = False + ApplicationExcel.Run "CL063_AC800_sub" 'va lancer la macro + ApplicationExcel.Quit + + Set ClasseurExcel = Nothing + Set ApplicationExcel = Nothing + +End Sub \ No newline at end of file diff --git a/ecole/vbs/script_pa025/Attestation.xlsm b/ecole/vbs/script_pa025/Attestation.xlsm new file mode 100644 index 00000000..6c3644e1 Binary files /dev/null and b/ecole/vbs/script_pa025/Attestation.xlsm differ diff --git a/ecole/vbs/script_pa025/ECFDDEViva.exe b/ecole/vbs/script_pa025/ECFDDEViva.exe new file mode 100644 index 00000000..3abaed46 Binary files /dev/null and b/ecole/vbs/script_pa025/ECFDDEViva.exe differ diff --git a/ecole/vbs/script_pa025/attestation.vbs b/ecole/vbs/script_pa025/attestation.vbs new file mode 100644 index 00000000..70a4dfa7 --- /dev/null +++ b/ecole/vbs/script_pa025/attestation.vbs @@ -0,0 +1,24 @@ +Option Explicit +On Error Resume Next +ExempleMacroExcel + +Sub ExempleMacroExcel() + + Dim ApplicationExcel + Dim ClasseurExcel + Set ApplicationExcel = CreateObject("Excel.Application") + + Dim WshShell, strCurDir + Set WshShell = CreateObject("WScript.Shell") + strCurDir = WshShell.CurrentDirectory + + + Set ClasseurExcel = ApplicationExcel.Workbooks.Open( strCurDir & "\vbs\script_pa025\attestation.xlsm") + ApplicationExcel.Visible = False + ApplicationExcel.Run "PA025_AC800_sub" 'va lancer la macro + ApplicationExcel.Quit + + Set ClasseurExcel = Nothing + Set ApplicationExcel = Nothing + +End Sub \ No newline at end of file diff --git a/ecole/vbs/script_pa025/config/.gitkeep b/ecole/vbs/script_pa025/config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ecole/vbs/script_qt550/Bordereau.xlsm b/ecole/vbs/script_qt550/Bordereau.xlsm new file mode 100644 index 00000000..66813f58 Binary files /dev/null and b/ecole/vbs/script_qt550/Bordereau.xlsm differ diff --git a/ecole/vbs/script_qt550/ECFDDEViva.exe b/ecole/vbs/script_qt550/ECFDDEViva.exe new file mode 100644 index 00000000..3abaed46 Binary files /dev/null and b/ecole/vbs/script_qt550/ECFDDEViva.exe differ diff --git a/ecole/vbs/script_qt550/bordereau.vbs b/ecole/vbs/script_qt550/bordereau.vbs new file mode 100644 index 00000000..041d209e --- /dev/null +++ b/ecole/vbs/script_qt550/bordereau.vbs @@ -0,0 +1,24 @@ +Option Explicit +On Error Resume Next +ExempleMacroExcel + +Sub ExempleMacroExcel() + + Dim ApplicationExcel + Dim ClasseurExcel + Set ApplicationExcel = CreateObject("Excel.Application") + + Dim WshShell, strCurDir + Set WshShell = CreateObject("WScript.Shell") + strCurDir = WshShell.CurrentDirectory + + + Set ClasseurExcel = ApplicationExcel.Workbooks.Open( strCurDir & "\vbs\script_qt550\Bordereau.xlsm") + ApplicationExcel.Visible = False + ApplicationExcel.Run "QT550_sub" 'va lancer la macro + ApplicationExcel.Quit + + Set ClasseurExcel = Nothing + Set ApplicationExcel = Nothing + +End Sub \ No newline at end of file diff --git a/ecole/vbs/script_qt550/config/.gitkeep b/ecole/vbs/script_qt550/config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/ecole/views/advalo.ejs b/ecole/views/advalo.ejs new file mode 100644 index 00000000..ca8f93b5 --- /dev/null +++ b/ecole/views/advalo.ejs @@ -0,0 +1,326 @@ +
+

Advalorem

+
+
+ +
+ +
+ +
+
+
Advalorem est intégré à EasyTransport.
+

Parité V1: Hors grille (ponctuel/périodique), Facturation, Historique, Cumul, Reporting et documents.

+
+
+ + + + + + + + + + + + + + + + diff --git a/ecole/views/partials/navbar.ejs b/ecole/views/partials/navbar.ejs index 8547600d..9bfcb1f2 100644 --- a/ecole/views/partials/navbar.ejs +++ b/ecole/views/partials/navbar.ejs @@ -17,10 +17,10 @@ EasyTransport 2.0.0 -
  • Advalorem
  • +
  • Advalorem
  • Documentation
  • Liens utiles
  • Reporting
  • Admin
  • Déconnexion
  • - \ No newline at end of file +