/** * Backfill de cfdis.contribuyente_id para los despachos. * * Asocia CFDIs huérfanos (contribuyente_id NULL) con el contribuyente cuyo RFC * coincide con rfc_emisor (si type='EMITIDO') o rfc_receptor (si type='RECIBIDO'). * * Causa raíz: retry path de sat.service.ts construía SyncContext sin * contribuyenteId (bug fixed 2026-04-20). * * Idempotente: solo actualiza filas con contribuyente_id IS NULL y match único * por RFC. Si no hay contribuyentes en el tenant (Horux360 clásico), no-op. * * Uso: * pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts # ejecuta * pnpm --filter @horux/api exec tsx scripts/backfill-cfdi-contribuyente.ts --dry # reporta sin escribir */ import { prisma, tenantDb } from '../src/config/database.js'; const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run'); interface PerTenantResult { tenantId: string; rfc: string; databaseName: string; contribuyentesCount: number; updated: number; perContribuyente: Array<{ rfc: string; entidadId: string; rows: number }>; error?: string; } async function backfillTenant( tenantId: string, rfc: string, databaseName: string, ): Promise { const result: PerTenantResult = { tenantId, rfc, databaseName, contribuyentesCount: 0, updated: 0, perContribuyente: [], }; const pool = await tenantDb.getPool(tenantId, databaseName); const { rows: contribs } = await pool.query<{ entidad_id: string; rfc: string }>( `SELECT entidad_id, rfc FROM contribuyentes`, ); result.contribuyentesCount = contribs.length; if (contribs.length === 0) return result; const client = await pool.connect(); try { await client.query('BEGIN'); const sql = ` UPDATE cfdis c SET contribuyente_id = cnt.entidad_id FROM contribuyentes cnt WHERE c.contribuyente_id IS NULL AND ( (c.type = 'EMITIDO' AND cnt.rfc = c.rfc_emisor) OR (c.type = 'RECIBIDO' AND cnt.rfc = c.rfc_receptor) ) RETURNING cnt.entidad_id as "entidadId", cnt.rfc as "rfcContrib" `; const { rows: updated } = await client.query<{ entidadId: string; rfcContrib: string }>(sql); result.updated = updated.length; const byContrib = new Map(); for (const row of updated) { const cur = byContrib.get(row.entidadId); if (cur) cur.rows += 1; else byContrib.set(row.entidadId, { rfc: row.rfcContrib, rows: 1 }); } result.perContribuyente = Array.from(byContrib.entries()).map(([entidadId, v]) => ({ entidadId, rfc: v.rfc, rows: v.rows, })); 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; } async function main() { console.log(`=== Backfill cfdis.contribuyente_id ${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}] (${t.databaseName}) ... `); 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.contribuyentesCount === 0) { console.log(`sin contribuyentes (skip)`); } else { console.log(`${r.contribuyentesCount} contribs, ${r.updated} CFDIs backfill`); for (const pc of r.perContribuyente) { console.log(` ${pc.rfc}: ${pc.rows}`); } } } catch (err: any) { console.log(`FATAL: ${err?.message || err}`); results.push({ tenantId: t.id, rfc: t.rfc, databaseName: t.databaseName, contribuyentesCount: 0, updated: 0, perContribuyente: [], error: err?.message || String(err), }); } } const totalUpdated = results.reduce((s, r) => s + r.updated, 0); const tenantsTouched = results.filter(r => r.updated > 0).length; const tenantsFailed = results.filter(r => r.error).length; console.log(`\n=== Resumen ===`); console.log(` Tenants procesados: ${results.length}`); console.log(` Tenants con backfill: ${tenantsTouched}`); console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`); 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); });