Accumulated work on the dispatch/legacy-writeback branch: - Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard, PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs; hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js. - Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js, sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental migration scripts; LegacySyncPage, SupplierInvoices + negative-billing / terminated-active reports. - Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit, olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers, legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets, ClientDetail updates. - docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
750 lines
49 KiB
JavaScript
750 lines
49 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(/&/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,
|
|
_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']
|
|
// 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, 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 }
|
|
}
|
|
|
|
// ── 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, 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, 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/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, createCustomers, createCustomersByIds, applyAccountChange, applyReviewBatch, fCounts, domainCounts, loadState, mapAccount, svcStatus, fAccountStatuses, fResiliatedAccountIds, F_ACCT_STATUS_LABEL, GROUP_MAP, CMP }
|