/** * Backfill de `saldo_pendiente_mxn` para CFDIs I PPD vigentes. Computa el * saldo con la fórmula centralizada en `utils/saldo.ts` (pagos P + NC no-07 * + anticipo aplicado si es I/07) y lo persiste. * * Idempotente: corrido varias veces produce el mismo resultado. Safe para * repetir después de un sync SAT masivo o si se sospecha drift. * * Uso: * pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts # ejecuta * pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts --dry # reporta sin escribir */ import { prisma, tenantDb } from '../src/config/database.js'; import { saldoComputadoExpr } from '../src/utils/saldo.js'; const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run'); interface PerTenantResult { tenantId: string; rfc: string; databaseName: string; iPpdsVigentes: number; actualizadas: number; saldoTotalAntes: number; saldoTotalDespues: number; error?: string; } async function backfillTenant( tenantId: string, rfc: string, databaseName: string, ): Promise { const result: PerTenantResult = { tenantId, rfc, databaseName, iPpdsVigentes: 0, actualizadas: 0, saldoTotalAntes: 0, saldoTotalDespues: 0, }; const pool = await tenantDb.getPool(tenantId, databaseName); const { rows: count } = await pool.query<{ n: number; suma: string }>( `SELECT COUNT(*)::int AS n, COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma FROM cfdis WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD' AND status NOT IN ('Cancelado', '0')`, ); result.iPpdsVigentes = count[0]?.n || 0; result.saldoTotalAntes = Number(count[0]?.suma || 0); if (result.iPpdsVigentes === 0) return result; const client = await pool.connect(); try { await client.query('BEGIN'); // UPDATE masivo con la fórmula centralizada (misma que hooks y reporte). const expr = saldoComputadoExpr('c'); const { rowCount } = await client.query( `UPDATE cfdis c SET saldo_pendiente_mxn = ${expr} WHERE c.tipo_comprobante = 'I' AND c.metodo_pago = 'PPD' AND c.status NOT IN ('Cancelado', '0')`, ); result.actualizadas = rowCount ?? 0; const { rows: cntDespues } = await client.query<{ suma: string }>( `SELECT COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma FROM cfdis WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD' AND status NOT IN ('Cancelado', '0')`, ); result.saldoTotalDespues = Number(cntDespues[0]?.suma || 0); if (DRY_RUN) { await client.query('ROLLBACK'); } else { await client.query('COMMIT'); } } catch (err: any) { await client.query('ROLLBACK').catch(() => {}); result.error = err?.message || String(err); } finally { client.release(); } return result; } function fmt(n: number): string { return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } async function main() { console.log(`=== Backfill saldo_pendiente_mxn ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===\n`); const tenants = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true }, orderBy: { rfc: 'asc' }, }); console.log(`Tenants activos: ${tenants.length}\n`); const results: PerTenantResult[] = []; for (const t of tenants) { process.stdout.write(`[${t.rfc}] ... `); try { const r = await backfillTenant(t.id, t.rfc, t.databaseName); results.push(r); if (r.error) { console.log(`ERROR: ${r.error}`); } else if (r.iPpdsVigentes === 0) { console.log(`sin I PPD vigentes (skip)`); } else { const delta = r.saldoTotalDespues - r.saldoTotalAntes; console.log( `I_PPD=${r.iPpdsVigentes} upd=${r.actualizadas} ` + `antes=${fmt(r.saldoTotalAntes)} despues=${fmt(r.saldoTotalDespues)} ` + `Δ=${delta >= 0 ? '+' : ''}${fmt(delta)}${DRY_RUN ? ' (rolled back)' : ''}`, ); } } catch (err: any) { console.log(`FATAL: ${err?.message || err}`); results.push({ tenantId: t.id, rfc: t.rfc, databaseName: t.databaseName, iPpdsVigentes: 0, actualizadas: 0, saldoTotalAntes: 0, saldoTotalDespues: 0, error: err?.message || String(err), }); } } const totalI = results.reduce((s, r) => s + r.iPpdsVigentes, 0); const totalAntes = results.reduce((s, r) => s + r.saldoTotalAntes, 0); const totalDespues = results.reduce((s, r) => s + r.saldoTotalDespues, 0); const tenantsFailed = results.filter(r => r.error).length; console.log(`\n=== Resumen ===`); console.log(` Tenants procesados: ${results.length}`); console.log(` I PPD vigentes total: ${totalI}`); console.log(` Saldo total antes: ${fmt(totalAntes)}`); console.log(` Saldo total después: ${fmt(totalDespues)}${DRY_RUN ? ' (rolled back)' : ''}`); console.log(` Delta (recuperado): ${fmt(totalAntes - totalDespues)} (saldo que ya no está pendiente)`); if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`); await prisma.$disconnect(); process.exit(tenantsFailed > 0 ? 1 : 0); } main().catch(async (err) => { console.error('Fatal:', err); await prisma.$disconnect().catch(() => {}); process.exit(1); });