/** * Invalida TODAS las entradas en `metricas_mensuales` — marca para recompute * cada (contribuyente_id, anio, mes) que tenga datos cacheados. Diseñado para * usarse después de un cambio de fórmula que afecta resultados históricos * (ej. 2026-04-23: NC tipo E con TipoRelacion=07 dejan de restar en Grupo 1). * * El cron `metricas-invalidations.job` (cada 15min) procesa el backlog. * Para acelerar: `pnpm --filter @horux/api exec tsx -e "import { runProcessInvalidations } from './src/jobs/metricas-invalidations.job.js'; runProcessInvalidations().then(()=>process.exit(0))"` * * Uso: * pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.ts # ejecuta * pnpm --filter @horux/api exec tsx scripts/invalidate-metricas-all.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'); const REASON = process.argv.find(a => a.startsWith('--reason='))?.slice(9) || 'FORMULA_CHANGE_E07_GRUPO1'; interface PerTenantResult { tenantId: string; rfc: string; databaseName: string; metricasRows: number; marcadasNuevas: number; marcadasUpdate: number; error?: string; } async function invalidateTenant( tenantId: string, rfc: string, databaseName: string, ): Promise { const result: PerTenantResult = { tenantId, rfc, databaseName, metricasRows: 0, marcadasNuevas: 0, marcadasUpdate: 0, }; const pool = await tenantDb.getPool(tenantId, databaseName); // Cuenta filas existentes en metricas_mensuales para reportar const { rows: cnt } = await pool.query<{ n: number }>( `SELECT COUNT(DISTINCT (contribuyente_id, anio, mes))::int AS n FROM metricas_mensuales`, ); result.metricasRows = cnt[0]?.n || 0; if (result.metricasRows === 0) return result; const client = await pool.connect(); try { await client.query('BEGIN'); // Insert-or-update: si ya estaba marcada, sobrescribe reason y marcado_at // para que el cron la re-procese con el motivo correcto. const { rows: inserted } = await client.query<{ contribuyente_id: string; anio: number; mes: number; was_new: boolean; }>( ` INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason) SELECT DISTINCT contribuyente_id, anio, mes, $1 AS reason FROM metricas_mensuales ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE SET reason = EXCLUDED.reason, marcado_at = now() RETURNING contribuyente_id, anio, mes, (xmax = 0) AS was_new `, [REASON], ); result.marcadasNuevas = inserted.filter(r => r.was_new).length; result.marcadasUpdate = inserted.length - result.marcadasNuevas; 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(`=== Invalidate metricas_mensuales ${DRY_RUN ? '(DRY RUN — no writes)' : ''} ===`); console.log(`Reason: ${REASON}\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 invalidateTenant(t.id, t.rfc, t.databaseName); results.push(r); if (r.error) { console.log(`ERROR: ${r.error}`); } else if (r.metricasRows === 0) { console.log(`sin cache (skip)`); } else { console.log( `cache=${r.metricasRows} (contrib,año,mes), marcadas=${r.marcadasNuevas + r.marcadasUpdate} (nuevas=${r.marcadasNuevas}, re-marcadas=${r.marcadasUpdate})`, ); } } catch (err: any) { console.log(`FATAL: ${err?.message || err}`); results.push({ tenantId: t.id, rfc: t.rfc, databaseName: t.databaseName, metricasRows: 0, marcadasNuevas: 0, marcadasUpdate: 0, error: err?.message || String(err), }); } } const totalMetricas = results.reduce((s, r) => s + r.metricasRows, 0); const totalMarcadas = results.reduce((s, r) => s + r.marcadasNuevas + r.marcadasUpdate, 0); const tenantsTouched = results.filter(r => r.marcadasNuevas + r.marcadasUpdate > 0).length; const tenantsFailed = results.filter(r => r.error).length; console.log(`\n=== Resumen ===`); console.log(` Tenants procesados: ${results.length}`); console.log(` Tenants con cache: ${tenantsTouched}`); console.log(` Filas cache total: ${totalMetricas}`); console.log(` Invalidaciones: ${totalMarcadas}${DRY_RUN ? ' (rolled back)' : ''}`); if (tenantsFailed > 0) console.log(` Tenants con error: ${tenantsFailed}`); if (!DRY_RUN && totalMarcadas > 0) { console.log(`\nCron metricas-invalidations procesará el backlog en <=15 min.`); console.log(`Para disparar manual: runProcessInvalidations() desde un tsx -e ad-hoc.`); } await prisma.$disconnect(); process.exit(tenantsFailed > 0 ? 1 : 0); } main().catch(async (err) => { console.error('Fatal:', err); await prisma.$disconnect().catch(() => {}); process.exit(1); });