'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 }