gigafibre-fsm/services/targo-hub/lib/legacy-payments.js
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
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>
2026-06-15 06:12:12 -04:00

583 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// F (gestionclient) → ERPNext — RÉCONCILIATION DES PAIEMENTS, mode PREVIEW.
//
// LECTURE SEULE par défaut. F reste AUTORITAIRE sur la facturation ; ERPNext en
// est une COPIE. On ne touche JAMAIS F. L'« apply » (miroir des paiements) est
// gardé derrière confirm==='F-WINS' et n'écrit que la couche LOG (Payment Entry
// + références), exactement comme la migration l'a fait pour les 542k paiements
// existants.
//
// ÉTAT VÉRIFIÉ EN PROD (2026-06-12) — à garder en tête pour la cohérence :
// • Sales Invoice : ~629 927 en docstatus=2 (ANNULÉES) → l'AR ERPNext est GELÉ
// (les factures annulées ont outstanding=0 et n'impactent pas l'AR vivant).
// 3 factures « vivantes » récentes en série SINV-2026-NNNNNN.
// • Payment Entry : 542 102, nommées PE-{id zfill10}, mode_of_payment peuplé,
// paid_from='Comptes clients - T', paid_to='Banque - T', docstatus=1.
// → importées par reimport_payments.py (zfill10 + MODE_MAP). max id = 542325.
// • GL (3,1M) + PLE (1,06M) construits par clean_reimport.py (contre les
// factures désormais annulées).
// • F : 553 372 paiements max → delta ≈ 11k paiements nouveaux depuis le miroir.
//
// CONSÉQUENCE : « appliquer un paiement » ne peut PAS réduire un solde de facture
// ERPNext (factures annulées). La cohérence des SOLDES vit dans F (invoice.billed_amt).
// Ce module : (1) PREVIEW/réconciliation F↔ERPNext, (2) MIROIR du log de paiement
// (PE+PER) pour le delta, idempotent par PE-{id}, gardé. La couche GL/PLE/outstanding
// reste figée tant que la BASCULE de facturation vers ERPNext n'est pas décidée.
//
// Modèle de nommage (= clés d'idempotence, identiques à la migration) :
// Payment Entry PE-{legacy_payment_id zfill10}
// Payment Entry Reference PER-{legacy_payment_id}-{idx} → SINV-{invoice_id zfill10}
// Sales Invoice SINV-{legacy_invoice_id zfill10}
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { json, log } = require('./helpers')
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-payments-preview.json')
const RECON_FILE = path.join(REPORT_DIR, 'legacy-payments-recon.json') // cache réconciliation (évite de rescanner F live)
const STATE_FILE = path.join(REPORT_DIR, 'legacy-payments-state.json')
const RUN_FILE = path.join(REPORT_DIR, 'legacy-payments-run.json') // dernier cycle (santé/observabilité)
// ── Legacy type → ERPNext mode_of_payment (IDENTIQUE à reimport_payments.py) ──
const MODE_MAP = {
ppa: 'Bank Draft',
'paiement direct': 'Bank Transfer',
'carte credit': 'Credit Card',
cheque: 'Cheque',
comptant: 'Cash',
reversement: 'Bank Transfer',
credit: 'Credit Note',
'credit targo': 'Credit Note',
'credit facture': 'Credit Note',
}
const CREDIT_TYPES = new Set(['credit', 'credit targo', 'credit facture'])
const PAID_FROM = 'Comptes clients - T' // Receivable (idem migration)
const PAID_TO = 'Banque - T' // Bank
const pad10 = (n) => String(n).padStart(10, '0')
const peName = (id) => 'PE-' + pad10(id)
const sinvName = (id) => 'SINV-' + pad10(id)
function modeFor (type) { return MODE_MAP[String(type || '').trim().toLowerCase()] || 'Bank Transfer' }
function tsToDate (t) { if (!t || t <= 0) return null; try { return new Date(Number(t) * 1000).toISOString().slice(0, 10) } catch { return null } }
function r2 (v) { return Math.round((Number(v) || 0) * 100) / 100 }
// ── pools ─────────────────────────────────────────────────────────────────────
function pgPool () { return require('./address-db').pool() } // PG ERPNext (_eb65bdc0c4b1b2d6)
let _my
function myPool () {
if (!mysql) return null
if (!_my) _my = 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 _my
}
const pgq = (s, a) => pgPool().query(s, a).then(r => r.rows)
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-payments: state write fail', e.message) } }
// ── état de l'AR ERPNext (factures par docstatus) ────────────────────────────
async function arState () {
const rows = await pgq('SELECT docstatus, COUNT(*) c FROM "tabSales Invoice" GROUP BY docstatus')
const by = { draft: 0, submitted: 0, cancelled: 0 }
for (const r of rows) { if (r.docstatus === 0) by.draft = Number(r.c); else if (r.docstatus === 1) by.submitted = Number(r.c); else if (r.docstatus === 2) by.cancelled = Number(r.c) }
const frozen = by.submitted <= 5 && by.cancelled > 1000 // l'immense majorité annulée → AR gelé
return { ...by, ar_frozen: frozen, note: frozen ? 'AR ERPNext GELÉ (factures migrées annulées) — F autoritaire sur les soldes' : 'AR ERPNext actif' }
}
// ── PREVIEW : delta de paiements F non encore reflétés dans ERPNext ───────────
// since = id de paiement plancher (défaut : MAX(legacy_payment_id) dans ERPNext).
async function previewPayments ({ since = null, limit = 20000, window = 0 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir legacy-db indisponible (mysql2 absent)')
const erpMax = Number((await pgq('SELECT COALESCE(MAX(legacy_payment_id),0) mx FROM "tabPayment Entry"'))[0].mx) || 0
// GAP-AWARE : on recule le plancher d'une fenêtre → on repère aussi les TROUS sous le max
// (paiements sautés « sans client » résolus plus tard). Idempotent : les présents sont sautés.
const sinceId = (since != null) ? Number(since) : Math.max(0, erpMax - Number(window || 0))
const ar = await arState()
const [prows] = await mp.query(
'SELECT id, account_id, date_orig, amount, applied_amt, type, reference, memo FROM payment WHERE id > ? ORDER BY id LIMIT ?',
[sinceId, limit + 1])
const hasMore = prows.length > limit
const payments = hasMore ? prows.slice(0, limit) : prows
const ids = payments.map(p => p.id)
// payment_item (allocations) du delta
const itemsByPay = {}
if (ids.length) {
const [ir] = await mp.query(`SELECT payment_id, invoice_id, amount FROM payment_item WHERE payment_id IN (${ids.map(() => '?').join(',')})`, ids)
for (const it of ir) (itemsByPay[it.payment_id] || (itemsByPay[it.payment_id] = [])).push(it)
}
// index Customer (par legacy_account_id) — seulement les comptes concernés
const acctIds = [...new Set(payments.map(p => Number(p.account_id)))]
const custMap = {}
for (let i = 0; i < acctIds.length; i += 1000) {
const b = acctIds.slice(i, i + 1000); if (!b.length) continue
for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x
}
// existence des factures cibles (par legacy_invoice_id) + docstatus
const invIds = [...new Set(Object.values(itemsByPay).flat().map(it => Number(it.invoice_id)).filter(Boolean))]
const invMap = {}
for (let i = 0; i < invIds.length; i += 2000) {
const b = invIds.slice(i, i + 2000); if (!b.length) continue
for (const x of await pgq('SELECT legacy_invoice_id, name, docstatus FROM "tabSales Invoice" WHERE legacy_invoice_id = ANY($1)', [b])) invMap[Number(x.legacy_invoice_id)] = x
}
// PE déjà miroir (idempotence) pour le delta
const existingPe = new Set()
if (ids.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [ids])) existingPe.add(Number(x.legacy_payment_id))
// ── classification ──
const byType = {}; const issues = { no_customer: 0, unallocated: 0, alloc_missing_invoice: 0, alloc_to_cancelled: 0, credits: 0 }
const custRoll = {}; let totalAmt = 0, wouldCreate = 0, alreadyMirror = 0
const samples = { no_customer: [], missing_invoice: [], credits: [], to_create: [] }
let minDate = null, maxDate = null
for (const p of payments) {
const type = String(p.type || '').trim().toLowerCase()
const amt = r2(Math.abs(p.amount))
const d = tsToDate(p.date_orig)
if (d) { if (!minDate || d < minDate) minDate = d; if (!maxDate || d > maxDate) maxDate = d }
byType[type] = byType[type] || { count: 0, amount: 0 }
byType[type].count++; byType[type].amount = r2(byType[type].amount + amt)
totalAmt = r2(totalAmt + amt)
if (CREDIT_TYPES.has(type)) issues.credits++
const cust = custMap[Number(p.account_id)]
if (!cust) { issues.no_customer++; if (samples.no_customer.length < 15) samples.no_customer.push({ legacy_payment_id: p.id, account_id: p.account_id, amount: amt, type }); continue }
const alloc = itemsByPay[p.id] || []
let resolvedAlloc = 0, missingAlloc = 0, cancelledAlloc = 0
const refs = []
for (const it of alloc) {
const inv = invMap[Number(it.invoice_id)]
if (!inv) { missingAlloc++; if (samples.missing_invoice.length < 15) samples.missing_invoice.push({ legacy_payment_id: p.id, invoice_id: it.invoice_id }); continue }
if (inv.docstatus === 2) cancelledAlloc++
resolvedAlloc++
refs.push({ reference_name: inv.name, allocated: r2(Math.abs(it.amount)), cancelled: inv.docstatus === 2 })
}
if (missingAlloc) issues.alloc_missing_invoice += missingAlloc
if (cancelledAlloc) issues.alloc_to_cancelled += cancelledAlloc
if (!alloc.length) issues.unallocated++
if (existingPe.has(Number(p.id))) { alreadyMirror++; continue }
wouldCreate++
const cr = custRoll[cust.name] || (custRoll[cust.name] = { customer: cust.name, customer_name: cust.customer_name, count: 0, amount: 0 })
cr.count++; cr.amount = r2(cr.amount + amt)
if (samples.to_create.length < 25) samples.to_create.push({ pe: peName(p.id), date: d, amount: amt, type, mode: modeFor(type), customer_name: cust.customer_name, refs: refs.slice(0, 4), unallocated: !alloc.length })
}
const topCustomers = Object.values(custRoll).sort((a, b) => b.amount - a.amount).slice(0, 20)
const out = {
mode: 'PREVIEW (lecture seule — aucune écriture ni dans F ni dans ERPNext)',
generated_at: new Date().toISOString(),
ar_state: ar,
watermark: { since_payment_id: sinceId, erp_max_payment_id: erpMax, source: (since != null ? 'override' : 'erp_max') },
delta: {
scanned: payments.length, has_more: hasMore, would_create: wouldCreate, already_mirrored: alreadyMirror,
total_amount: totalAmt, date_range: { from: minDate, to: maxDate },
by_type: Object.fromEntries(Object.entries(byType).map(([k, v]) => [k, { count: v.count, amount: v.amount, mode_of_payment: modeFor(k) }])),
},
issues,
top_customers: topCustomers,
samples,
consistency_note: ar.ar_frozen
? "ERPNext AR gelé : le miroir ajoute le LOG de paiement (PE+PER) sans réduire de solde (factures annulées). Les SOLDES restent dans F (invoice.billed_amt). La bascule de facturation vers ERPNext est une décision séparée."
: 'ERPNext AR actif : le miroir des paiements impacte les soldes.',
}
try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); fs.writeFileSync(REPORT_FILE, JSON.stringify(out, null, 2)); out.saved_to = REPORT_FILE } catch (e) { log('legacy-payments: report write fail', e.message) }
return out
}
// F invoice → statut ERPNext REFLÉTANT F (mirroir clean_reimport + fix_invoice_outstanding).
// docstatus=1 (submitted) car « on insère selon le statut qu'elles ont dans F ».
const TODAY = () => new Date().toISOString().slice(0, 10)
function mapInvoice (f, today) {
const total = r2(f.total_amt); const billed = r2(f.billed_amt)
const isReturn = total < 0
const due = tsToDate(f.due_date) || tsToDate(f.date_orig)
const owed = isReturn ? 0 : r2(Math.max(0, total - billed))
let status
if (isReturn) status = 'Return'
else if (owed <= 0.005) status = 'Paid'
else if (due && due < (today || TODAY())) status = 'Overdue'
else status = 'Unpaid'
return { sinv: sinvName(f.id), legacy_invoice_id: Number(f.id), is_return: isReturn ? 1 : 0, total, billed, outstanding: owed, status, docstatus: 1 }
}
// ── PREVIEW : delta de FACTURES F absentes d'ERPNext (le volet manquant) ──────
// Lecture seule. Quantifie les factures créées dans F depuis le gel de migration.
async function previewInvoices ({ since = null, limit = 30000 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir legacy-db indisponible')
const erpMax = Number((await pgq('SELECT COALESCE(MAX(legacy_invoice_id),0) mx FROM "tabSales Invoice"'))[0].mx) || 0
const sinceId = (since != null) ? Number(since) : erpMax
const [rows] = await mp.query('SELECT id, account_id, date_orig, due_date, total_amt, billed_amt, billing_status FROM invoice WHERE id > ? ORDER BY id LIMIT ?', [sinceId, limit + 1])
const hasMore = rows.length > limit
const invs = hasMore ? rows.slice(0, limit) : rows
const today = TODAY()
const byStatus = { Paid: 0, Unpaid: 0, Overdue: 0, Return: 0 }
const acctIds = [...new Set(invs.map(i => Number(i.account_id)))]
const custMap = {}
for (let i = 0; i < acctIds.length; i += 1000) { const b = acctIds.slice(i, i + 1000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x }
const byMonth = {}; const custRoll = {}
let invoiced = 0, owed = 0, billed = 0, returns = 0, noCust = 0, unbilledCnt = 0
let minD = null, maxD = null
const sampleMapped = []
for (const i of invs) {
const tgt = mapInvoice(i, today); byStatus[tgt.status]++
const tot = tgt.total; const ow = tgt.outstanding
const d = tsToDate(i.date_orig); if (d) { if (!minD || d < minD) minD = d; if (!maxD || d > maxD) maxD = d }
invoiced = r2(invoiced + tot); owed = r2(owed + ow); if (Number(i.billing_status) === 1) billed = r2(billed + tot)
if (tot < 0) returns++
if (Number(i.billing_status) !== 1) unbilledCnt++
if (sampleMapped.length < 12) sampleMapped.push({ sinv: tgt.sinv, status: tgt.status, total: tgt.total, outstanding: tgt.outstanding, is_return: tgt.is_return })
const mo = d ? d.slice(0, 7) : '?'; byMonth[mo] = byMonth[mo] || { count: 0, invoiced: 0, owed: 0 }
byMonth[mo].count++; byMonth[mo].invoiced = r2(byMonth[mo].invoiced + tot); byMonth[mo].owed = r2(byMonth[mo].owed + ow)
const cust = custMap[Number(i.account_id)]
if (!cust) { noCust++; continue }
const cr = custRoll[cust.name] || (custRoll[cust.name] = { customer: cust.name, customer_name: cust.customer_name, count: 0, owed: 0 })
cr.count++; cr.owed = r2(cr.owed + ow)
}
return {
mode: 'PREVIEW (lecture seule)', generated_at: new Date().toISOString(),
watermark: { since_invoice_id: sinceId, erp_max_invoice_id: erpMax },
delta: { scanned: invs.length, has_more: hasMore, total_invoiced: invoiced, total_billed: billed, total_owed: owed, returns, unbilled: unbilledCnt, no_customer: noCust, date_range: { from: minD, to: maxD } },
target_status: byStatus, // statut ERPNext qui serait appliqué (reflète F)
sample_mapped: sampleMapped,
by_month: byMonth,
top_customers_by_owed: Object.values(custRoll).sort((a, b) => b.owed - a.owed).slice(0, 20),
note: "Factures F créées depuis le gel (id > max importé). Volet PRÉREQUIS au miroir des paiements : sans elles, les allocations de paiement restent orphelines.",
}
}
// ── VUE D'ENSEMBLE : l'écart de facturation complet (factures + paiements) ────
async function billingGap () {
const [inv, pay, ar] = await Promise.all([previewInvoices(), previewPayments(), arState()])
return {
generated_at: new Date().toISOString(),
ar_state: ar,
frozen_since: inv.delta.date_range.from,
invoices_behind: { count: inv.delta.scanned, has_more: inv.delta.has_more, total_invoiced: inv.delta.total_invoiced, total_owed: inv.delta.total_owed, returns: inv.delta.returns, by_month: inv.by_month },
payments_behind: { count: pay.delta.scanned, has_more: pay.delta.has_more, total_amount: pay.delta.total_amount, by_type: pay.delta.by_type, would_create: pay.delta.would_create, orphan_allocations: pay.issues.alloc_missing_invoice },
note: 'F autoritaire. ERPNext = copie gelée depuis frozen_since. Rétablir les factures d\'abord rend les allocations de paiement résolvables.',
}
}
// ── RÉCONCILIATION GLOBALE F ↔ ERPNext (« vérifier ce qu'on a déjà ») ─────────
// Agrégats BORNÉS sur F live (one-shot) + ERPNext, mis EN CACHE (RECON_FILE) pour
// ne JAMAIS rescanner F live à chaque appel. ?fresh=1 force un recalcul.
async function reconcileSummary ({ fresh = false } = {}) {
if (!fresh) { try { const c = JSON.parse(fs.readFileSync(RECON_FILE, 'utf8')); c.from_cache = true; return c } catch {} }
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
// ── F live : agrégats indexés/one-shot (MVCC, non bloquant) ──
const [[fi]] = await mp.query(`SELECT COUNT(*) c, MAX(id) mx,
SUM(CASE WHEN total_amt < 0 THEN 1 ELSE 0 END) returns,
SUM(CASE WHEN total_amt > billed_amt + 0.005 THEN 1 ELSE 0 END) open_cnt,
ROUND(SUM(CASE WHEN total_amt > billed_amt THEN total_amt - billed_amt ELSE 0 END), 2) owed
FROM invoice`)
const [fbs] = await mp.query('SELECT billing_status, COUNT(*) c FROM invoice GROUP BY billing_status')
const [[fp]] = await mp.query('SELECT COUNT(*) c, MAX(id) mx FROM payment')
// ── ERPNext : ce qu'on a déjà ──
const erpInv = (await pgq('SELECT docstatus, COUNT(*) c FROM "tabSales Invoice" GROUP BY docstatus')).reduce((o, r) => (o[r.docstatus] = Number(r.c), o), {})
const erpInvLeg = (await pgq('SELECT COUNT(*) c, MAX(legacy_invoice_id) mx FROM "tabSales Invoice" WHERE legacy_invoice_id > 0'))[0]
const erpPe = (await pgq('SELECT COUNT(*) c, MAX(legacy_payment_id) mx FROM "tabPayment Entry"'))[0]
const ar = await arState()
// ── divergence d'un échantillon : factures ERPNext annulées vs statut réel F ──
const samp = await pgq(`SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE docstatus=2 AND legacy_invoice_id>0 ORDER BY legacy_invoice_id DESC LIMIT 500`)
let paidInF = 0, owedInF = 0, absentInF = 0; const owedSamples = []
if (samp.length) {
const ids = samp.map(r => Number(r.legacy_invoice_id))
const [frows] = await mp.query(`SELECT id, total_amt, billed_amt, billing_status FROM invoice WHERE id IN (${ids.map(() => '?').join(',')})`, ids)
const fmap = {}; for (const f of frows) fmap[f.id] = f
for (const id of ids) { const f = fmap[id]; if (!f) { absentInF++; continue }
const du = r2(Math.max(0, Number(f.total_amt) - Number(f.billed_amt)))
if (du > 0.005) { owedInF++; if (owedSamples.length < 8) owedSamples.push({ sinv: sinvName(id), erp_status: 'Cancelled', f_owed: du, f_billing_status: f.billing_status }) } else paidInF++
}
}
const fInvTotal = Number(fi.c); const erpCovered = Number(erpInvLeg.c)
const out = {
generated_at: new Date().toISOString(), from_cache: false,
F: {
invoices_total: fInvTotal, max_invoice_id: Number(fi.mx),
by_billing_status: fbs.reduce((o, r) => (o[r.billing_status] = Number(r.c), o), {}),
reversals: Number(fi.returns), open_invoices: Number(fi.open_cnt), real_AR_owed: Number(fi.owed),
payments_total: Number(fp.c), max_payment_id: Number(fp.mx),
},
ERPNext: {
invoices_by_docstatus: { draft: erpInv[0] || 0, submitted: erpInv[1] || 0, cancelled: erpInv[2] || 0 },
invoices_with_legacy_id: erpCovered, max_legacy_invoice_id: Number(erpInvLeg.mx),
payments: Number(erpPe.c), max_legacy_payment_id: Number(erpPe.mx),
ar_frozen: ar.ar_frozen,
},
coverage: {
invoices_in_F_not_in_ERPNext: fInvTotal - erpCovered,
invoices_forward_gap: Math.max(0, Number(fi.mx) - Number(erpInvLeg.mx)), // id > max importé
payments_forward_gap: Math.max(0, Number(fp.mx) - Number(erpPe.mx)),
},
divergence_sample: { of: samp.length, erp_cancelled_but_F_paid: paidInF, erp_cancelled_but_F_owed: owedInF, absent_in_F: absentInF, owed_examples: owedSamples },
verdict: `F = vérité : ${Number(fi.open_cnt)} factures ouvertes, ${Number(fi.owed)}$ dus réellement. ERPNext reflète actuellement ~0 (toutes annulées). Pour refléter F : insérer/réactiver avec le statut F + outstanding=montant_du.`,
}
try { fs.writeFileSync(RECON_FILE, JSON.stringify(out, null, 2)); out.saved_to = RECON_FILE } catch (e) { log('recon write fail', e.message) }
return out
}
// ── RÉCONCILIATION D'UN COMPTE : timeline factures vs paiements (F autoritaire) ──
// Lecture seule. Sert à VOIR la cohérence pour un client (ERPNext copie vs F vérité).
async function reconcileAccount ({ accountId = null, customer = null } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
let acct = accountId
if (!acct && customer) {
const r = await pgq('SELECT legacy_account_id FROM "tabCustomer" WHERE name=$1', [customer])
acct = r[0] && Number(r[0].legacy_account_id)
}
if (!acct) throw new Error('accountId ou customer requis')
const [invs] = await mp.query('SELECT id, date_orig, total_amt, billed_amt, billing_status, due_date FROM invoice WHERE account_id=? ORDER BY date_orig', [acct])
const [pays] = await mp.query('SELECT id, date_orig, amount, type, reference FROM payment WHERE account_id=? ORDER BY date_orig', [acct])
// état ERPNext de ces PE/SINV
const peIds = pays.map(p => Number(p.id))
const erpPe = new Set(); if (peIds.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [peIds])) erpPe.add(Number(x.legacy_payment_id))
const f_balance = r2(invs.reduce((s, i) => s + Math.max(0, Number(i.total_amt) - Number(i.billed_amt)), 0))
const f_invoiced = r2(invs.reduce((s, i) => s + Number(i.total_amt), 0))
const f_paid = r2(pays.reduce((s, p) => s + Number(p.amount), 0))
return {
account_id: acct, customer,
f_summary: { invoices: invs.length, payments: pays.length, total_invoiced: f_invoiced, total_paid: f_paid, balance_owed: f_balance },
erp_mirror: { payments_in_erp: erpPe.size, payments_missing: pays.length - erpPe.size },
invoices: invs.slice(0, 60).map(i => ({ id: i.id, sinv: sinvName(i.id), date: tsToDate(i.date_orig), total: r2(i.total_amt), billed: r2(i.billed_amt), owed: r2(Math.max(0, Number(i.total_amt) - Number(i.billed_amt))), paid_in_F: Number(i.billing_status) === 1 })),
payments: pays.slice(0, 60).map(p => ({ id: p.id, pe: peName(p.id), date: tsToDate(p.date_orig), amount: r2(p.amount), type: p.type, mirrored_in_erp: erpPe.has(Number(p.id)) })),
note: 'F est la source de vérité du solde (total_amt billed_amt). ERPNext est une copie du LOG de paiement.',
}
}
// ── APPLY (gardé) — MIROIR du log de paiement uniquement (PE + PER), idempotent ──
// N'écrit QUE si confirm==='F-WINS'. Reproduit EXACTEMENT le schéma des 542k PE
// existants (PE-{zfill10}, mode_of_payment, paid_from/paid_to, docstatus=1, refs
// vers SINV existantes). N'écrit PAS GL/PLE/outstanding (couche figée — bascule à décider).
// Sans confirm → dry-run : renvoie le plan (échantillon des lignes qui seraient créées).
async function syncPayments ({ confirm = false, since = null, limit = 5000, window = 0 } = {}) {
const pre = await previewPayments({ since, limit, window })
const plan = {
mode: 'miroir LOG paiement (PE+PER) — GL/PLE/outstanding NON touchés',
ar_frozen: pre.ar_state.ar_frozen,
since_payment_id: pre.watermark.since_payment_id,
scanned: pre.delta.scanned, would_create: pre.delta.would_create, already_mirrored: pre.delta.already_mirrored,
total_amount: pre.delta.total_amount, by_type: pre.delta.by_type, issues: pre.issues,
sample: pre.samples.to_create.slice(0, 10),
}
if (confirm !== 'F-WINS') {
return { dry_run: true, ...plan, note: "POST { confirm:'F-WINS' } pour écrire le miroir (PE+PER). limit limite le lot. since force le plancher d'id." }
}
// ── écriture idempotente, transactionnelle ──
const mp = myPool()
const [prows] = await mp.query('SELECT id, account_id, date_orig, amount, type, reference, memo FROM payment WHERE id > ? ORDER BY id LIMIT ?', [pre.watermark.since_payment_id, limit])
const ids = prows.map(p => p.id)
const itemsByPay = {}
if (ids.length) { const [ir] = await mp.query(`SELECT payment_id, invoice_id, amount FROM payment_item WHERE payment_id IN (${ids.map(() => '?').join(',')})`, ids); for (const it of ir) (itemsByPay[it.payment_id] || (itemsByPay[it.payment_id] = [])).push(it) }
// index customer + factures existantes
const acctIds = [...new Set(prows.map(p => Number(p.account_id)))]
const custMap = {}
for (let i = 0; i < acctIds.length; i += 1000) { const b = acctIds.slice(i, i + 1000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x }
const invIds = [...new Set(Object.values(itemsByPay).flat().map(it => Number(it.invoice_id)).filter(Boolean))]
const invSet = new Set()
for (let i = 0; i < invIds.length; i += 2000) { const b = invIds.slice(i, i + 2000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE legacy_invoice_id = ANY($1)', [b])) invSet.add(Number(x.legacy_invoice_id)) }
const existingPe = new Set()
if (ids.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [ids])) existingPe.add(Number(x.legacy_payment_id))
const client = await pgPool().connect()
let ok = 0, skip = 0, refOk = 0, err = 0; const errors = []; let maxId = pre.watermark.since_payment_id
try {
for (const p of prows) {
maxId = Math.max(maxId, Number(p.id))
if (existingPe.has(Number(p.id))) { skip++; continue }
const cust = custMap[Number(p.account_id)]
if (!cust) { skip++; continue }
const amt = r2(Math.abs(p.amount)); if (amt <= 0) { skip++; continue }
const posting = tsToDate(p.date_orig) || new Date().toISOString().slice(0, 10)
const mode = modeFor(p.type)
const ref = String(p.reference || '').slice(0, 140)
const memo = String(p.memo || '').slice(0, 140)
try {
await client.query('BEGIN')
await client.query(
`INSERT INTO "tabPayment Entry" (name, owner, creation, modified, modified_by, docstatus, posting_date, company,
payment_type, party_type, party, party_name, paid_from, paid_from_account_currency, paid_to, paid_to_account_currency,
paid_amount, base_paid_amount, received_amount, base_received_amount, source_exchange_rate, target_exchange_rate,
mode_of_payment, reference_no, reference_date, status, remarks, legacy_payment_id)
VALUES ($1,'Administrator',NOW(),NOW(),'Administrator',1,$2,'TARGO',
'Receive','Customer',$3,$4,$5,'CAD',$6,'CAD',
$7,$7,$7,$7,1,1,
$8,$9,$2,'Submitted',$10,$11)
ON CONFLICT (name) DO NOTHING`,
[peName(p.id), posting, cust.name, cust.customer_name, PAID_FROM, PAID_TO, amt, mode, ref || null, memo || null, Number(p.id)])
let j = 0
for (const it of (itemsByPay[p.id] || [])) {
if (!invSet.has(Number(it.invoice_id))) continue
await client.query(
`INSERT INTO "tabPayment Entry Reference" (name, owner, creation, modified, modified_by, docstatus, idx,
parent, parentfield, parenttype, reference_doctype, reference_name, allocated_amount, exchange_rate)
VALUES ($1,'Administrator',NOW(),NOW(),'Administrator',1,$2,$3,'references','Payment Entry','Sales Invoice',$4,$5,1)
ON CONFLICT (name) DO NOTHING`,
[`PER-${p.id}-${j}`, j + 1, peName(p.id), sinvName(it.invoice_id), r2(Math.abs(it.amount))])
j++; refOk++
}
await client.query('COMMIT')
ok++
} catch (e) { await client.query('ROLLBACK'); err++; if (errors.length < 20) errors.push({ payment_id: p.id, err: String(e.message).slice(0, 200) }) }
}
} finally { client.release() }
const st = loadState(); st.payment_max_id = Math.max(Number(st.payment_max_id) || 0, maxId); st.last_run = new Date().toISOString(); saveState(st)
log(`legacy-payments syncPayments: ${ok} PE créés, ${refOk} refs, ${skip} ignorés, ${err} err, watermark→${maxId}`)
return { applied: ok, references: refOk, skipped: skip, errors: err, error_samples: errors, watermark: st.payment_max_id, by_type: pre.delta.by_type }
}
// ── RAFRAÎCHIR LE SOLDE DES FACTURES VIVANTES OUVERTES (F autoritaire) ────────
// Borné + léger : ne recheck que les factures docstatus=1 Unpaid/Overdue (seules
// dont le statut peut basculer vers Paid quand F encaisse). Re-fetch F billed_amt
// par id (WHERE id IN ...). Met à jour outstanding + status d'après F. Sans confirm → dry-run.
async function refreshOpenInvoices ({ confirm = false, limit = 20000 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
const open = await pgq(`SELECT name, legacy_invoice_id, grand_total, due_date FROM "tabSales Invoice"
WHERE docstatus=1 AND legacy_invoice_id>0 AND status IN ('Unpaid','Overdue') LIMIT ${Number(limit)}`)
if (!open.length) return { open: 0, would_update: 0, note: 'aucune facture vivante ouverte' }
const ids = open.map(r => Number(r.legacy_invoice_id))
const fmap = {}
for (let i = 0; i < ids.length; i += 2000) {
const b = ids.slice(i, i + 2000); if (!b.length) continue
const [fr] = await mp.query(`SELECT id, total_amt, billed_amt FROM invoice WHERE id IN (${b.map(() => '?').join(',')})`, b)
for (const f of fr) fmap[f.id] = f
}
const td = TODAY(); const changes = []
for (const inv of open) {
const f = fmap[Number(inv.legacy_invoice_id)]; if (!f) continue
const owed = r2(Math.max(0, Number(f.total_amt) - Number(f.billed_amt)))
const due = (inv.due_date && String(inv.due_date).slice(0, 10)) || td
let status = owed <= 0.005 ? 'Paid' : (due < td ? 'Overdue' : 'Unpaid')
if (Math.abs(owed - r2(inv.grand_total)) < 0.005 && owed > 0) { /* still fully open */ }
const curOwed = null // we don't have current outstanding in this query; update unconditionally if status/owed differ
changes.push({ name: inv.name, owed, status })
}
if (confirm !== 'F-WINS') return { dry_run: true, open: open.length, candidates: changes.length, sample: changes.slice(0, 8) }
const client = await pgPool().connect(); let upd = 0
try {
for (const c of changes) {
const r = await client.query('UPDATE "tabSales Invoice" SET outstanding_amount=$1, status=$2 WHERE name=$3 AND docstatus=1 AND (ROUND(outstanding_amount::numeric,2)<>$1 OR status<>$2)', [c.owed, c.status, c.name])
upd += r.rowCount
}
} finally { client.release() }
log(`legacy-payments refreshOpenInvoices: ${upd} factures mises à jour (sur ${open.length} ouvertes)`)
return { open: open.length, updated: upd }
}
// ── FERMER LA CHAÎNE : créer les Customers manquants derrière la facturation ──
// Racine des « sans client » : des comptes F (recent billing) absents d'ERPNext.
// Création CIBLÉE (seulement les comptes ayant des factures/paiements non synchronisés
// dans la fenêtre) via erp.create (REST → naming/validations/defaults gérés), idempotent
// par legacy_account_id, en réutilisant mapAccount de legacy-sync (même mapping que #62).
// Délègue au créateur CANONIQUE (legacy-sync.createCustomersByIds) — un seul chemin de création.
// Ici on ne fait que CIBLER : les comptes référencés par la facturation récente (fenêtre) absents d'ERPNext.
async function ensureBillingCustomers ({ confirm = false, window = 20000, limit = 1000 } = {}) {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
const invMax = Number((await pgq('SELECT COALESCE(MAX(legacy_invoice_id),0) mx FROM "tabSales Invoice"'))[0].mx) || 0
const payMax = Number((await pgq('SELECT COALESCE(MAX(legacy_payment_id),0) mx FROM "tabPayment Entry"'))[0].mx) || 0
const [ia] = await mp.query('SELECT DISTINCT account_id FROM invoice WHERE id > ?', [Math.max(0, invMax - Number(window))])
const [pa] = await mp.query('SELECT DISTINCT account_id FROM payment WHERE id > ?', [Math.max(0, payMax - Number(window))])
const acctIds = [...new Set([...ia, ...pa].map(r => Number(r.account_id)).filter(Boolean))]
const res = await require('./legacy-sync').createCustomersByIds({ ids: acctIds, confirm, limit }) // créateur canonique
return { referenced: acctIds.length, ...res }
}
// ── CYCLE DE SYNC (paiements + rafraîchissement soldes) — pour le cron ────────
// Note : l'insertion des FACTURES (Python sync_invoices_incremental.py) tourne en amont
// dans le même cycle cron (cf. /opt/targo-sync/run.sh). Ici : volet hub (paiements + soldes).
async function syncCycle ({ confirm = false, limit = 20000, window = 5000 } = {}) {
const pay = await syncPayments({ confirm, limit, window }) // window → comble les trous récents (numéros sautés)
const refresh = await refreshOpenInvoices({ confirm, limit })
const out = { ran_at: new Date().toISOString(), payments: pay, open_refresh: refresh }
if (confirm === 'F-WINS') { try { fs.writeFileSync(RUN_FILE, JSON.stringify({ ran_at: out.ran_at, payments_applied: pay.applied || 0, references: pay.references || 0, refreshed: refresh.updated || 0, errors: pay.errors || 0 }, null, 2)) } catch (e) {} }
return out
}
// ── ÉTAT DE COHÉRENCE (santé) — instantané, léger (MAX(id) indexé + agrégats PG) ──
// Pour un tableau de bord : où en est ERPNext vs F + dernier cycle. Ne scanne PAS F.
async function coherenceStatus () {
const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
const erp = (await pgq(`SELECT
(SELECT COALESCE(MAX(legacy_invoice_id),0) FROM "tabSales Invoice") inv_max,
(SELECT COUNT(*) FROM "tabSales Invoice" WHERE docstatus=1 AND legacy_invoice_id>638609) inv_live,
(SELECT COALESCE(SUM(outstanding_amount),0) FROM "tabSales Invoice" WHERE docstatus=1 AND status IN ('Unpaid','Overdue')) ar_open,
(SELECT COALESCE(MAX(legacy_payment_id),0) FROM "tabPayment Entry") pay_max,
(SELECT COUNT(*) FROM "tabPayment Entry") pay_count`))[0]
const [[fi]] = await mp.query('SELECT MAX(id) mx FROM invoice') // instantané (PK)
const [[fp]] = await mp.query('SELECT MAX(id) mx FROM payment')
const lagInv = Number(fi.mx) - Number(erp.inv_max)
const lagPay = Number(fp.mx) - Number(erp.pay_max)
let lastRun = null; try { lastRun = JSON.parse(fs.readFileSync(RUN_FILE, 'utf8')) } catch {}
const ranAgoMin = lastRun && lastRun.ran_at ? Math.round((Date.now() - Date.parse(lastRun.ran_at)) / 60000) : null
return {
checked_at: new Date().toISOString(),
invoices: { f_max_id: Number(fi.mx), erp_max_synced: Number(erp.inv_max), lag: lagInv, live_synced: Number(erp.inv_live), ar_open: r2(erp.ar_open) },
payments: { f_max_id: Number(fp.mx), erp_max_synced: Number(erp.pay_max), lag: lagPay, total: Number(erp.pay_count) },
last_cycle: lastRun, last_cycle_min_ago: ranAgoMin,
healthy: ranAgoMin != null && ranAgoMin < 90, // cron horaire → < 90 min = sain
note: 'lag = numéros F créés depuis le dernier cycle (se résorbe à l\'heure suivante). Cron horaire :15.',
}
}
// ── routes HTTP (gated ; preview = sûr) ───────────────────────────────────────
const { parseBody } = require('./helpers')
async function handle (req, res, method, p, url) {
if (p === '/legacy-payments/preview' && method === 'GET') {
try { const since = url && url.searchParams.get('since'); const limit = url && url.searchParams.get('limit'); return json(res, 200, await previewPayments({ since: since != null ? Number(since) : null, limit: limit ? Number(limit) : 20000 })) }
catch (e) { log('legacy-payments preview error:', e.message); return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/report' && method === 'GET') {
try { return json(res, 200, JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))) } catch { return json(res, 404, { error: 'aucun rapport — lancer /legacy-payments/preview' }) }
}
if (p === '/legacy-payments/invoices-preview' && method === 'GET') {
try { const since = url && url.searchParams.get('since'); const limit = url && url.searchParams.get('limit'); return json(res, 200, await previewInvoices({ since: since != null ? Number(since) : null, limit: limit ? Number(limit) : 30000 })) }
catch (e) { log('legacy-payments invoices-preview error:', e.message); return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/billing-gap' && method === 'GET') {
try { return json(res, 200, await billingGap()) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/reconcile-summary' && method === 'GET') {
try { const fresh = url && (url.searchParams.get('fresh') === '1'); return json(res, 200, await reconcileSummary({ fresh })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/reconcile' && method === 'GET') {
try { const acct = url && url.searchParams.get('account'); const cust = url && url.searchParams.get('customer'); return json(res, 200, await reconcileAccount({ accountId: acct ? Number(acct) : null, customer: cust || null })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/run' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await syncPayments({ confirm: b.confirm, since: b.since != null ? Number(b.since) : null, limit: b.limit ? Number(b.limit) : 5000 })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/ensure-customers' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await ensureBillingCustomers({ confirm: b.confirm, window: b.window ? Number(b.window) : 20000, limit: b.limit ? Number(b.limit) : 1000 })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/refresh-open' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await refreshOpenInvoices({ confirm: b.confirm, limit: b.limit ? Number(b.limit) : 20000 })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/sync-cycle' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await syncCycle({ confirm: b.confirm, limit: b.limit ? Number(b.limit) : 20000 })) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/coherence' && method === 'GET') {
try { return json(res, 200, await coherenceStatus()) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (p === '/legacy-payments/state' && method === 'GET') return json(res, 200, loadState())
return json(res, 404, { error: 'route legacy-payments inconnue' })
}
module.exports = { handle, previewPayments, previewInvoices, billingGap, reconcileSummary, reconcileAccount, syncPayments, refreshOpenInvoices, ensureBillingCustomers, syncCycle, coherenceStatus, arState, MODE_MAP, modeFor, peName, sinvName }