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