'use strict' // ───────────────────────────────────────────────────────────────────────────── // ORCHESTRATEUR DE SYNCHRONISATION F → ERPNext — colonne vertébrale unifiée. // // Pattern : moteurs MODULAIRES (un par domaine) + orchestration + vue UNIFIÉES. // (1) MANIFEST du DAG de dépendances → garantit l'ordre (un module ne tourne jamais // avant ses prérequis : c'est la leçon « comptes avant factures » généralisée). // (2) status() → agrège l'état LÉGER de chaque module (compteurs PG + MAX(id) F + // coherenceStatus billing), mis en cache 60s. Base du tableau de bord unifié. // // L'EXÉCUTION ordonnée vit dans /opt/targo-sync/run.sh (l'hôte a docker pour le step // Python des factures). Le DAG ci-dessous DOCUMENTE et VALIDE cet ordre. // Règle de statut client : disabled/is_suspended = DÉRIVÉ des services, jamais sync // depuis F (cf. feedback_customer_status_policy) → classés « dérivé » côté dashboard. // ───────────────────────────────────────────────────────────────────────────── const { json, log, parseBody } = require('./helpers') // DAG : chaque module déclare ses prérequis. Ordre topologique garanti par l'orchestrateur. // Dépendances VÉRIFIÉES contre les colonnes *_id de F (aucune FK déclarée) : // delivery.account_id→account · service.{delivery_id,device_id,product_id} · invoice.account_id // invoice_item.{invoice_id,service_id} · payment.account_id · payment_item.{payment_id,invoice_id} // ticket.{account_id,delivery_id} · account_memo.account_id const MODULES = [ { key: 'clients', label: 'Clients', deps: [], engine: 'legacy-sync', creator: 'createCustomers' }, { key: 'adresses', label: 'Adresses', deps: ['clients'], engine: 'legacy-sync', note: 'Service Location (delivery.account_id)' }, { key: 'services', label: 'Services', deps: ['clients', 'adresses'], engine: 'legacy-sync', note: 'Service Subscription (service.delivery_id)' }, { key: 'tickets', label: 'Tickets', deps: ['clients', 'adresses'], engine: 'legacy-dispatch-sync', note: 'ticket.{account_id,delivery_id} — cadence propre' }, { key: 'factures', label: 'Factures', deps: ['clients', 'adresses'], engine: 'sync_invoices_incremental.py', note: 'invoice.account_id ; items→service_location' }, { key: 'paiements', label: 'Paiements', deps: ['factures'], engine: 'legacy-payments', note: 'payment_item.invoice_id' }, ] // Ordre topologique (Kahn) — sert à la fois de doc et de garde-fou (détecte un cycle). function topoOrder () { const done = new Set(); const order = [] let guard = 0 while (order.length < MODULES.length && guard++ < 50) { for (const m of MODULES) { if (!done.has(m.key) && m.deps.every(d => done.has(d))) { order.push(m.key); done.add(m.key) } } } return order } let _cache = null; let _cacheAt = 0 async function status ({ fresh = false } = {}) { if (!fresh && _cache && (Date.now() - _cacheAt) < 60000) return { ..._cache, from_cache: true } const lp = require('./legacy-payments') const ls = require('./legacy-sync') let dc = null; try { dc = await ls.domainCounts() } catch (e) { dc = null } // set-diff EXACT (à créer + orphelins) let coh = {}; try { coh = await lp.coherenceStatus() } catch (e) { coh = { error: e.message } } const mods = {} const dom = (k, extra) => (dc && dc[k]) ? { erp: dc[k].erp, f: dc[k].f, to_create: dc[k].missing, orphans: dc[k].orphans, ...(extra || {}) } : { error: 'F indisponible' } mods.clients = dom('clients', { status_note: 'disabled/is_suspended = dérivé (non synchronisé)' }) mods.adresses = dom('adresses') mods.services = dom('services', { note: 'orphans = SS dont le service F est supprimé (≠ annulé)' }) mods.factures = coh.invoices ? { lag: coh.invoices.lag, synced: coh.invoices.live_synced, ar_open: coh.invoices.ar_open } : { error: coh.error } mods.paiements = coh.payments ? { lag: coh.payments.lag, total: coh.payments.total } : { error: coh.error } mods.tickets = { engine: 'legacy-dispatch-sync', note: 'moteur séparé (osTicket → Dispatch Job)' } const out = { checked_at: new Date().toISOString(), order: topoOrder(), dag: MODULES.map(m => ({ key: m.key, label: m.label, deps: m.deps, engine: m.engine, note: m.note })), modules: mods, billing_health: { healthy: coh.healthy, last_cycle: coh.last_cycle, last_cycle_min_ago: coh.last_cycle_min_ago }, note: 'Moteurs modulaires, orchestration + vue unifiées. Exécution ordonnée : /opt/targo-sync/run.sh (horaire).', } _cache = out; _cacheAt = Date.now() return out } // ── SYNC MANUEL (« Synchroniser maintenant ») — étapes HUB-SIDE, progression pollable ── // Couvre les couches qui changent en continu (clients/paiements/soldes/tickets), in-process. // Les FACTURES + SERVICES sont créés par le cron horaire (scripts Python sur l'hôte) → hors de ce bouton ; // l'UI affiche leur lag + le prochain passage auto. // Cœur (rapide, ~30s) : couches qui changent en continu. const CORE_STEPS = [ { key: 'clients', label: 'Comptes manquants', fn: async () => { const r = await require('./legacy-payments').ensureBillingCustomers({ confirm: 'F-WINS' }); return `${r.created || 0} créé(s)` } }, { key: 'paiements', label: 'Paiements', fn: async () => { const r = await require('./legacy-payments').syncPayments({ confirm: 'F-WINS', window: 5000, limit: 20000 }); return `${r.applied || 0} paiement(s), ${r.references || 0} alloc.` } }, { key: 'soldes', label: 'Soldes des factures', fn: async () => { const r = await require('./legacy-payments').refreshOpenInvoices({ confirm: 'F-WINS' }); return `${r.updated || 0} soldé(s)` } }, ] // Tickets : SÉPARÉ (lourd ~70s : pull legacy + géocodage ; déjà sur scheduler 15 min + sync d'assignation live en Planification). const TICKET_STEP = { key: 'tickets', label: 'Tickets (dispatch)', fn: async () => { const r = await require('./legacy-dispatch-sync').sync({ dryRun: false }); const n = r && (r.created ?? r.ingested ?? r.jobs_created); return n != null ? `${n} ticket(s)` : 'à jour' } } function stepsFor (scope) { return scope === 'tickets' ? [TICKET_STEP] : scope === 'all' ? [...CORE_STEPS, TICKET_STEP] : CORE_STEPS } let _run = { running: false, scope: null, steps: [], started_at: null, finished_at: null } async function runManual ({ confirm, scope = 'core' }) { if (confirm !== 'F-WINS') return { error: "confirm:'F-WINS' requis" } if (_run.running) return { already_running: true } const defs = stepsFor(scope) _run = { running: true, scope, started_at: new Date().toISOString(), finished_at: null, steps: defs.map(s => ({ key: s.key, label: s.label, status: 'pending', detail: null })) } ;(async () => { for (let i = 0; i < defs.length; i++) { _run.steps[i].status = 'running' try { _run.steps[i].detail = await defs[i].fn(); _run.steps[i].status = 'done' } catch (e) { _run.steps[i].status = 'error'; _run.steps[i].detail = String(e.message).slice(0, 160); log('sync run step ' + defs[i].key + ' err: ' + e.message) } } _run.running = false; _run.finished_at = new Date().toISOString(); _cacheAt = 0 // force /sync/status frais })() return { started: true, scope, steps: _run.steps.length } } function runStatus () { const done = _run.steps.filter(s => s.status === 'done' || s.status === 'error').length const ms = _run.started_at ? ((_run.finished_at ? Date.parse(_run.finished_at) : Date.now()) - Date.parse(_run.started_at)) : 0 return { ..._run, total: _run.steps.length, done, elapsed_s: Math.round(ms / 1000), note: 'Factures & services : créés au cycle horaire (cron :15).' } } async function handle (req, res, method, p, url) { if (p === '/sync/run' && method === 'POST') { try { const b = await parseBody(req); return json(res, 200, await runManual({ confirm: b.confirm, scope: b.scope })) } catch (e) { return json(res, 500, { error: e.message }) } } if (p === '/sync/run-status' && method === 'GET') return json(res, 200, runStatus()) if (p === '/sync/status' && method === 'GET') { try { const fresh = url && url.searchParams.get('fresh') === '1'; return json(res, 200, await status({ fresh })) } catch (e) { log('sync-orchestrator status error:', e.message); return json(res, 500, { error: e.message }) } } if (p === '/sync/dag' && method === 'GET') return json(res, 200, { order: topoOrder(), dag: MODULES }) return json(res, 404, { error: 'route sync inconnue' }) } module.exports = { handle, status, topoOrder, MODULES }