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>
583 lines
40 KiB
JavaScript
583 lines
40 KiB
JavaScript
'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 }
|