Files
HoruxDespachos/apps/api/scripts/validate-metricas.ts
2026-04-27 22:09:36 -06:00

161 lines
6.3 KiB
TypeScript

/**
* Validación Tanda A: para cada contribuyente con datos en metricas_mensuales,
* toma 5 filas al azar y compara contra el cálculo on-the-fly usando los
* servicios canónicos (dashboard, impuestos). Reporta PASS/FAIL por celda.
*
* Uso:
* pnpm --filter @horux/api exec tsx scripts/validate-metricas.ts
*/
import { prisma, tenantDb } from '../src/config/database.js';
import {
calcularIngresosPorRegimen,
calcularEgresosPorRegimen,
} from '../src/services/dashboard.service.js';
import { getResumenIva } from '../src/services/impuestos.service.js';
const TOL = 0.01; // tolerancia de $0.01 para redondeo decimal
interface StoredRow {
contribuyente_id: string;
anio: number;
mes: number;
regimen_fiscal: string | null;
ingresos_cobrados: string;
egresos_pagados: string;
iva_trasladado_total: string;
iva_acreditable: string;
iva_retenido_cobrado: string;
iva_resultado: string;
cfdis_emitidos_count: number;
cfdis_recibidos_count: number;
cfdis_cancelados_count: number;
}
function cmp(a: number, b: number): boolean {
return Math.abs(a - b) <= TOL;
}
function fmt(n: number): string {
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
async function validateRow(
tenantId: string,
row: StoredRow,
): Promise<{ pass: boolean; diffs: string[] }> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant) return { pass: false, diffs: ['tenant no encontrado'] };
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const fi = `${row.anio}-${String(row.mes).padStart(2, '0')}-01`;
const lastDay = new Date(row.anio, row.mes, 0).getDate();
const ff = `${row.anio}-${String(row.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
// Ejecutamos secuencial para evitar interferencia entre queries bajo el pool
// limit del tenant (max 3 conexiones). Con Promise.all concurrente, algunas
// queries compartidas de getResumenIva devolvían valores parciales.
const ingresos = await calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
const egresos = await calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, row.contribuyente_id);
const resumenIva = await getResumenIva(pool, fi, ff, tenantId, false, row.contribuyente_id);
const reg = row.regimen_fiscal;
const ingOtf = ingresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const egrOtf = egresos.porRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const trasOtf = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const acrOtf = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const retOtf = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === reg)?.monto || 0;
const resOtf = trasOtf - acrOtf - retOtf;
const diffs: string[] = [];
const ingStored = Number(row.ingresos_cobrados);
const egrStored = Number(row.egresos_pagados);
const trasStored = Number(row.iva_trasladado_total);
const acrStored = Number(row.iva_acreditable);
const retStored = Number(row.iva_retenido_cobrado);
const resStored = Number(row.iva_resultado);
if (!cmp(ingStored, ingOtf)) diffs.push(`ingresos: tabla=${fmt(ingStored)} vs otf=${fmt(ingOtf)}`);
if (!cmp(egrStored, egrOtf)) diffs.push(`egresos: tabla=${fmt(egrStored)} vs otf=${fmt(egrOtf)}`);
if (!cmp(trasStored, trasOtf)) diffs.push(`ivaTras: tabla=${fmt(trasStored)} vs otf=${fmt(trasOtf)}`);
if (!cmp(acrStored, acrOtf)) diffs.push(`ivaAcr: tabla=${fmt(acrStored)} vs otf=${fmt(acrOtf)}`);
if (!cmp(retStored, retOtf)) diffs.push(`ivaRet: tabla=${fmt(retStored)} vs otf=${fmt(retOtf)}`);
if (!cmp(resStored, resOtf)) diffs.push(`ivaResultado: tabla=${fmt(resStored)} vs otf=${fmt(resOtf)}`);
return { pass: diffs.length === 0, diffs };
}
async function main() {
console.log('=== Validación metricas_mensuales (5 muestras aleatorias por contribuyente) ===\n');
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true, databaseName: true },
});
let totalMuestras = 0;
let totalPass = 0;
let totalFail = 0;
for (const t of tenants) {
const pool = await tenantDb.getPool(t.id, t.databaseName);
const { rows: contribs } = await pool.query<{ entidad_id: string; nombre: string }>(
`SELECT c.entidad_id, eg.nombre
FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE EXISTS (
SELECT 1 FROM metricas_mensuales m WHERE m.contribuyente_id = c.entidad_id
)`,
);
if (contribs.length === 0) continue;
console.log(`\n[${t.rfc}] ${contribs.length} contribuyentes con datos`);
for (const c of contribs) {
const { rows: samples } = await pool.query<StoredRow>(
`SELECT contribuyente_id::text, anio, mes, regimen_fiscal,
ingresos_cobrados, egresos_pagados,
iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado,
cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count
FROM metricas_mensuales
WHERE contribuyente_id = $1
ORDER BY random()
LIMIT 5`,
[c.entidad_id],
);
console.log(` ${c.nombre} (${samples.length} muestras):`);
for (const s of samples) {
totalMuestras++;
const { pass, diffs } = await validateRow(t.id, s);
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
const reg = s.regimen_fiscal || 'null';
if (pass) {
totalPass++;
console.log(`${mesLabel} reg=${reg} ingresos=$${fmt(Number(s.ingresos_cobrados))}`);
} else {
totalFail++;
console.log(`${mesLabel} reg=${reg} DIFFS:`);
for (const d of diffs) console.log(` - ${d}`);
}
}
}
}
console.log(`\n=== Resumen ===`);
console.log(` Muestras totales: ${totalMuestras}`);
console.log(` PASS: ${totalPass}`);
console.log(` FAIL: ${totalFail}`);
await prisma.$disconnect();
process.exit(totalFail > 0 ? 1 : 0);
}
main().catch(async (err) => {
console.error('Fatal:', err);
await prisma.$disconnect().catch(() => {});
process.exit(1);
});