gigafibre-fsm/services/targo-hub/lib/legacy-sync.js
louispaulb 004c3f8dee sync-legacy: scoreboard de réconciliation + fix des abonnements fantômes à la source
Scoreboard (console de réconciliation F ↔ ERPNext) :
- hub: legacy-sync.js scoreboard() + GET /legacy-sync/scoreboard — comptes bruts
  par module (clients/lieux/abos/appareils/tickets/factures/paiements) + métrique
  ghost_active_subscriptions. Devices = tabService Equipment (pas tabDevice).
  Count F « abos actifs » exclut les comptes résiliés (comparaison pomme-à-pomme).
- ops: carte « Réconciliation par module » sur LegacySyncPage.vue.

Fix fantômes à la SOURCE (scripts/targo-sync/, jusqu'ici hors repo) :
- La vraie sync des services = scripts Python hôte (/opt/targo-sync/, cron horaire),
  PAS le hub. svc_status() ignorait le statut du COMPTE → un compte résilié F
  (status 3/4/5) dont les services restent status=1 ressortait « Actif » (F ne
  cascade pas). Résultat : ~3855 abonnements fantômes recréés à chaque heure.
- sync_services_incremental.py: svc_status() rendu conscient du compte (force
  'Annulé' si compte résilié) sur Phase B (création) + Phase D (rafraîchissement).
  Rollout: dry-run {Actif→Annulé: 3855} → APPLY=1 → ghost=0 stable.
- Versionne aussi run.sh + sync_invoices_incremental.py + README (snapshot fidèle
  de la prod ; ces scripts écrivent en base chaque heure et n'étaient pas suivis).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:03:36 -04:00

825 lines
55 KiB
JavaScript

'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(/&amp;/g, '&').replace(/&#0?39;|&apos;/g, "'").replace(/&quot;/g, '"')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&#0?(\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 SUR compte non-résilié (F ne cascade pas la résiliation → comptage brut gonflé) : comparé pomme-à-pomme au 'Actif' ERPNext maintenant nettoyé
myc('SELECT COUNT(*) c FROM service s JOIN delivery d ON d.id=s.delivery_id JOIN account a ON a.id=d.account_id WHERE s.status=1 AND a.status NOT IN (3,4,5)'),
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, ghost ? `${ghost} fantômes (compte résilié) à annuler` : 'F actif hors compte résilié ↔ Actif'),
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 }