'use strict' // ───────────────────────────────────────────────────────────────────────────── // F (gestionclient) → ERPNext — moteur de SYNC DELTA, mode PREVIEW. // // LECTURE SEULE : ne fait AUCUN erp.create / erp.update / écriture MySQL. // Lit le miroir local `legacy-db` (jamais le F live) + ERPNext, et calcule ce // qui SERAIT créé / mis à jour côté ERPNext, par clé legacy_* : // account → Customer (clé legacy_account_id) // delivery → Service Location (clé legacy_delivery_id) // service → Service Subscription(clé legacy_service_id) // // Détection de changement (cf. analyse 2026-06-10) : // • INSERT = id F absent côté ERPNext (par clé legacy_*). // • UPDATE = `account.date_last` (vraie date de modif, 86% des comptes) au-delà // du watermark ; en preview on compare directement les champs. // // F reste autoritaire pour la facturation : on NE synchronise PAS ici les // invoice/payment/GL/PLE (ça vit dans F). On synchronise la couche CLIENT // (comptes / adresses / services) nécessaire au funnel lead→abo→onboarding→RDV. // ───────────────────────────────────────────────────────────────────────────── const fs = require('fs') const path = require('path') const cfg = require('./config') const { json, log } = require('./helpers') const erp = require('./erp') let mysql; try { mysql = require('mysql2/promise') } catch (e) { mysql = null } const REPORT_DIR = path.join(__dirname, '..', 'data') const REPORT_FILE = path.join(REPORT_DIR, 'legacy-sync-preview.json') const STATE_FILE = path.join(REPORT_DIR, 'legacy-sync-state.json') // watermark incrémental const RUN_LOG = path.join(REPORT_DIR, 'legacy-sync-run.json') // progression de l'apply (pollable) function loadState () { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) } catch { return {} } } function saveState (s) { try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)) } catch (e) { log('legacy-sync: échec écriture state:', e.message) } } function writeRun (o) { try { fs.writeFileSync(RUN_LOG, JSON.stringify(o)) } catch (e) {} } const ERRORS_FILE = path.join(REPORT_DIR, 'legacy-sync-errors.json') // liste COMPLÈTE des erreurs d'apply + raisons function writeErrors (o) { try { fs.writeFileSync(ERRORS_FILE, JSON.stringify(o, null, 2)) } catch (e) {} } // Classe le message d'erreur ERPNext en raison courte (pour grouper/lister). function errReason (msg) { const m = String(msg || '') if (/InvalidEmailAddressError/i.test(m)) return 'email_invalide' // ex. emails multiples « a@x;b@y » if (/InvalidPhoneNumber|phone/i.test(m)) return 'téléphone_invalide' if (/LinkValidationError|Could not find|does not exist/i.test(m)) return 'lien_introuvable' if (/MandatoryError|is required|Mandatory/i.test(m)) return 'champ_obligatoire' if (/Duplicate|already exists/i.test(m)) return 'doublon' if (/socket hang up|ECONNRESET|ETIMEDOUT|timeout|EAI_AGAIN|50[234]/i.test(m)) return 'réseau' if (/Permission|not permitted|Forbidden|403/i.test(m)) return 'permission' if (/CharacterLengthExceeded|too long/i.test(m)) return 'trop_long' return 'autre' } let _pool function pool () { if (!mysql) return null if (!_pool) { _pool = mysql.createPool({ host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME, connectionLimit: 2, waitForConnections: true, connectTimeout: 8000, }) } return _pool } // ── client du pont PHP (lecture FRAÎCHE depuis F live ; le miroir peut être en retard) ── const https = require('https') const querystring = require('querystring') function bridgeUrl () { return process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php' } function bridgeToken () { try { return (process.env.OPS_LEGACY_TOKEN || fs.readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch { return (process.env.OPS_LEGACY_TOKEN || '').trim() } } function bridgeCall (params) { return new Promise((resolve, reject) => { const body = querystring.stringify(params) const u = new URL(bridgeUrl()) const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', timeout: 25000, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Ops-Token': bridgeToken(), 'Content-Length': Buffer.byteLength(body) } }, res => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve(JSON.parse(b)) } catch { reject(new Error('bridge parse: ' + String(b).slice(0, 160))) } }) }) req.on('error', reject); req.on('timeout', () => req.destroy(new Error('bridge timeout'))); req.write(body); req.end() }) } // Tire les comptes changés/nouveaux depuis F live, paginé par (date_last, id). async function fetchAccountsViaBridge (since, sinceId) { const sinceFixed = Number(since) || 0 if (sinceFixed > 0) { // INCRÉMENTAL : `date_last > since` couvre create+update ; le delta est petit → 1 page large. const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'account', since: String(sinceFixed), since_id: '0', limit: '5000' }), 'bridge delta') if (!d || !d.ok) throw new Error('bridge changes_since: ' + ((d && d.error) || 'réponse invalide')) if (d.has_more) log('legacy-sync: ⚠ delta > 5000 lignes — pagination incrémentale à étendre') return d.rows || [] } // FULL : keyset PUR sur id (since=0 → le PHP utilise `id > sinceId`) const rows = []; let sid = Number(sinceId) || 0 for (let page = 0; page < 100; page++) { const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'account', since: '0', since_id: String(sid), limit: '1000' }), 'bridge full') if (!d || !d.ok) throw new Error('bridge full: ' + ((d && d.error) || 'réponse invalide')) rows.push(...(d.rows || [])) if (!d.has_more) break const next = Number(d.max_id) || 0 if (next <= sid) break // garde-fou anti-boucle sid = next } return rows } // Services F (statut frais) — keyset pur sur id (service n'a pas de date de modif → scan complet périodique). async function fetchServicesViaBridge () { const rows = []; let sid = 0 for (let page = 0; page < 200; page++) { const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'service', since: '0', since_id: String(sid), limit: '2000' }), 'bridge services') if (!d || !d.ok) throw new Error('bridge service: ' + ((d && d.error) || 'réponse invalide')) rows.push(...(d.rows || [])) if (!d.has_more) break const next = Number(d.max_id) || 0; if (next <= sid) break; sid = next } return rows } // F service.status → statut Service Subscription. CORRIGÉ 2026-06-13 : dans F, **status=1 = ACTIF**, point. // `date_suspended` n'est que l'HISTORIQUE d'une ancienne suspension (service réactivé) → l'IGNORER quand status=1. // (L'ancien code `status==1 && date_suspended>0 → Suspendu` excluait à tort les services réactivés du total : // prouvé sur Schink #38243 et Marianne #31841 → totaux F restaurés. Cf. feedback_billing_invariants.) function svcStatus (s, dateSusp) { if (Number(s) === 1) return 'Actif' // F status=1 = ACTIF ; date_suspended ignoré (historique d'une suspension passée) return 'Annulé' // status=0 = exclu du total (suspendu/annulé) — on garde 'Annulé' comme avant pour limiter le blast-radius aux 177 corrections de facturation } // ── helpers de normalisation ──────────────────────────────────────────────── function clean (v) { if (v == null) return '' let s = String(v) s = s.replace(/&/g, '&').replace(/�?39;|'/g, "'").replace(/"/g, '"') .replace(/</g, '<').replace(/>/g, '>') .replace(/�?(\d+);/g, (_, n) => { try { return String.fromCharCode(+n) } catch { return _ } }) return s.trim() } function digits (v) { return String(v == null ? '' : v).replace(/\D/g, '') } // F met parfois PLUSIEURS emails dans un champ (couple/entreprise/partenaires qui suivent la facturation), // séparés par ; ou ,. ERPNext.email_id n'accepte qu'UN email → on garde le 1er valide comme principal // et la LISTE COMPLÈTE va dans email_billing (CC facturation, emails secondaires importés). function emailList (v) { return clean(v).toLowerCase().split(/[;,]/).map(s => s.trim()).filter(s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)) } function firstEmail (v) { return emailList(v)[0] || '' } const GROUP_MAP = { 1: 'Individual', 4: 'Commercial', 5: 'Individual', 6: 'Individual', 7: 'Individual', 8: 'Commercial', 9: 'Government', 10: 'Non Profit' } // account (F) → sous-ensemble HAUTE-CONFIANCE des champs Customer (ceux que la // migration peuple de façon déterministe). L'enrichissement plus fin viendra plus tard. function mapAccount (a) { const first = clean(a.first_name), last = clean(a.last_name), company = clean(a.company) const isCompany = !!company return { legacy_account_id: Number(a.id), customer_name: isCompany ? company : (`${first} ${last}`.trim() || `Client-${a.id}`), customer_type: isCompany ? 'Company' : 'Individual', customer_group: GROUP_MAP[a.group_id] || 'Individual', language: a.language_id === 'francais' ? 'fr' : 'en', disabled: [1, 2].includes(Number(a.status)) ? 0 : 1, // actif/suspendu = visible ; résilié (3,4,5) = désactivé is_suspended: Number(a.status) === 2 ? 1 : 0, email_id: firstEmail(a.email) || null, email_billing: (() => { const e = emailList(a.email); return e.length > 1 ? e.join(';') : null })(), // emails secondaires (couple/entreprise/partenaires) → CC facturation tel_home: digits(a.tel_home) || null, cell_phone: digits(a.cell) || null, stripe_id: clean(a.stripe_id) || null, ppa_enabled: Number(a.ppa) ? 1 : 0, // pont PHP renvoie "0"/"1" (string) → Number() requis ("0" est truthy en JS) is_commercial: Number(a.commercial) ? 1 : 0, is_bad_payer: Number(a.mauvais_payeur) ? 1 : 0, mandataire: clean(a.mandataire) || null, // représentant autorisé (titulaire ≠ payeur/écrivain) — recherchable contact_name_legacy: clean(a.contact) || null, // personne-contact détaillée _status: Number(a.status), } } // Statut de compte GLOBAL F par id (account.status : 1=actif, 2=suspendu, 3/4/5=résilié). PK indexée → batch rapide/sûr. const F_ACCT_STATUS_LABEL = { 1: 'Actif', 2: 'Suspendu', 3: 'Résilié', 4: 'Résilié', 5: 'Résilié' } async function fAccountStatuses (ids) { ids = [...new Set((ids || []).map(Number).filter(Boolean))] if (!ids.length) return {} const p = pool(); if (!p) return {} const out = {} for (let i = 0; i < ids.length; i += 500) { const b = ids.slice(i, i + 500) const [rows] = await p.query({ sql: 'SELECT id, status FROM account WHERE id IN (' + b.map(() => '?').join(',') + ')', timeout: 6000 }, b) for (const r of rows) out[Number(r.id)] = Number(r.status) } return out } // IDs des comptes F RÉSILIÉS (status 3/4/5). Scan léger d'account (~17k lignes). async function fResiliatedAccountIds () { const p = pool(); if (!p) return [] const [rows] = await p.query({ sql: 'SELECT id FROM account WHERE status IN (3,4,5)', timeout: 10000 }) return rows.map(r => Number(r.id)) } // Champs texte : politique ADD / CHANGE / NEVER-CLOBBER (cf. preview 2026-06-11). const TEXT_FIELDS = ['customer_name', 'customer_type', 'customer_group', 'language', 'email_id', 'email_billing', 'tel_home', 'cell_phone', 'stripe_id', 'mandataire', 'contact_name_legacy'] // Drapeaux booléens SYNCHRONISÉS (F autoritaire, vont dans « à réviser »). const BOOL_FIELDS = ['ppa_enabled', 'is_commercial', 'is_bad_payer'] // Statut client = DÉRIVÉ des abonnements (Service Subscription) — affiché en divergence // mais JAMAIS synchronisé/flippé depuis F (cf. feedback_customer_status_policy : pas de faux « churn », // le client reste vendable/cross-sell). Fetché pour l'info, hors review/sync. const STATUS_FIELDS = ['disabled', 'is_suspended'] const CMP = [...TEXT_FIELDS, ...BOOL_FIELDS, ...STATUS_FIELDS] const PHONE_FIELDS = new Set(['tel_home', 'cell_phone']) function normField (field, v) { if (v == null) return '' if (PHONE_FIELDS.has(field)) return digits(v) if (field === 'email_id') return String(v).trim().toLowerCase() if (typeof v === 'number') return String(v) return String(v).trim() } function isEmpty (v) { return v == null || String(v).trim() === '' } // Retry sur erreurs réseau transitoires (socket hang up, ECONNRESET, timeouts, 5xx) — « non bloquant si surcharge ». async function withRetry (fn, label, tries = 5) { let last for (let i = 0; i < tries; i++) { try { return await fn() } catch (e) { last = e const transient = /socket hang up|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EPIPE|network|timeout|50[234]/i.test(e.message || '') if (!transient || i === tries - 1) throw e await new Promise(r => setTimeout(r, 500 * (i + 1))) log(`legacy-sync retry ${label} #${i + 1}: ${e.message}`) } } throw last } // ── chargement de l'index ERPNext (paginé) ────────────────────────────────── async function loadErpIndex (doctype, key, fields) { const idx = new Map() let start = 0; const lim = 1000; let available = null for (;;) { const rows = await withRetry(() => erp.list(doctype, { filters: [[key, '>', 0]], fields: ['name', key, ...fields], limit: lim, start }), `list ${doctype} @${start}`) if (!rows || !rows.length) break if (available === null) available = fields.filter(f => f in rows[0]) // v16 PG peut bloquer des champs for (const r of rows) idx.set(Number(r[key]), r) if (rows.length < lim) break start += lim } return { idx, available: available || fields } } // Fetch ciblé (mode incrémental) : ne charge QUE les enregistrements ERPNext dont la clé change. async function loadErpByIds (doctype, key, fields, ids) { const idx = new Map(); let available = null for (let i = 0; i < ids.length; i += 100) { const batch = ids.slice(i, i + 100) if (!batch.length) continue const rows = await withRetry(() => erp.list(doctype, { filters: [[key, 'in', batch]], fields: ['name', key, ...fields], limit: 500 }), `list ${doctype} in-batch`) if (available === null && rows && rows[0]) available = fields.filter(f => f in rows[0]) for (const r of (rows || [])) idx.set(Number(r[key]), r) } return { idx, available: available || fields } } // ── PREVIEW : comptes → Customer ───────────────────────────────────────────── function bump (obj, k) { obj[k] = (obj[k] || 0) + 1 } async function previewCustomers ({ sinceDateLast = 0, sinceId = 0, source = 'mirror' } = {}) { let rows if (source === 'bridge') { rows = await fetchAccountsViaBridge(sinceDateLast, sinceId) // lecture FRAÎCHE de F live (miroir périmé contourné) } else { const p = pool(); if (!p) throw new Error('miroir legacy-db indisponible (mysql2 absent)') const where = Number(sinceDateLast) > 0 ? ` WHERE date_last > ${Number(sinceDateLast)}` : '' ;[rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa, date_last FROM account' + where) } const maxDateLast = rows.reduce((m, r) => Math.max(m, Number(r.date_last) || 0), Number(sinceDateLast) || 0) const maxId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), Number(sinceId) || 0) // Index ERPNext : ciblé si jeu borné (bridge/incrémental & ≤3000), sinon index complet. const targeted = (source === 'bridge' || Number(sinceDateLast) > 0) && rows.length <= 3000 const { idx: erpIdx, available } = targeted ? await loadErpByIds('Customer', 'legacy_account_id', CMP, rows.map(r => Number(r.id))) : await loadErpIndex('Customer', 'legacy_account_id', CMP) const fullIndexLoaded = !targeted const textF = TEXT_FIELDS.filter(f => available.includes(f)) const boolF = BOOL_FIELDS.filter(f => available.includes(f)) const hasDisabled = available.includes('disabled') const out = { f_total: rows.length, erp_total: erpIdx.size, compared_text: textF, compared_bool: boolF, dropped: CMP.filter(f => !available.includes(f)), creates: [], safe_add: [], needs_review: [], unchanged: 0, add_by_field: {}, review_by_field: {}, never_clobber_by_field: {}, status_divergence: { f_active_erp_disabled: 0, f_inactive_erp_enabled: 0, samples: [] }, erp_only: [], } for (const a of rows) { const m = mapAccount(a) const ex = erpIdx.get(Number(a.id)) if (!ex) { out.creates.push({ legacy_account_id: m.legacy_account_id, customer_name: m.customer_name, status: m._status }); continue } const add = [], change = [], skipNull = [] // champs texte → ADD (ERPNext vide) / CHANGE (les deux diffèrent) / NEVER-CLOBBER (F vide) for (const f of textF) { if (normField(f, m[f]) === normField(f, ex[f])) continue const fEmpty = isEmpty(m[f]), eEmpty = isEmpty(ex[f]) if (fEmpty && !eEmpty) { skipNull.push({ field: f, erp: ex[f] }); bump(out.never_clobber_by_field, f) } else if (eEmpty && !fEmpty) { add.push({ field: f, to: m[f] }); bump(out.add_by_field, f) } else { change.push({ field: f, from: ex[f], to: m[f] }); bump(out.review_by_field, f) } } // drapeaux booléens → toujours review (jamais d'auto-bascule) for (const f of boolF) { if (normField(f, m[f]) !== normField(f, ex[f])) { change.push({ field: f, from: ex[f], to: m[f] }); bump(out.review_by_field, f) } } // disabled : divergence de statut (NON synchronisée — ERPNext la dérive des abonnements) if (hasDisabled && normField('disabled', m.disabled) !== normField('disabled', ex.disabled)) { if (m.disabled === 0 && ex.disabled === 1) out.status_divergence.f_active_erp_disabled++ else if (m.disabled === 1 && ex.disabled === 0) out.status_divergence.f_inactive_erp_enabled++ if (out.status_divergence.samples.length < 30) out.status_divergence.samples.push({ legacy_account_id: m.legacy_account_id, customer_name: m.customer_name, f_status: m._status, erp_disabled: ex.disabled }) } const rec = { legacy_account_id: m.legacy_account_id, name: ex.name, customer_name: m.customer_name } if (change.length) { rec.change = change; if (add.length) rec.add = add; if (skipNull.length) rec.skip_null = skipNull; out.needs_review.push(rec) } else if (add.length) { rec.add = add; if (skipNull.length) rec.skip_null = skipNull; out.safe_add.push(rec) } else out.unchanged++ } // erp_only n'a de sens qu'en scan complet (sinon on n'a chargé qu'un sous-ensemble) if (fullIndexLoaded) { const fIds = new Set(rows.map(r => Number(r.id))); out.erp_only = [...erpIdx.keys()].filter(id => !fIds.has(id)) } out.incremental = Number(sinceDateLast) > 0 || source === 'bridge' out.source = source out.since_date_last = Number(sinceDateLast) || 0 out.max_date_last = maxDateLast out.max_id = maxId return out } // ── PREVIEW générique INSERT-only (delivery / service) ─────────────────────── async function previewInsertsOnly ({ table, idCol = 'id', doctype, key, label, where = '' }) { const p = pool(); if (!p) throw new Error('miroir indisponible') const [rows] = await p.query(`SELECT ${idCol} AS id FROM \`${table}\`${where ? ` WHERE ${where}` : ''}`) const { idx } = await loadErpIndex(doctype, key, []) const sample = []; let would = 0 for (const r of rows) { if (!idx.has(Number(r.id))) { would++; if (sample.length < 30) sample.push(Number(r.id)) } } return { label, where: where || '(toutes)', f_total: rows.length, erp_total: idx.size, would_create: would, sample } } // ── orchestrateur preview ──────────────────────────────────────────────────── async function runPreview ({ persist = true } = {}) { const startedAt = new Date().toISOString() const customers = await previewCustomers() const locations = await previewInsertsOnly({ table: 'delivery', doctype: 'Service Location', key: 'legacy_delivery_id', label: 'delivery→Service Location' }) const services = await previewInsertsOnly({ table: 'service', doctype: 'Service Subscription', key: 'legacy_service_id', label: 'service ACTIF→Service Subscription', where: 'status=1' }) const c = customers const report = { mode: 'PREVIEW (lecture seule — aucune écriture ni dans F ni dans ERPNext)', started_at: startedAt, customers: { summary: { f_total: c.f_total, erp_total: c.erp_total, would_create: c.creates.length, safe_add: c.safe_add.length, // ⇐ enrichissement SÛR : remplir des champs vides (email/tél…) needs_review: c.needs_review.length, // ⇐ valeur divergente → révision humaine avant écriture unchanged: c.unchanged, erp_only_not_in_F: c.erp_only.length, }, add_by_field: c.add_by_field, // ce qui serait REMPLI (sûr) review_by_field: c.review_by_field, // ce qui DIVERGE (à réviser) never_clobber_by_field: c.never_clobber_by_field, // F vide → JAMAIS écrasé (ex. stripe_id) status_divergence: { f_active_erp_disabled: c.status_divergence.f_active_erp_disabled, f_inactive_erp_enabled: c.status_divergence.f_inactive_erp_enabled, samples: c.status_divergence.samples.slice(0, 15) }, compared_text: c.compared_text, compared_bool: c.compared_bool, dropped: c.dropped, creates_sample: c.creates.slice(0, 30), safe_add_sample: c.safe_add.slice(0, 15), needs_review_sample: c.needs_review.slice(0, 20), erp_only_sample: c.erp_only.slice(0, 30), }, locations, services, finished_at: new Date().toISOString(), } // rapport COMPLET (toutes les listes) à part, pour révision/itération const full = { started_at: startedAt, customers_creates: c.creates, customers_safe_add: c.safe_add, customers_needs_review: c.needs_review, customers_erp_only: c.erp_only, status_divergence_samples: c.status_divergence.samples } if (persist) { try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }) fs.writeFileSync(REPORT_FILE, JSON.stringify({ report, full }, null, 2)) report.saved_to = REPORT_FILE } catch (e) { log('legacy-sync: échec écriture rapport:', e.message) } } return report } // Écrit un patch de champs Customer via le POOL PG (PAS erp.update : ERPNext re-dérive // email_id/tél du Contact lié → renvoie ok mais NO-OP silencieux ; vérifié 2026-06-12). // fieldnames = allowlist fixe TEXT_FIELDS+BOOL_FIELDS (pas d'injection). Never-clobber par du vide. async function writeCustomerPatch (name, patch) { const SYNCABLE = new Set([...TEXT_FIELDS, ...BOOL_FIELDS]) const keys = Object.keys(patch).filter(f => SYNCABLE.has(f) && patch[f] != null && String(patch[f]) !== '') if (!keys.length) return { ok: true, fields: 0 } const pgp = require('./address-db').pool() const sets = keys.map((f, i) => `"${f}"=$${i + 1}`).join(', ') await pgp.query(`UPDATE "tabCustomer" SET ${sets}, modified=NOW() WHERE name=$${keys.length + 1}`, [...keys.map(f => patch[f]), name]) return { ok: true, fields: keys.length } } // ── APPLY (mode SAFE-ADD uniquement) — dry-run par défaut, écriture gardée ──── // N'applique QUE le bucket safe_add : remplir des champs ERPNext VIDES avec la // valeur de F (email/tél/stripe). Écrit via le pool PG. Ne touche aucun autre champ. // N'écrit JAMAIS sans confirm==='SAFE-ADD'. Ne touche pas needs_review ni disabled. const { parseBody } = require('./helpers') async function applySafeAdds ({ confirm = false, limit = 0 } = {}) { const c = await previewCustomers() const targets = limit ? c.safe_add.slice(0, limit) : c.safe_add if (confirm !== 'SAFE-ADD') { return { dry_run: true, eligible: c.safe_add.length, would_update: targets.length, sample: targets.slice(0, 10).map(t => ({ name: t.name, add: t.add })) } } let ok = 0, err = 0; const errors = [] for (const t of targets) { const patch = {}; for (const a of t.add) patch[a.field] = a.to try { const r = await writeCustomerPatch(t.name, patch) if (r && r.ok !== false) ok++; else { err++; if (errors.length < 20) errors.push({ name: t.name, err: (r && r.error) || 'update failed' }) } } catch (e) { err++; if (errors.length < 20) errors.push({ name: t.name, err: e.message }) } } log(`legacy-sync apply SAFE-ADD: ${ok} ok, ${err} err`) return { applied: ok, errors: err, error_samples: errors } } // ── SYNC INCRÉMENTAL F→OPS (watermark date_last) — ERPNext = test, F autoritaire ── // Applique ADD + CHANGE (F gagne) sur les Customers EXISTANTS. PUT partiel → ne touche // QUE les champs mappés (l'enrichissement ERPNext-only : geocoding/fibre/AQ reste intact). // JAMAIS `disabled` (dérivé des abonnements), JAMAIS écraser une valeur par du vide. // Les CRÉATIONS (nouveaux comptes F) sont comptées mais gérées par un step dédié. // dry-run sauf confirm==='F-WINS'. Avance le watermark seulement après application. async function syncCustomers ({ confirm = false, full = false, source = 'bridge' } = {}) { const st = loadState() const since = full ? 0 : (Number(st.account_date_last) || 0) const sinceId = full ? 0 : (Number(st.account_max_id) || 0) const c = await previewCustomers({ sinceDateLast: since, sinceId, source }) const plan = { mode: (since || sinceId) ? 'incrémental' : 'complet', source, since_date_last: since, since_id: sinceId, scanned: c.f_total, would_create: c.creates.length, would_update_safe: c.safe_add.length, would_update_review: c.needs_review.length, max_date_last: c.max_date_last, max_id: c.max_id } if (confirm !== 'F-WINS') return { dry_run: true, ...plan, note: "POST { confirm:'F-WINS' } pour appliquer les MAJ. full:true = ré-scan complet. source:'mirror' = lire le miroir au lieu de F live." } // « sans briser rien » : on ne RÉGRESSE pas des champs enrichis côté ERPNext. // customer_name = nettoyé à la migration (accents/composés, tâche #12) → on ne l'écrase // jamais par le brut de F. (Il n'apparaît que dans CHANGE, jamais dans ADD.) const PROTECT = new Set(['customer_name']) let ok = 0, err = 0, protectedSkips = 0; const errors = [] const apply = async (rec) => { const patch = {} for (const a of (rec.add || [])) patch[a.field] = a.to for (const d of (rec.change || [])) { if (PROTECT.has(d.field)) { protectedSkips++; continue } patch[d.field] = d.to } if (!Object.keys(patch).length) return const fields = Object.keys(patch) const fail = (msg) => { err++; errors.push({ name: rec.name, legacy_account_id: rec.legacy_account_id, customer_name: rec.customer_name, fields, reason: errReason(msg), err: String(msg || 'fail').slice(0, 300) }) } try { const r = await writeCustomerPatch(rec.name, patch); if (r && r.ok !== false) ok++; else fail(r && r.error) } catch (e) { fail(e.message) } } const all = [...c.safe_add, ...c.needs_review] const total = all.length; let done = 0 writeRun({ status: 'running', mode: plan.mode, total, done: 0, ok: 0, err: 0, started_at: new Date().toISOString() }) for (const rec of all) { await apply(rec); done++ if (done % 50 === 0) writeRun({ status: 'running', mode: plan.mode, total, done, ok, err }) if (done % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle : laisse ERPNext respirer (ne pas saturer l'UI dispatch) } const byReason = {}; for (const e of errors) byReason[e.reason] = (byReason[e.reason] || 0) + 1 writeErrors({ ts: new Date().toISOString(), mode: plan.mode, applied: ok, total_errors: errors.length, by_reason: byReason, errors }) st.account_date_last = Math.max(Number(st.account_date_last) || 0, c.max_date_last || 0) st.account_max_id = Math.max(Number(st.account_max_id) || 0, c.max_id || 0) st.last_run = new Date().toISOString(); saveState(st) writeRun({ status: 'done', mode: plan.mode, total, done, ok, err, errors_by_reason: byReason, protected_name_skips: protectedSkips, watermark: st.account_date_last, creates_deferred: c.creates.length, finished_at: new Date().toISOString() }) log(`legacy-sync syncCustomers (${plan.mode}): ${ok} maj, ${err} err ${JSON.stringify(byReason)}, watermark→${c.max_date_last}`) return { ...plan, applied: ok, errors: err, errors_by_reason: byReason, protected_name_skips: protectedSkips, error_samples: errors.slice(0, 20), watermark: st.account_date_last, creates_deferred: c.creates.length } } // ── SYNC STATUT DE SERVICE (subscription) F→ERPNext — F autoritaire ────────── // service n'a pas de date de modif → SCAN COMPLET (statut peut changer sans changer l'id). // Met à jour le statut des Service Subscription EXISTANTes (par legacy_service_id) : Actif/Suspendu/Annulé. // dry-run sauf confirm==='F-WINS'. (Créations des services F manquants = step séparé.) async function syncServices ({ confirm = false } = {}) { const fsvc = await fetchServicesViaBridge() const want = new Map() for (const s of fsvc) want.set(Number(s.id), svcStatus(s.status, s.date_suspended)) // index ERPNext Service Subscription (legacy_service_id → {name,status}) const idx = new Map(); let start = 0 for (;;) { const r = await withRetry(() => erp.list('Service Subscription', { filters: [['legacy_service_id', '>', 0]], fields: ['name', 'legacy_service_id', 'status'], limit: 1000, start }), 'list SS') if (!r || !r.length) break for (const x of r) idx.set(Number(x.legacy_service_id), x) if (r.length < 1000) break start += 1000 } const changes = [] for (const [lid, sub] of idx) { const w = want.get(lid); if (w && w !== sub.status) changes.push({ name: sub.name, from: sub.status, to: w, lid }) } const byTrans = {}; for (const c of changes) { const k = (c.from || '∅') + '→' + c.to; byTrans[k] = (byTrans[k] || 0) + 1 } const plan = { f_services: fsvc.length, erp_subs: idx.size, would_update: changes.length, by_transition: byTrans } if (confirm !== 'F-WINS') return { dry_run: true, ...plan, sample: changes.slice(0, 20) } let ok = 0, err = 0; const errs = [] let i = 0 for (const c of changes) { try { const r = await withRetry(() => erp.update('Service Subscription', c.name, { status: c.to }), 'svc ' + c.name); if (r && r.ok !== false) ok++; else { err++; if (errs.length < 20) errs.push({ name: c.name, err: (r && r.error) || 'fail' }) } } catch (e) { err++; if (errs.length < 20) errs.push({ name: c.name, err: e.message }) } if (++i % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle } log(`legacy-sync syncServices: ${ok} maj statut, ${err} err ${JSON.stringify(byTrans)}`) return { ...plan, applied: ok, errors: err, error_samples: errs } } // ── CORRECTION : abonnements « fantômes » sur comptes F RÉSILIÉS ───────────── // Bug : F résilie au niveau COMPTE (account.status 3/4/5) mais laisse service.status=1 → le // mapping svcStatus (basé sur service.status seul) garde l'abonnement 'Actif'. Ici on force // 'Annulé' pour tout abonnement 'Actif' dont le client a un legacy_account_id RÉSILIÉ. // Robuste (indépendant du bridge : fResiliatedAccountIds + jointure PG). Idempotent → réexécutable // périodiquement (fix « forward »). dry-run sauf confirm==='F-WINS'. Cf feedback_ghost_active_subscriptions. async function annulResiliatedSubscriptions ({ confirm = false, limit = 20000 } = {}) { const resil = await fResiliatedAccountIds() if (!resil || !resil.length) return { resiliated_accounts: 0, would_update: 0 } const pg = require('./address-db').pool() const rows = [] for (let i = 0; i < resil.length; i += 2000) { const b = resil.slice(i, i + 2000) const r = await pg.query('SELECT ss.name, ss.customer, ss.monthly_price FROM "tabService Subscription" ss JOIN "tabCustomer" c ON ss.customer=c.name WHERE ss.status=$1 AND c.legacy_account_id = ANY($2)', ['Actif', b]) rows.push(...r.rows) } const plan = { resiliated_accounts: resil.length, would_update: rows.length, monthly_sum: Math.round(rows.reduce((s, x) => s + (Number(x.monthly_price) || 0), 0)) } if (confirm !== 'F-WINS') return { dry_run: true, ...plan, sample: rows.slice(0, 15).map(x => ({ name: x.name, customer: x.customer })) } let ok = 0; let err = 0; const errs = []; let i = 0 for (const x of rows.slice(0, limit)) { try { const r = await withRetry(() => erp.update('Service Subscription', x.name, { status: 'Annulé' }), 'annul ' + x.name); if (r && r.ok !== false) ok++; else { err++; if (errs.length < 15) errs.push({ name: x.name, err: (r && r.error) || 'fail' }) } } catch (e) { err++; if (errs.length < 15) errs.push({ name: x.name, err: e.message }) } if (++i % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle } log(`legacy-sync annulResiliatedSubscriptions: ${ok} annulés, ${err} err`) return { ...plan, applied: ok, errors: err, error_samples: errs } } // ── Tableau de bord de réconciliation : F (mysql) vs ERPNext (pg), par entité ── // Chaque compte est indépendant (try/catch → null si table absente) pour ne jamais casser la vue. async function scoreboard () { const pg = require('./address-db').pool() const pgc = async (sql, params) => { try { return Number((await pg.query(sql, params)).rows[0].c) } catch (e) { return null } } const mp = pool() const myc = async (sql) => { if (!mp) return null; try { const [r] = await mp.query({ sql, timeout: 8000 }); return Number(r[0].c) } catch (e) { return null } } let resil = []; try { resil = await fResiliatedAccountIds() } catch (e) {} const [fAcct, fSvcActive, fDelivery, fTicket, fInvoice, fPayment, fDevice, eCust, eSubActif, eLoc, eIssue, eInv, ePay, eDevice, ghost] = await Promise.all([ myc('SELECT COUNT(*) c FROM account'), // F actif = service.status=1 BRUT (ce que F croit actif, fantômes F-non-cascadés inclus) ; l'écart positif = fantômes avérés qu'ERPNext a retirés (raffinage 2026-07-06) myc('SELECT COUNT(*) c FROM service WHERE status=1'), myc('SELECT COUNT(*) c FROM delivery'), myc('SELECT COUNT(*) c FROM ticket'), myc('SELECT COUNT(*) c FROM invoice'), myc('SELECT COUNT(*) c FROM payment'), myc('SELECT COUNT(*) c FROM device'), pgc('SELECT COUNT(*) c FROM "tabCustomer"'), pgc('SELECT COUNT(*) c FROM "tabService Subscription" WHERE status=$1', ['Actif']), pgc('SELECT COUNT(*) c FROM "tabService Location"'), pgc('SELECT COUNT(*) c FROM "tabIssue"'), pgc('SELECT COUNT(*) c FROM "tabSales Invoice"'), pgc('SELECT COUNT(*) c FROM "tabPayment Entry"'), pgc('SELECT COUNT(*) c FROM "tabService Equipment"'), resil.length ? pgc('SELECT COUNT(*) c FROM "tabService Subscription" ss JOIN "tabCustomer" c ON ss.customer=c.name WHERE ss.status=$1 AND c.legacy_account_id = ANY($2)', ['Actif', resil]) : 0, ]) const row = (key, label, f, erp, note) => ({ key, label, f, erp, delta: (f != null && erp != null) ? f - erp : null, note: note || '' }) return { generated_at: new Date().toISOString(), resiliated_f_accounts: resil.length, ghost_active_subscriptions: ghost, entities: [ row('customers', 'Clients', fAcct, eCust, 'F account ↔ Customer'), row('locations', 'Lieux de service', fDelivery, eLoc, 'F delivery ↔ Service Location'), row('subscriptions', 'Abonnements actifs', fSvcActive, eSubActif, `écart = fantômes avérés retirés d'ERPNext (payants, résiliés, F silencieux >24 mois) ; ${ghost} actifs sur compte résilié restants à revoir`), row('devices', 'Appareils', fDevice, eDevice, 'F device ↔ Service Equipment (liens orphelins possibles)'), row('tickets', 'Tickets', fTicket, eIssue, 'F ticket ↔ Issue (import à réparer)'), row('invoices', 'Factures', fInvoice, eInv, 'F invoice ↔ Sales Invoice'), row('payments', 'Paiements', fPayment, ePay, 'F payment ↔ Payment Entry'), ], } } // ── CRÉATEUR DE CLIENTS CANONIQUE (utilisé par billing + orchestrateur) ────── // Crée les Customers ERPNext manquants depuis F (par legacy_account_id) via erp.create // (REST → naming/validations/defaults gérés). Idempotent. POLITIQUE : créé VISIBLE // (disabled=0) — le statut actif/inactif est DÉRIVÉ des abonnements, jamais imposé par F // (cf. feedback_customer_status_policy : pas de faux « churn », client reste vendable/cross-sell). async function createCustomersByIds ({ ids = [], confirm = false, limit = 2000 } = {}) { ids = [...new Set((ids || []).map(Number).filter(Boolean))] if (!ids.length) return { requested: 0, missing: 0, created: 0 } // existence via le pool PG (ANY paramétré) — pas d'erp.list 'in' (URL trop longue pour de gros lots) const pgp = require('./address-db').pool() const have = new Set() for (let i = 0; i < ids.length; i += 2000) { const b = ids.slice(i, i + 2000) const r = await pgp.query('SELECT legacy_account_id FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b]) for (const x of r.rows) have.add(Number(x.legacy_account_id)) } const missing = ids.filter(id => !have.has(id)).slice(0, limit) if (!missing.length) return { requested: ids.length, missing: 0, created: 0 } const p = pool(); if (!p) throw new Error('F indisponible (mysql2 absent)') const accts = [] for (let i = 0; i < missing.length; i += 1000) { const b = missing.slice(i, i + 1000) const [rows] = await p.query(`SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa FROM account WHERE id IN (${b.map(() => '?').join(',')})`, b) accts.push(...rows) } if (confirm !== 'F-WINS') return { dry_run: true, requested: ids.length, missing: missing.length, would_create: accts.length, sample: accts.slice(0, 8).map(a => ({ legacy_account_id: Number(a.id), customer_name: mapAccount(a).customer_name })) } let ok = 0, err = 0; const errors = [] for (const a of accts) { const m = mapAccount(a) const doc = { customer_name: m.customer_name, customer_type: m.customer_type, customer_group: m.customer_group, legacy_account_id: m.legacy_account_id, disabled: 0 } // créé visible (politique de statut) if (m.email_id) doc.email_id = m.email_id if (m.cell_phone) doc.mobile_no = m.cell_phone try { const r = await withRetry(() => erp.create('Customer', doc), 'create cust'); if (r && r.ok !== false) ok++; else { err++; if (errors.length < 15) errors.push({ legacy_account_id: m.legacy_account_id, err: (r && r.error) || 'fail' }) } } catch (e) { err++; if (errors.length < 15) errors.push({ legacy_account_id: m.legacy_account_id, err: String(e.message).slice(0, 160) }) } if ((ok + err) % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle } log(`legacy-sync createCustomersByIds: ${ok} créés, ${err} err (sur ${missing.length} manquants)`) return { requested: ids.length, missing: missing.length, created: ok, errors: err, error_samples: errors } } // Applique les changements RÉVISÉS EN LOT depuis le dernier rapport (REPORT_FILE.full.customers_needs_review). // field précisé → n'applique QUE ce champ (ex. tous les email_id) ; sinon TOUS les champs divergents (sauf PROTECT). // Écrit via writeCustomerPatch (pool PG). Idempotent. dry-run sauf confirm==='F-WINS'. async function applyReviewBatch ({ field = null, confirm = false, limit = 0 } = {}) { let saved try { saved = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8')).full } catch { throw new Error('aucun rapport — lancer /legacy-sync/preview d\'abord') } const PROTECT = new Set(['customer_name']) let items = (saved.customers_needs_review || []).filter(r => Array.isArray(r.change) && r.change.length) if (field) items = items.filter(r => r.change.some(d => d.field === field && !PROTECT.has(d.field))) if (limit) items = items.slice(0, limit) // décompte par champ (pour le détail) const byField = {} for (const r of items) for (const d of r.change) { if (PROTECT.has(d.field)) continue; if (field && d.field !== field) continue; byField[d.field] = (byField[d.field] || 0) + 1 } if (confirm !== 'F-WINS') return { dry_run: true, field: field || 'tous', would_apply: items.length, by_field: byField, sample: items.slice(0, 10).map(r => ({ customer_name: r.customer_name, legacy_account_id: r.legacy_account_id, change: r.change.filter(d => !PROTECT.has(d.field) && (!field || d.field === field)) })) } let ok = 0, err = 0, protectedSkips = 0; const errors = [] for (const r of items) { const patch = {} for (const d of r.change) { if (PROTECT.has(d.field)) { protectedSkips++; continue } if (field && d.field !== field) continue; patch[d.field] = d.to } if (!Object.keys(patch).length) continue try { await writeCustomerPatch(r.name, patch); ok++ } catch (e) { err++; if (errors.length < 20) errors.push({ name: r.name, err: String(e.message).slice(0, 160) }) } } log(`legacy-sync applyReviewBatch (${field || 'tous'}): ${ok} appliqués, ${err} err`) return { applied: ok, errors: err, field: field || 'tous', by_field: byField, protected_name_skips: protectedSkips, error_samples: errors } } // Crée TOUS les nouveaux comptes F (bucket `creates` de la preview) — pour l'orchestrateur « clients ». async function createCustomers ({ confirm = false, limit = 1000 } = {}) { const c = await previewCustomers() return await createCustomersByIds({ ids: c.creates.map(x => x.legacy_account_id), confirm, limit }) } // Appliquer un changement RÉVISÉ d'UN compte (F gagne, ciblé). Ex. email hotmail→gmail. // Re-dérive les valeurs F (jamais la valeur stale de l'UI), n'applique QUE les champs divergents, // JAMAIS customer_name (protégé) ni disabled/is_suspended (dérivés), JAMAIS écraser par du vide. async function applyAccountChange ({ legacy_account_id, fields = null, confirm = false } = {}) { legacy_account_id = Number(legacy_account_id) if (!legacy_account_id) throw new Error('legacy_account_id requis') const p = pool(); if (!p) throw new Error('F indisponible') const [rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa FROM account WHERE id=?', [legacy_account_id]) if (!rows.length) throw new Error('compte F introuvable') const m = mapAccount(rows[0]) const PROTECT = new Set(['customer_name']) // nettoyé à la migration, jamais réécrit par le brut F const SYNCABLE = [...TEXT_FIELDS, ...BOOL_FIELDS] // exclut STATUS_FIELDS (disabled/is_suspended = dérivés) // Écriture via le pool PG direct (comme tout le reste de la sync) — erp.update (REST) ne persiste pas // email_id/tel (ERPNext re-dérive du Contact lié). Les fieldnames sont une allowlist fixe (pas d'injection). const pgp = require('./address-db').pool() const cols = SYNCABLE.map(f => `"${f}"`).join(', ') const exRows = (await pgp.query(`SELECT name, ${cols} FROM "tabCustomer" WHERE legacy_account_id=$1 LIMIT 1`, [legacy_account_id])).rows if (!exRows.length) throw new Error('Customer ERPNext introuvable') const ex = exRows[0] const cand = (fields && fields.length) ? fields : SYNCABLE const patch = {} for (const f of cand) { if (PROTECT.has(f) || !SYNCABLE.includes(f)) continue if (normField(f, m[f]) !== normField(f, ex[f]) && !isEmpty(m[f])) patch[f] = m[f] // F gagne, jamais par du vide } const keys = Object.keys(patch) if (!keys.length) return { applied: 0, name: ex.name, note: 'aucun changement applicable (ou déjà à jour)' } if (confirm !== 'F-WINS') return { dry_run: true, name: ex.name, patch } await writeCustomerPatch(ex.name, patch) // helper partagé (pool PG) log(`legacy-sync applyAccountChange #${legacy_account_id}: ${keys.join(',')}`) return { applied: keys.length, name: ex.name, patch } } // Compteurs F LÉGERS (petites tables : account/delivery/service) — pour le tableau de bord. À mettre en cache côté appelant. async function fCounts () { const p = pool(); if (!p) return {} const [[a]] = await p.query('SELECT COUNT(*) c FROM account') const [[d]] = await p.query('SELECT COUNT(*) c FROM delivery') const [[s]] = await p.query('SELECT COUNT(*) c FROM service WHERE status=1') return { accounts: Number(a.c), deliveries: Number(d.c), services_active: Number(s.c) } } // Écart EXACT par domaine (set-diff d'ids, pas une soustraction de compte) → « à créer » juste + orphelins // (ids ERPNext sans source F = comptes supprimés/fusionnés dans F). Léger : ids seuls, petites tables ; à cacher. async function domainCounts () { const p = pool(); if (!p) return null const pgp = require('./address-db').pool() // missing (à créer) = F-actif absent d'ERPNext ; orphans = ERPNext absent de F-TOTAL (supprimé de F, // PAS juste annulé — un service annulé garde sa SS et n'est pas orphelin). const d = async (fActiveSql, pgSql, fAllSql = null) => { const [fa] = await p.query(fActiveSql); const fActive = new Set(fa.map(r => Number(r.id))) const er = (await pgp.query(pgSql)).rows.map(r => Number(r.k)); const eset = new Set(er) let missing = 0; for (const id of fActive) if (!eset.has(id)) missing++ let fAll = fActive if (fAllSql) { const [fr] = await p.query(fAllSql); fAll = new Set(fr.map(r => Number(r.id))) } let orphans = 0; for (const id of eset) if (!fAll.has(id)) orphans++ return { f: fActive.size, erp: eset.size, missing, orphans } } return { clients: await d('SELECT id FROM account', 'SELECT legacy_account_id k FROM "tabCustomer" WHERE legacy_account_id>0'), adresses: await d('SELECT id FROM delivery', 'SELECT legacy_delivery_id k FROM "tabService Location" WHERE legacy_delivery_id>0'), services: await d('SELECT id FROM service WHERE status=1', 'SELECT legacy_service_id k FROM "tabService Subscription" WHERE legacy_service_id>0', 'SELECT id FROM service'), } } // ── RÉCONCILIATION SERVICES PAR COMPTE (F→ERPNext) — APERÇU lecture seule ───── // Cause vue (Schink/Cousineau/Marianne) : ERPNext a gardé l'ANCIEN forfait (suspendu) et n'a jamais // reçu le NOUVEAU forfait ACTIF de F → total faux. On lit F par delivery_id (INDEXÉ ; dérivé d'ERPNext // pour ÉVITER la jointure account non indexée qui figeait F), on diffe vs ERPNext. AUCUNE écriture. // 1 compte à la fois = requête F légère, pas de scan complet, pas de rafale. async function previewAccountServiceReconcile (customer) { if (!customer) throw new Error('customer requis') const pgp = require('./address-db').pool(); if (!pgp) throw new Error('ERPNext PG indisponible') const locRows = (await pgp.query('SELECT name, legacy_delivery_id FROM "tabService Location" WHERE customer=$1', [customer])).rows const dids = [...new Set(locRows.map(l => Number(l.legacy_delivery_id)).filter(Boolean))] const p = pool(); if (!p) throw new Error('F indisponible (mysql2 absent)') let fsvc = [] // PAS de jointure (les lectures de lignes service + jointure figent F) → 2 requêtes simples. if (dids.length) { const [rows] = await p.query({ sql: `SELECT id, status, date_suspended, delivery_id, product_id, hijack, hijack_price, hijack_desc FROM service WHERE delivery_id IN (${dids.map(() => '?').join(',')})`, timeout: 6000 }, dids) fsvc = rows } // prix/sku produit par PK (rapide). F : prix effectif = hijack ? hijack_price : product.price (le « hijack » = override par service). const prodIds = [...new Set(fsvc.map(s => Number(s.product_id)).filter(Boolean))] const prod = {} if (prodIds.length) { try { const [pr] = await p.query({ sql: `SELECT id, sku, price FROM product WHERE id IN (${prodIds.map(() => '?').join(',')})`, timeout: 6000 }, prodIds); for (const x of pr) prod[Number(x.id)] = x } catch (e) { /* produit optionnel */ } } const effPrice = s => (Number(s.hijack) === 1) ? Number(s.hijack_price || 0) : Number((prod[Number(s.product_id)] || {}).price || 0) const effDesc = s => (Number(s.hijack) === 1 && s.hijack_desc) ? s.hijack_desc : ((prod[Number(s.product_id)] || {}).sku || ('prod ' + s.product_id)) const subs = await erp.list('Service Subscription', { filters: [['customer', '=', customer]], fields: ['name', 'legacy_service_id', 'status', 'monthly_price', 'plan_name', 'service_location'], limit: 300 }) || [] const erpByLid = new Map(); for (const s of subs) erpByLid.set(Number(s.legacy_service_id || 0), s) const isFActive = s => Number(s.status) === 1 // F: status=1 = ACTIF (date_suspended = historique, ignoré — cf. svcStatus corrigé) const fActive = fsvc.filter(isFActive) const missingActive = [] // F actif mais ABSENT/INACTIF dans ERPNext → à créer/réactiver for (const s of fActive) { const e = erpByLid.get(Number(s.id)) if (!e) missingActive.push({ legacy: Number(s.id), product: effDesc(s), price: Math.round(effPrice(s) * 100) / 100, erp: 'absent' }) else if (e.status !== 'Actif') missingActive.push({ legacy: Number(s.id), product: effDesc(s), price: Math.round(effPrice(s) * 100) / 100, erp: e.status, erp_name: e.name }) } const erpActiveButFInactive = [] // ERPNext actif mais F inactif → à suspendre/annuler for (const e of subs) { if (e.status !== 'Actif' || !e.legacy_service_id) continue const f = fsvc.find(x => Number(x.id) === Number(e.legacy_service_id)) if (f && !isFActive(f)) erpActiveButFInactive.push({ legacy: Number(e.legacy_service_id), name: e.name, plan: e.plan_name, price: Number(e.monthly_price || 0), f_status: Number(f.status), f_susp: Number(f.date_suspended || 0) }) } const round = n => Math.round(n * 100) / 100 return { customer, deliveries: dids, f_active_total: round(fActive.reduce((a, s) => a + effPrice(s), 0)), erp_active_total: round(subs.filter(s => s.status === 'Actif').reduce((a, s) => a + Number(s.monthly_price || 0), 0)), missing_active: missingActive, erp_active_f_inactive: erpActiveButFInactive, aligned: missingActive.length === 0 && erpActiveButFInactive.length === 0, } } // ── routes HTTP (gated ; preview = sûr) ────────────────────────────────────── async function handle (req, res, method, p, url) { if (p === '/legacy-sync/reconcile-account' && method === 'GET') { try { return json(res, 200, await previewAccountServiceReconcile(url.searchParams.get('customer') || '')) } catch (e) { log('reconcile-account error:', e.message); return json(res, 200, { error: e.message }) } } if (p === '/legacy-sync/preview' && method === 'GET') { try { return json(res, 200, await runPreview({ persist: true })) } catch (e) { log('legacy-sync preview error:', e.message); return json(res, 500, { error: e.message }) } } if (p === '/legacy-sync/report' && method === 'GET') { try { return json(res, 200, JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))) } catch (e) { return json(res, 404, { error: 'aucun rapport — lancer /legacy-sync/preview' }) } } // Écriture gardée : sans body.confirm==='SAFE-ADD' → dry-run (n'écrit rien). if (p === '/legacy-sync/apply-safe-adds' && method === 'POST') { try { const b = await parseBody(req); return json(res, 200, await applySafeAdds({ confirm: b.confirm, limit: b.limit || 0 })) } catch (e) { return json(res, 500, { error: e.message }) } } // Sync incrémental F→OPS (watermark). dry-run sauf { confirm:'F-WINS' }. { full:true } = ignorer le watermark. if (p === '/legacy-sync/run' && method === 'POST') { try { const b = await parseBody(req); return json(res, 200, await syncCustomers({ confirm: b.confirm, full: !!b.full, source: b.source || 'bridge' })) } catch (e) { return json(res, 500, { error: e.message }) } } // Sync statut de service (subscription). dry-run sauf { confirm:'F-WINS' }. if (p === '/legacy-sync/run-services' && method === 'POST') { try { const b = await parseBody(req); return json(res, 200, await syncServices({ confirm: b.confirm })) } catch (e) { return json(res, 500, { error: e.message }) } } // Appliquer les changements révisés EN LOT (par champ ou tous). dry-run sauf { confirm:'F-WINS' }. if (p === '/legacy-sync/apply-review-batch' && method === 'POST') { try { const b = await parseBody(req); return json(res, 200, await applyReviewBatch({ field: b.field || null, confirm: b.confirm, limit: b.limit || 0 })) } catch (e) { return json(res, 500, { error: e.message }) } } // Appliquer un changement révisé d'un compte (F gagne, ciblé). dry-run sauf { confirm:'F-WINS' }. if (p === '/legacy-sync/apply-change' && method === 'POST') { try { const b = await parseBody(req); return json(res, 200, await applyAccountChange({ legacy_account_id: b.legacy_account_id, fields: b.fields, confirm: b.confirm })) } catch (e) { return json(res, 500, { error: e.message }) } } // Création des comptes F manquants (créateur canonique). dry-run sauf { confirm:'F-WINS' }. if (p === '/legacy-sync/create-customers' && method === 'POST') { try { const b = await parseBody(req); return json(res, 200, b.ids ? await createCustomersByIds({ ids: b.ids, confirm: b.confirm, limit: b.limit || 2000 }) : await createCustomers({ confirm: b.confirm, limit: b.limit || 1000 })) } catch (e) { return json(res, 500, { error: e.message }) } } // Monitoring de F via le pont (fraîcheur + compteurs) — lecture seule. if (p === '/legacy-sync/f-ping' && method === 'GET') { try { return json(res, 200, await bridgeCall({ action: 'ping' })) } catch (e) { return json(res, 502, { error: e.message }) } } if (p === '/legacy-sync/scoreboard' && method === 'GET') { try { return json(res, 200, await scoreboard()) } catch (e) { return json(res, 500, { error: e.message }) } } if (p === '/legacy-sync/state' && method === 'GET') return json(res, 200, loadState()) if (p === '/legacy-sync/run-status' && method === 'GET') { try { return json(res, 200, JSON.parse(fs.readFileSync(RUN_LOG, 'utf8'))) } catch { return json(res, 200, { status: 'idle' }) } } // Liste COMPLÈTE des erreurs du dernier apply, groupées par raison. ?reason=email_invalide pour filtrer. if (p === '/legacy-sync/errors' && method === 'GET') { try { const d = JSON.parse(fs.readFileSync(ERRORS_FILE, 'utf8')) const filt = url && url.searchParams.get('reason') if (filt) d.errors = (d.errors || []).filter(e => e.reason === filt) return json(res, 200, d) } catch { return json(res, 200, { total_errors: 0, by_reason: {}, errors: [], note: 'aucun apply exécuté' }) } } return json(res, 404, { error: 'route legacy-sync inconnue' }) } module.exports = { handle, runPreview, previewCustomers, applySafeAdds, syncCustomers, syncServices, annulResiliatedSubscriptions, scoreboard, createCustomers, createCustomersByIds, applyAccountChange, applyReviewBatch, fCounts, domainCounts, loadState, mapAccount, svcStatus, fAccountStatuses, fResiliatedAccountIds, F_ACCT_STATUS_LABEL, GROUP_MAP, CMP }