/** * Backfill de `fecha_emision` (y opcionalmente `fecha_cert_sat`) para CFDIs * sincronizados antes del fix de zona horaria. El parser convertía la fecha * del XML ("2025-12-31T18:37:51") asumiéndola como hora local de la máquina * y la guardaba en UTC ("2026-01-01T00:37:51Z"), corriendo 6 horas y a veces * sacando el CFDI de su mes/año correcto. * * Re-parsea la fecha literal del XML (atributo `Fecha=""` del Comprobante y * `FechaTimbrado=""` del TimbreFiscalDigital) y lo guarda como UTC-literal * (forzando 'Z' al string del XML). * * Solo aplica a CFDIs con `xml_original IS NOT NULL`. Idempotente. * * Uso: * pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts # ejecuta * pnpm --filter @horux/api exec tsx scripts/backfill-fechas-tz.ts --dry # reporta */ import { prisma, tenantDb } from '../src/config/database.js'; const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run'); function parseLiteral(str: string | null | undefined): Date | null { if (!str) return null; const s = String(str).trim(); if (!s) return null; const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s); return new Date(hasTz ? s : s + 'Z'); } function extractFechaFromXml(xml: string): string | null { // Atributo Fecha del root const m = xml.match(/]*\bFecha="([^"]+)"/); return m ? m[1] : null; } function extractFechaTimbradoFromXml(xml: string): string | null { const m = xml.match(/]*\bFechaTimbrado="([^"]+)"/); return m ? m[1] : null; } interface PerTenantResult { tenantId: string; rfc: string; databaseName: string; scanned: number; updatedFechaEmision: number; updatedFechaCert: number; noChange: number; noXmlMatch: number; error?: string; } async function backfillTenant(tenantId: string, rfc: string, databaseName: string): Promise { const result: PerTenantResult = { tenantId, rfc, databaseName, scanned: 0, updatedFechaEmision: 0, updatedFechaCert: 0, noChange: 0, noXmlMatch: 0, }; const pool = await tenantDb.getPool(tenantId, databaseName); const { rows } = await pool.query<{ id: number; uuid: string; fecha_emision: Date; fecha_cert_sat: Date | null; xml_original: string; }>( `SELECT id, uuid, fecha_emision, fecha_cert_sat, xml_original FROM cfdis WHERE xml_original IS NOT 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) { const fechaXml = extractFechaFromXml(row.xml_original); const fechaTimbradoXml = extractFechaTimbradoFromXml(row.xml_original); if (!fechaXml) { result.noXmlMatch++; continue; } const nuevaFecha = parseLiteral(fechaXml); const nuevaFechaCert = fechaTimbradoXml ? parseLiteral(fechaTimbradoXml) : null; if (!nuevaFecha) { result.noXmlMatch++; continue; } const fechaEmisionActual = row.fecha_emision?.toISOString(); const fechaCertActual = row.fecha_cert_sat?.toISOString(); const fechaEmisionNueva = nuevaFecha.toISOString(); const fechaCertNueva = nuevaFechaCert?.toISOString(); let updatedThis = false; if (fechaEmisionActual !== fechaEmisionNueva) { await client.query( `UPDATE cfdis SET fecha_emision = $2 WHERE id = $1`, [row.id, nuevaFecha], ); result.updatedFechaEmision++; updatedThis = true; } if (nuevaFechaCert && fechaCertActual !== fechaCertNueva) { await client.query( `UPDATE cfdis SET fecha_cert_sat = $2 WHERE id = $1`, [row.id, nuevaFechaCert], ); result.updatedFechaCert++; updatedThis = true; } if (!updatedThis) result.noChange++; } 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 fechas (fecha_emision + fecha_cert_sat) ${DRY_RUN ? '(DRY RUN)' : ''} ===\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.scanned === 0) console.log(`sin XMLs (skip)`); else console.log( `scan=${r.scanned} upd_emision=${r.updatedFechaEmision} upd_cert=${r.updatedFechaCert} ` + `sin_cambio=${r.noChange} sin_match=${r.noXmlMatch}${DRY_RUN ? ' (rolled back)' : ''}`, ); } catch (err: any) { console.log(`FATAL: ${err?.message || err}`); } } const totalScan = results.reduce((s, r) => s + r.scanned, 0); const totalUpdEm = results.reduce((s, r) => s + r.updatedFechaEmision, 0); const totalUpdCert = results.reduce((s, r) => s + r.updatedFechaCert, 0); const tFail = results.filter(r => r.error).length; console.log(`\n=== Resumen ===`); console.log(` Tenants procesados: ${results.length}`); console.log(` CFDIs escaneados: ${totalScan}`); console.log(` fecha_emision actualizada: ${totalUpdEm}`); console.log(` fecha_cert_sat actualizada: ${totalUpdCert}`); if (tFail > 0) console.log(` Tenants con error: ${tFail}`); await prisma.$disconnect(); process.exit(tFail > 0 ? 1 : 0); } main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });