Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check` tout OK + build SPA propre. - ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules, UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons, pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…), usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl - hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js, avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js, legacy-dispatch-sync.js, server.js Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
125 lines
9.4 KiB
JavaScript
125 lines
9.4 KiB
JavaScript
'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 lds = require('./legacy-dispatch-sync'); const r = await lds.sync({ dryRun: false }); const n = (r && (r.created ?? r.ingested ?? r.jobs_created)) || 0; let a = null; try { a = await lds.ingestAssigned({ dryRun: false, allDates: true }) } catch (e) { /* pool créé quand même */ }; const na = (a && a.created) || 0; return `${n + na} ticket(s)${na ? ` (dont ${na} assigné(s))` : ''}` } }
|
|
// Créer TOUS les comptes F manquants (pas seulement ceux référencés par une facture récente comme le step `clients`).
|
|
// Lourd (scan complet des comptes F + création throttlée) → passe par le run ASYNC (polling), jamais une requête HTTP bloquante.
|
|
const CREATE_ALL_STEP = { key: 'clients-all', label: 'Tous les comptes manquants (F→OPS)', fn: async () => { const r = await require('./legacy-sync').createCustomers({ confirm: 'F-WINS', limit: 100000 }); return `${r.created || 0} créé(s)${r.errors ? ` · ${r.errors} err` : ''}` } }
|
|
function stepsFor (scope) { return scope === 'tickets' ? [TICKET_STEP] : scope === 'customers' ? [CREATE_ALL_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 }
|