/** * Backfill de cfdis.cfdi_tipo_relacion + cfdis.cfdis_relacionados desde * xml_original para CFDIs pre-migración 032. * * Criterio: WHERE xml_original IS NOT NULL AND cfdi_tipo_relacion IS NULL. * Re-usa `parseXml()` para mantener la lógica de extracción idéntica al sync. * Solo escribe si el parser extrae `cfdiTipoRelacion` no-nulo — los CFDIs sin * CfdiRelacionados se siguen dejando con NULL (distinguible de "no procesado" * via el filtro `cfdi_tipo_relacion IS NULL` porque el WHERE al final del run * ya no los va a volver a tocar — pero cada invocación empieza desde el mismo * filtro, por eso es idempotente: los sin-relación se re-parsean cada vez pero * no se escribe nada). * * Uso: * pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts # ejecuta * pnpm --filter @horux/api exec tsx scripts/backfill-cfdis-relaciones.ts --dry # reporta sin escribir */ import { prisma, tenantDb } from '../src/config/database.js'; import { parseXml } from '../src/services/sat/sat-parser.service.js'; const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run'); interface PerTenantResult { tenantId: string; rfc: string; databaseName: string; scanned: number; parsedOk: number; parseFailed: number; withRelation: number; updated: number; byTipoRelacion: Record; error?: string; } async function backfillTenant( tenantId: string, rfc: string, databaseName: string, ): Promise { const result: PerTenantResult = { tenantId, rfc, databaseName, scanned: 0, parsedOk: 0, parseFailed: 0, withRelation: 0, updated: 0, byTipoRelacion: {}, }; const pool = await tenantDb.getPool(tenantId, databaseName); const { rows } = await pool.query<{ id: number; uuid: string; type: string; xml_original: string | null; }>( `SELECT id, uuid, type, xml_original FROM cfdis WHERE xml_original IS NOT NULL AND cfdi_tipo_relacion IS NULL ORDER BY id`, ); result.scanned = rows.length; if (rows.length === 0) return result; const client = await pool.connect(); try { await client.query('BEGIN'); for (const row of rows) { if (!row.xml_original) continue; const downloadType = row.type === 'EMITIDO' ? 'emitidos' : 'recibidos'; let parsed; try { parsed = parseXml(row.xml_original, downloadType); } catch { result.parseFailed++; continue; } if (!parsed) { result.parseFailed++; continue; } result.parsedOk++; if (!parsed.cfdiTipoRelacion) continue; result.withRelation++; const tr = parsed.cfdiTipoRelacion; result.byTipoRelacion[tr] = (result.byTipoRelacion[tr] || 0) + 1; await client.query( `UPDATE cfdis SET cfdi_tipo_relacion = $2, cfdis_relacionados = $3, actualizado_en = NOW() WHERE id = $1`, [row.id, parsed.cfdiTipoRelacion, parsed.cfdisRelacionados], ); result.updated++; } 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 CfdiRelacionados ${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.scanned === 0) { console.log(`sin CFDIs candidatos (skip)`); } else { const tiposStr = Object.entries(r.byTipoRelacion) .sort((a, b) => b[1] - a[1]) .map(([tr, n]) => `${tr}:${n}`) .join(', '); console.log( `scan=${r.scanned} parsed=${r.parsedOk} fail=${r.parseFailed} rel=${r.withRelation} upd=${r.updated}${ tiposStr ? ` [${tiposStr}]` : '' }`, ); } } catch (err: any) { console.log(`FATAL: ${err?.message || err}`); results.push({ tenantId: t.id, rfc: t.rfc, databaseName: t.databaseName, scanned: 0, parsedOk: 0, parseFailed: 0, withRelation: 0, updated: 0, byTipoRelacion: {}, error: err?.message || String(err), }); } } const totalScanned = results.reduce((s, r) => s + r.scanned, 0); const totalUpdated = results.reduce((s, r) => s + r.updated, 0); const totalParseFailed = results.reduce((s, r) => s + r.parseFailed, 0); const tenantsTouched = results.filter(r => r.updated > 0).length; const tenantsFailed = results.filter(r => r.error).length; const tiposGlobales: Record = {}; for (const r of results) { for (const [tr, n] of Object.entries(r.byTipoRelacion)) { tiposGlobales[tr] = (tiposGlobales[tr] || 0) + n; } } console.log(`\n=== Resumen ===`); console.log(` Tenants procesados: ${results.length}`); console.log(` Tenants con backfill: ${tenantsTouched}`); console.log(` CFDIs escaneados: ${totalScanned}`); console.log(` CFDIs actualizados: ${totalUpdated}${DRY_RUN ? ' (rolled back)' : ''}`); if (totalParseFailed > 0) console.log(` CFDIs parse falló: ${totalParseFailed}`); if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`); if (Object.keys(tiposGlobales).length > 0) { console.log(` Desglose TipoRelacion:`); for (const [tr, n] of Object.entries(tiposGlobales).sort((a, b) => b[1] - a[1])) { console.log(` ${tr}: ${n}`); } } await prisma.$disconnect(); process.exit(tenantsFailed > 0 ? 1 : 0); } main().catch(async (err) => { console.error('Fatal:', err); await prisma.$disconnect().catch(() => {}); process.exit(1); });