Update: nueva version Horux Despachos
This commit is contained in:
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal file
158
apps/api/scripts/backfill-cfdi-contribuyente.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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<PerTenantResult> {
|
||||
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<string, { rfc: string; rows: number }>();
|
||||
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);
|
||||
});
|
||||
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal file
209
apps/api/scripts/backfill-cfdis-relaciones.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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<string, number>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function backfillTenant(
|
||||
tenantId: string,
|
||||
rfc: string,
|
||||
databaseName: string,
|
||||
): Promise<PerTenantResult> {
|
||||
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<string, number> = {};
|
||||
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);
|
||||
});
|
||||
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal file
126
apps/api/scripts/backfill-facturapi-cfdis.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Backfill one-shot: completa los campos de emisor/subtotal/IVA/XML en las
|
||||
* filas de `cfdis` con `source='facturapi'` que fueron insertadas por la
|
||||
* versión buggy del controller (previo al fix 2026-04-24).
|
||||
*
|
||||
* Descarga el XML real de Facturapi, lo parsea con el mismo parser SAT,
|
||||
* upsertea la fila de `rfcs` del emisor, y actualiza la fila de `cfdis`.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { downloadXmlContribuyente } from '../src/services/contribuyente-facturapi.service.js';
|
||||
import * as facturapiService from '../src/services/facturapi.service.js';
|
||||
import { parseXml } from '../src/services/sat/sat-parser.service.js';
|
||||
|
||||
const TENANT_RFC = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) {
|
||||
console.log(`Tenant ${TENANT_RFC} no encontrado`);
|
||||
return;
|
||||
}
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const { rows: pendientes } = await pool.query<{
|
||||
id: number;
|
||||
uuid: string;
|
||||
facturapi_id: string;
|
||||
contribuyente_id: string | null;
|
||||
rfc_emisor: string | null;
|
||||
}>(
|
||||
`SELECT id, uuid, facturapi_id, contribuyente_id, rfc_emisor
|
||||
FROM cfdis
|
||||
WHERE source = 'facturapi'
|
||||
AND (COALESCE(rfc_emisor, '') = '' OR xml_original IS NULL OR subtotal = 0)
|
||||
ORDER BY fecha_emision ASC`,
|
||||
);
|
||||
|
||||
console.log(`Encontradas ${pendientes.length} CFDIs Facturapi a backfillear en ${TENANT_RFC}\n`);
|
||||
|
||||
let ok = 0;
|
||||
let fail = 0;
|
||||
|
||||
for (const row of pendientes) {
|
||||
try {
|
||||
console.log(`\n[${row.uuid}] facturapi_id=${row.facturapi_id} contrib=${row.contribuyente_id}`);
|
||||
|
||||
const xmlBuffer = row.contribuyente_id
|
||||
? await downloadXmlContribuyente(pool, row.contribuyente_id, row.facturapi_id)
|
||||
: await facturapiService.downloadXml(tenant.id, row.facturapi_id);
|
||||
const xmlString = xmlBuffer.toString('utf-8');
|
||||
const parsed = parseXml(xmlString, 'emitidos');
|
||||
if (!parsed) {
|
||||
console.log(` ⚠️ Parser retornó null — skip`);
|
||||
fail++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` emisor=${parsed.rfcEmisor} (${parsed.nombreEmisor}, régimen ${parsed.regimenFiscalEmisor})`);
|
||||
console.log(` receptor=${parsed.rfcReceptor} (${parsed.nombreReceptor}, régimen ${parsed.regimenFiscalReceptor})`);
|
||||
console.log(` subtotal=${parsed.subtotal} total=${parsed.total} iva_traslado=${parsed.ivaTraslado}`);
|
||||
|
||||
// Upsert rfcs emisor
|
||||
const { rows: [emisorRow] } = await pool.query(
|
||||
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (rfc) DO UPDATE SET
|
||||
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
|
||||
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
|
||||
RETURNING id`,
|
||||
[parsed.rfcEmisor, parsed.nombreEmisor || null, parsed.regimenFiscalEmisor || null],
|
||||
);
|
||||
// Upsert rfcs receptor
|
||||
const { rows: [receptorRow] } = await pool.query(
|
||||
`INSERT INTO rfcs (rfc, razon_social, regimen_fiscal) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (rfc) DO UPDATE SET
|
||||
razon_social = COALESCE(NULLIF($2, ''), rfcs.razon_social),
|
||||
regimen_fiscal = CASE WHEN $3 IS NOT NULL AND $3 != '' THEN $3 ELSE rfcs.regimen_fiscal END
|
||||
RETURNING id`,
|
||||
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null],
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`UPDATE cfdis SET
|
||||
fecha_cert_sat = $2,
|
||||
rfc_emisor_id = $3, rfc_emisor = $4, nombre_emisor = $5,
|
||||
regimen_fiscal_emisor = $6,
|
||||
rfc_receptor_id = $7, rfc_receptor = $8, nombre_receptor = $9,
|
||||
regimen_fiscal_receptor = $10,
|
||||
subtotal = $11, subtotal_mxn = $11,
|
||||
total = $12, total_mxn = $12,
|
||||
iva_traslado = $13, iva_traslado_mxn = $13,
|
||||
iva_retencion = $14, iva_retencion_mxn = $14,
|
||||
xml_original = $15,
|
||||
serie = COALESCE($16, serie), folio = COALESCE($17, folio)
|
||||
WHERE id = $1`,
|
||||
[
|
||||
row.id,
|
||||
parsed.fechaCertSat,
|
||||
emisorRow.id, parsed.rfcEmisor, parsed.nombreEmisor,
|
||||
parsed.regimenFiscalEmisor,
|
||||
receptorRow.id, parsed.rfcReceptor, parsed.nombreReceptor,
|
||||
parsed.regimenFiscalReceptor,
|
||||
parsed.subtotal,
|
||||
parsed.total,
|
||||
parsed.ivaTraslado,
|
||||
parsed.ivaRetencion,
|
||||
xmlString,
|
||||
parsed.serie, parsed.folio,
|
||||
],
|
||||
);
|
||||
|
||||
console.log(` ✅ actualizada fila id=${row.id}`);
|
||||
ok++;
|
||||
} catch (e: any) {
|
||||
console.log(` ❌ error: ${e?.message || String(e)}`);
|
||||
fail++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen: ${ok} actualizadas, ${fail} fallidas ===`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
174
apps/api/scripts/backfill-fechas-tz.ts
Normal file
174
apps/api/scripts/backfill-fechas-tz.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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 <cfdi:Comprobante Fecha="...">
|
||||
const m = xml.match(/<cfdi:Comprobante\b[^>]*\bFecha="([^"]+)"/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function extractFechaTimbradoFromXml(xml: string): string | null {
|
||||
const m = xml.match(/<tfd:TimbreFiscalDigital\b[^>]*\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<PerTenantResult> {
|
||||
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); });
|
||||
101
apps/api/scripts/backfill-metricas.ts
Normal file
101
apps/api/scripts/backfill-metricas.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Backfill de métricas mensuales pre-calculadas (Tanda A hot/cold).
|
||||
*
|
||||
* Itera todos los tenants activos, sus contribuyentes, y popula la tabla
|
||||
* `metricas_mensuales` con los agregados de años pasados (desde el CFDI más
|
||||
* antiguo hasta el año actual - 1). El año actual queda on-the-fly.
|
||||
*
|
||||
* Idempotente: usa upsert — re-correrlo no duplica filas, recalcula valores.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-metricas.ts --dry # dry-run
|
||||
*
|
||||
* Opciones via env:
|
||||
* BACKFILL_DESDE_ANIO=2023 # limita el rango inferior
|
||||
* BACKFILL_HASTA_ANIO=2024 # default: año actual - 1
|
||||
* BACKFILL_TENANT=<uuid> # procesa solo un tenant
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { backfillTenant } from '../src/services/metricas-compute.service.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
const TENANT_FILTER = process.env.BACKFILL_TENANT || null;
|
||||
const DESDE_ANIO = process.env.BACKFILL_DESDE_ANIO ? parseInt(process.env.BACKFILL_DESDE_ANIO, 10) : undefined;
|
||||
const HASTA_ANIO = process.env.BACKFILL_HASTA_ANIO ? parseInt(process.env.BACKFILL_HASTA_ANIO, 10) : undefined;
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Backfill metricas_mensuales ${DRY_RUN ? '(DRY RUN)' : ''} ===\n`);
|
||||
if (DESDE_ANIO) console.log(`Desde año: ${DESDE_ANIO}`);
|
||||
if (HASTA_ANIO) console.log(`Hasta año: ${HASTA_ANIO}`);
|
||||
if (TENANT_FILTER) console.log(`Tenant filtro: ${TENANT_FILTER}`);
|
||||
console.log();
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: {
|
||||
active: true,
|
||||
...(TENANT_FILTER ? { id: TENANT_FILTER } : {}),
|
||||
},
|
||||
select: { id: true, rfc: true, nombre: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
console.log(`Tenants activos: ${tenants.length}\n`);
|
||||
|
||||
let totalContribs = 0;
|
||||
let totalMeses = 0;
|
||||
let totalFilas = 0;
|
||||
let totalErrores = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
process.stdout.write(`[${t.rfc}] ${t.nombre} ... `);
|
||||
try {
|
||||
const r = await backfillTenant(t.id, {
|
||||
dryRun: DRY_RUN,
|
||||
desdeAnio: DESDE_ANIO,
|
||||
hastaAnio: HASTA_ANIO,
|
||||
});
|
||||
if (r.contribuyentesProcesados === 0) {
|
||||
console.log('sin contribuyentes (skip)');
|
||||
} else {
|
||||
console.log(
|
||||
`${r.contribuyentesProcesados} contribs, ${r.mesesProcesados} meses, ` +
|
||||
`${r.filasEscritas} filas${r.errores.length > 0 ? `, ${r.errores.length} errores` : ''}`,
|
||||
);
|
||||
if (r.errores.length > 0 && r.errores.length <= 5) {
|
||||
for (const e of r.errores) {
|
||||
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
|
||||
}
|
||||
} else if (r.errores.length > 5) {
|
||||
console.log(` (${r.errores.length} errores — los primeros 3):`);
|
||||
for (const e of r.errores.slice(0, 3)) {
|
||||
console.log(` ERR (${e.anio}-${String(e.mes).padStart(2, '0')}): ${e.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
totalContribs += r.contribuyentesProcesados;
|
||||
totalMeses += r.mesesProcesados;
|
||||
totalFilas += r.filasEscritas;
|
||||
totalErrores += r.errores.length;
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
totalErrores++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${tenants.length}`);
|
||||
console.log(` Contribuyentes: ${totalContribs}`);
|
||||
console.log(` (Contribuyente, mes): ${totalMeses}`);
|
||||
console.log(` Filas metricas_mensuales: ${totalFilas}${DRY_RUN ? ' (NO escritas)' : ''}`);
|
||||
if (totalErrores > 0) console.log(` Errores: ${totalErrores}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(totalErrores > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal file
163
apps/api/scripts/backfill-saldo-pendiente.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Backfill de `saldo_pendiente_mxn` para CFDIs I PPD vigentes. Computa el
|
||||
* saldo con la fórmula centralizada en `utils/saldo.ts` (pagos P + NC no-07
|
||||
* + anticipo aplicado si es I/07) y lo persiste.
|
||||
*
|
||||
* Idempotente: corrido varias veces produce el mismo resultado. Safe para
|
||||
* repetir después de un sync SAT masivo o si se sospecha drift.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts # ejecuta
|
||||
* pnpm --filter @horux/api exec tsx scripts/backfill-saldo-pendiente.ts --dry # reporta sin escribir
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { saldoComputadoExpr } from '../src/utils/saldo.js';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry') || process.argv.includes('--dry-run');
|
||||
|
||||
interface PerTenantResult {
|
||||
tenantId: string;
|
||||
rfc: string;
|
||||
databaseName: string;
|
||||
iPpdsVigentes: number;
|
||||
actualizadas: number;
|
||||
saldoTotalAntes: number;
|
||||
saldoTotalDespues: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function backfillTenant(
|
||||
tenantId: string,
|
||||
rfc: string,
|
||||
databaseName: string,
|
||||
): Promise<PerTenantResult> {
|
||||
const result: PerTenantResult = {
|
||||
tenantId,
|
||||
rfc,
|
||||
databaseName,
|
||||
iPpdsVigentes: 0,
|
||||
actualizadas: 0,
|
||||
saldoTotalAntes: 0,
|
||||
saldoTotalDespues: 0,
|
||||
};
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
|
||||
const { rows: count } = await pool.query<{ n: number; suma: string }>(
|
||||
`SELECT COUNT(*)::int AS n, COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')`,
|
||||
);
|
||||
result.iPpdsVigentes = count[0]?.n || 0;
|
||||
result.saldoTotalAntes = Number(count[0]?.suma || 0);
|
||||
if (result.iPpdsVigentes === 0) return result;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// UPDATE masivo con la fórmula centralizada (misma que hooks y reporte).
|
||||
const expr = saldoComputadoExpr('c');
|
||||
const { rowCount } = await client.query(
|
||||
`UPDATE cfdis c
|
||||
SET saldo_pendiente_mxn = ${expr}
|
||||
WHERE c.tipo_comprobante = 'I'
|
||||
AND c.metodo_pago = 'PPD'
|
||||
AND c.status NOT IN ('Cancelado', '0')`,
|
||||
);
|
||||
result.actualizadas = rowCount ?? 0;
|
||||
|
||||
const { rows: cntDespues } = await client.query<{ suma: string }>(
|
||||
`SELECT COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) AS suma
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')`,
|
||||
);
|
||||
result.saldoTotalDespues = Number(cntDespues[0]?.suma || 0);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`=== Backfill saldo_pendiente_mxn ${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}] ... `);
|
||||
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.iPpdsVigentes === 0) {
|
||||
console.log(`sin I PPD vigentes (skip)`);
|
||||
} else {
|
||||
const delta = r.saldoTotalDespues - r.saldoTotalAntes;
|
||||
console.log(
|
||||
`I_PPD=${r.iPpdsVigentes} upd=${r.actualizadas} ` +
|
||||
`antes=${fmt(r.saldoTotalAntes)} despues=${fmt(r.saldoTotalDespues)} ` +
|
||||
`Δ=${delta >= 0 ? '+' : ''}${fmt(delta)}${DRY_RUN ? ' (rolled back)' : ''}`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log(`FATAL: ${err?.message || err}`);
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
rfc: t.rfc,
|
||||
databaseName: t.databaseName,
|
||||
iPpdsVigentes: 0,
|
||||
actualizadas: 0,
|
||||
saldoTotalAntes: 0,
|
||||
saldoTotalDespues: 0,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalI = results.reduce((s, r) => s + r.iPpdsVigentes, 0);
|
||||
const totalAntes = results.reduce((s, r) => s + r.saldoTotalAntes, 0);
|
||||
const totalDespues = results.reduce((s, r) => s + r.saldoTotalDespues, 0);
|
||||
const tenantsFailed = results.filter(r => r.error).length;
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Tenants procesados: ${results.length}`);
|
||||
console.log(` I PPD vigentes total: ${totalI}`);
|
||||
console.log(` Saldo total antes: ${fmt(totalAntes)}`);
|
||||
console.log(` Saldo total después: ${fmt(totalDespues)}${DRY_RUN ? ' (rolled back)' : ''}`);
|
||||
console.log(` Delta (recuperado): ${fmt(totalAntes - totalDespues)} (saldo que ya no está pendiente)`);
|
||||
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);
|
||||
});
|
||||
135
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
135
apps/api/scripts/bootstrap-horux360-admin.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Bootstrap del tenant admin global (Horux 360 — HTS240708LJA) + usuarios staff.
|
||||
*
|
||||
* Crea:
|
||||
* 1. Tenant Horux 360 (RFC HTS240708LJA, plan enterprise)
|
||||
* 2. Carlos como owner del tenant + rol platform_admin
|
||||
* 3. Ivan como contador del tenant + rol platform_ti (TI superset)
|
||||
* 4. Suscripción authorized por 1 año
|
||||
*
|
||||
* Uso: `pnpm bootstrap:admin-global`
|
||||
*
|
||||
* Idempotente-ish: falla limpio si el tenant ya existe (RFC unique).
|
||||
* Para re-ejecutar, borra el tenant y su BD manualmente antes.
|
||||
*
|
||||
* Requisitos previos:
|
||||
* 1. `pnpm prisma migrate deploy` (schema central)
|
||||
* 2. `pnpm db:seed` (catálogos SAT, regímenes, ISR, eventos fiscales, roles)
|
||||
*
|
||||
* Env vars opcionales (con defaults):
|
||||
* HORUX_ADMIN_EMAIL (default: carlos@horuxfin.com)
|
||||
* HORUX_ADMIN_NOMBRE (default: Carlos)
|
||||
* HORUX_TI_EMAIL (default: ivan@horuxfin.com)
|
||||
* HORUX_TI_NOMBRE (default: Ivan)
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import * as tenantsService from '../src/services/tenants.service.js';
|
||||
import * as usuariosService from '../src/services/usuarios.service.js';
|
||||
|
||||
const RFC = 'HTS240708LJA';
|
||||
const TENANT_NAME = 'Horux 360';
|
||||
const PLAN = 'enterprise' as const;
|
||||
const CFDI_LIMIT = -1; // ilimitado
|
||||
const USERS_LIMIT = 10;
|
||||
const SUBSCRIPTION_YEARS = 1;
|
||||
|
||||
async function main() {
|
||||
const adminEmail = process.env.HORUX_ADMIN_EMAIL || 'carlos@horuxfin.com';
|
||||
const adminNombre = process.env.HORUX_ADMIN_NOMBRE || 'Carlos';
|
||||
const tiEmail = process.env.HORUX_TI_EMAIL || 'ivan@horuxfin.com';
|
||||
const tiNombre = process.env.HORUX_TI_NOMBRE || 'Ivan';
|
||||
|
||||
console.log(`Bootstrap del tenant admin global`);
|
||||
console.log(` RFC: ${RFC}`);
|
||||
console.log(` Nombre: ${TENANT_NAME}`);
|
||||
console.log(` Admin: ${adminNombre} <${adminEmail}> (platform_admin)`);
|
||||
console.log(` TI: ${tiNombre} <${tiEmail}> (platform_ti)`);
|
||||
console.log(` Plan: ${PLAN} (cfdi: ${CFDI_LIMIT}, users: ${USERS_LIMIT})`);
|
||||
console.log('');
|
||||
|
||||
// 1. Crea tenant + BD provisionada + Carlos como owner + subscription pending
|
||||
const { tenant, user: carlosUser, tempPassword: carlosPassword } = await tenantsService.createTenant({
|
||||
nombre: TENANT_NAME,
|
||||
rfc: RFC,
|
||||
plan: PLAN,
|
||||
cfdiLimit: CFDI_LIMIT,
|
||||
usersLimit: USERS_LIMIT,
|
||||
adminEmail,
|
||||
adminNombre,
|
||||
amount: 0,
|
||||
});
|
||||
|
||||
console.log(`✓ Tenant creado: ${tenant.id}`);
|
||||
console.log(`✓ BD provisionada: ${tenant.databaseName}`);
|
||||
console.log(`✓ Carlos creado (owner): ${carlosUser.email}`);
|
||||
|
||||
// 2. Asigna platform_admin a Carlos (no se hace automáticamente desde tenants.service)
|
||||
const carlosFull = await prisma.user.findUnique({ where: { email: adminEmail } });
|
||||
if (carlosFull) {
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: { userId_role: { userId: carlosFull.id, role: 'platform_admin' } },
|
||||
update: {},
|
||||
create: { userId: carlosFull.id, role: 'platform_admin' },
|
||||
});
|
||||
console.log(`✓ Carlos: rol platform_admin asignado`);
|
||||
}
|
||||
|
||||
// 3. Crea Ivan como contador del tenant (membership) y le asigna platform_ti
|
||||
const ivan = await usuariosService.inviteUsuario(tenant.id, {
|
||||
email: tiEmail,
|
||||
nombre: tiNombre,
|
||||
role: 'contador',
|
||||
});
|
||||
console.log(`✓ Ivan creado: ${ivan.email} (membership contador)`);
|
||||
|
||||
await prisma.userPlatformRole.upsert({
|
||||
where: { userId_role: { userId: ivan.id, role: 'platform_ti' } },
|
||||
update: {},
|
||||
create: { userId: ivan.id, role: 'platform_ti' },
|
||||
});
|
||||
console.log(`✓ Ivan: rol platform_ti asignado (superset, mismos permisos que admin)`);
|
||||
|
||||
// 4. Sube la subscription a 'authorized' con vigencia de 1 año
|
||||
const existing = await prisma.subscription.findFirst({
|
||||
where: { tenantId: tenant.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (existing) {
|
||||
const now = new Date();
|
||||
const end = new Date(now);
|
||||
end.setFullYear(end.getFullYear() + SUBSCRIPTION_YEARS);
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
status: 'authorized',
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: end,
|
||||
},
|
||||
});
|
||||
console.log(`✓ Suscripción marcada 'authorized' hasta ${end.toISOString().slice(0, 10)}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('=== DONE ===');
|
||||
console.log(`Credenciales temporales para primer login:`);
|
||||
console.log(` Carlos (admin): ${adminEmail}`);
|
||||
console.log(` Password: ${carlosPassword}`);
|
||||
console.log('');
|
||||
console.log(` Ivan (TI): ${tiEmail}`);
|
||||
console.log(` Password: revisa el correo de bienvenida (inviteUsuario lo envía por email)`);
|
||||
console.log('');
|
||||
console.log('Próximos pasos manuales:');
|
||||
console.log(` 1. Carlos login en /login con las credenciales de arriba`);
|
||||
console.log(` 2. Cambiar el password desde /configuracion/seguridad`);
|
||||
console.log(` 3. Verificar que Ivan recibió su correo de invitación`);
|
||||
console.log(` 4. Subir FIEL en /configuracion/sat para habilitar sincronización`);
|
||||
console.log(` 5. (Opcional) Configurar organización Facturapi en /configuracion`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('✗ Bootstrap falló:', err.message || err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
75
apps/api/scripts/breakdown-gastos.ts
Normal file
75
apps/api/scripts/breakdown-gastos.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const yearMonth = '2025-02';
|
||||
const contribuyenteId = 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const tenantRfc = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
|
||||
|
||||
// Drill desglosado por régimen del receptor
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(regimen_fiscal_receptor, 'null') AS regimen_rec,
|
||||
type, tipo_comprobante, metodo_pago,
|
||||
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(total_mxn) AS total_bruto,
|
||||
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
|
||||
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
|
||||
FROM cfdis
|
||||
WHERE (
|
||||
(type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE')
|
||||
OR (type='RECIBIDO' AND tipo_comprobante='P')
|
||||
OR (type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE' AND COALESCE(cfdi_tipo_relacion,'')<>'07')
|
||||
)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
|
||||
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
|
||||
AND contribuyente_id = $3
|
||||
GROUP BY regimen_rec, type, tipo_comprobante, metodo_pago, tipo_rel
|
||||
ORDER BY regimen_rec, tipo_comprobante, metodo_pago`,
|
||||
[fi, ff, contribuyenteId],
|
||||
);
|
||||
|
||||
const byReg: Record<string, { fact: number; pago: number; nc: number; detalle: any[] }> = {};
|
||||
for (const r of rows) {
|
||||
const reg = r.regimen_rec;
|
||||
if (!byReg[reg]) byReg[reg] = { fact: 0, pago: 0, nc: 0, detalle: [] };
|
||||
const v = r.tipo_comprobante === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
|
||||
byReg[reg].detalle.push({ tc: r.tipo_comprobante, mp: r.metodo_pago, rel: r.tipo_rel, n: r.n, valor: v, bruto: Number(r.total_bruto) });
|
||||
if (r.tipo_comprobante === 'I') byReg[reg].fact += v;
|
||||
else if (r.tipo_comprobante === 'P') byReg[reg].pago += v;
|
||||
else if (r.tipo_comprobante === 'E') byReg[reg].nc += v;
|
||||
}
|
||||
|
||||
console.log(`\n=== DRILL-DOWN por régimen del receptor — ${fi} a ${ff} ===\n`);
|
||||
let totalAll = 0;
|
||||
const TODOS_REGS = new Set(['605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624']);
|
||||
for (const [reg, v] of Object.entries(byReg).sort()) {
|
||||
const subtot = v.fact + v.pago - v.nc;
|
||||
totalAll += subtot;
|
||||
const inTodos = TODOS_REGS.has(reg) ? '✓' : '✗ (excluido de TODOS_REGIMENES)';
|
||||
console.log(`Régimen ${reg} ${inTodos}`);
|
||||
console.log(` fact=${v.fact.toFixed(2)} pago=${v.pago.toFixed(2)} NC=${v.nc.toFixed(2)} → subtotal=${subtot.toFixed(2)}`);
|
||||
for (const d of v.detalle) {
|
||||
console.log(` ${d.tc} ${d.mp || '-'} rel=${d.rel || '-'} n=${d.n} bruto=${d.bruto.toFixed(2)} neto=${d.valor.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
console.log(`\nTotal todos regímenes: ${totalAll.toFixed(2)}`);
|
||||
const inTodos = Object.entries(byReg).filter(([r]) => TODOS_REGS.has(r)).reduce((s, [, v]) => s + (v.fact + v.pago - v.nc), 0);
|
||||
console.log(`Total solo en TODOS_REGIMENES: ${inTodos.toFixed(2)}`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
67
apps/api/scripts/breakdown-ingresos.ts
Normal file
67
apps/api/scripts/breakdown-ingresos.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Breakdown ingresos por grupo + filas que el drill-down mostraría,
|
||||
* para un contribuyente + mes. Identifica discrepancias entre el
|
||||
* dashboard y el drill.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const yearMonth = process.argv[4] || '2025-05';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
|
||||
console.log(`\n=== ${yearMonth} ${contribuyenteId} RFC=${ctx.rfc} ===\n`);
|
||||
console.log(`esEmisor: ${ctx.esEmisor}`);
|
||||
console.log(`esReceptor: ${ctx.esReceptor}\n`);
|
||||
|
||||
// Todos los CFDIs donde el contribuyente es emisor en el mes (ingresos potenciales)
|
||||
const { rows: emitidos } = await pool.query(
|
||||
`SELECT uuid, fecha_emision, tipo_comprobante, metodo_pago,
|
||||
cfdi_tipo_relacion, regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
total_mxn, monto_pago_mxn
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esEmisor}
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
|
||||
OR (tipo_comprobante<>'P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
|
||||
ORDER BY fecha_emision, uuid`,
|
||||
[fi, ff],
|
||||
);
|
||||
|
||||
console.log(`EMITIDOS por el contribuyente en el mes: ${emitidos.length}`);
|
||||
let sumaTotal = 0, sumaPagos = 0;
|
||||
const porRegimen: Record<string, { n: number; total: number; pago: number; types: Record<string, number> }> = {};
|
||||
for (const r of emitidos) {
|
||||
const reg = r.regimen_fiscal_emisor || 'NULL';
|
||||
const tcKey = `${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? '/rel=' + r.cfdi_tipo_relacion : ''}`;
|
||||
if (!porRegimen[reg]) porRegimen[reg] = { n: 0, total: 0, pago: 0, types: {} };
|
||||
porRegimen[reg].n++;
|
||||
porRegimen[reg].total += Number(r.total_mxn || 0);
|
||||
porRegimen[reg].pago += Number(r.monto_pago_mxn || 0);
|
||||
porRegimen[reg].types[tcKey] = (porRegimen[reg].types[tcKey] || 0) + 1;
|
||||
sumaTotal += Number(r.total_mxn || 0);
|
||||
sumaPagos += Number(r.monto_pago_mxn || 0);
|
||||
}
|
||||
|
||||
console.log(`Suma total_mxn: ${sumaTotal.toFixed(2)} | Suma monto_pago_mxn: ${sumaPagos.toFixed(2)}\n`);
|
||||
for (const [reg, v] of Object.entries(porRegimen)) {
|
||||
console.log(` Régimen ${reg}: n=${v.n} total=${v.total.toFixed(2)} pago=${v.pago.toFixed(2)}`);
|
||||
for (const [tc, n] of Object.entries(v.types)) {
|
||||
console.log(` ${tc}: ${n}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
24
apps/api/scripts/check-cache-contrib.ts
Normal file
24
apps/api/scripts/check-cache-contrib.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3];
|
||||
const year = process.argv[4] || '2025';
|
||||
const month = process.argv[5];
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const monthFilter = month ? `AND mes = ${Number(month)}` : '';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT anio, mes, regimen_fiscal, ingresos_cobrados, egresos_pagados,
|
||||
iva_trasladado_total, iva_acreditable, computed_at
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1 AND anio = $2 ${monthFilter}
|
||||
ORDER BY mes, regimen_fiscal`,
|
||||
[contribuyenteId, Number(year)],
|
||||
);
|
||||
for (const r of rows) console.log(r);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
26
apps/api/scripts/check-cache.ts
Normal file
26
apps/api/scripts/check-cache.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT anio, mes, regimen_fiscal,
|
||||
ingresos_cobrados, egresos_pagados,
|
||||
iva_trasladado_total, iva_acreditable,
|
||||
computed_at
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1 AND anio = 2025 AND mes = 2
|
||||
ORDER BY regimen_fiscal`,
|
||||
['d745a915-6a23-4818-944b-a7e1e18e536a'],
|
||||
);
|
||||
console.log(`Cache rows para Feb 2025:`);
|
||||
for (const r of rows) console.log(r);
|
||||
|
||||
// Also force on-the-fly by setting BYPASS
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
console.log(`\n(cache bypassed below is N/A here; the dashboard service reads planCache directly)`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
85
apps/api/scripts/check-carlos-emision.ts
Normal file
85
apps/api/scripts/check-carlos-emision.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const RFC_CARLOS = 'TORC9611214CA';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let found = false;
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try {
|
||||
pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { rows: contribs } = await pool.query(
|
||||
`SELECT c.entidad_id, c.rfc, c.regimen_fiscal, e.nombre, fo.facturapi_org_id, fo.csd_uploaded, fo.active AS org_active
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas e ON e.id = c.entidad_id
|
||||
LEFT JOIN facturapi_orgs fo ON fo.contribuyente_id = c.entidad_id
|
||||
WHERE UPPER(c.rfc) = $1`,
|
||||
[RFC_CARLOS],
|
||||
);
|
||||
if (contribs.length === 0) continue;
|
||||
|
||||
found = true;
|
||||
console.log(`\n=== Tenant ${t.rfc} — BD ${t.databaseName} ===`);
|
||||
for (const c of contribs) {
|
||||
console.log(`Contribuyente Carlos: ${c.entidad_id}`);
|
||||
console.log(` nombre=${c.nombre}`);
|
||||
console.log(` regimen_fiscal (CSV)=${c.regimen_fiscal}`);
|
||||
console.log(` facturapi_org_id=${c.facturapi_org_id || 'NULL (sin org)'}`);
|
||||
console.log(` csd_uploaded=${c.csd_uploaded} org_active=${c.org_active}`);
|
||||
}
|
||||
|
||||
const { rows: cfdis } = await pool.query(
|
||||
`SELECT uuid, type, tipo_comprobante, metodo_pago, total, total_mxn,
|
||||
rfc_emisor, rfc_receptor, nombre_receptor, status, fecha_emision,
|
||||
source, facturapi_id
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_emisor) = $1
|
||||
AND (source = 'facturapi' OR facturapi_id IS NOT NULL OR fecha_emision >= NOW() - interval '2 days')
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 10`,
|
||||
[RFC_CARLOS],
|
||||
);
|
||||
|
||||
console.log(`\nÚltimas ${cfdis.length} facturas (facturapi o recientes) emitidas por ${RFC_CARLOS}:`);
|
||||
for (const c of cfdis) {
|
||||
console.log(` UUID=${c.uuid}`);
|
||||
console.log(` tipo=${c.tipo_comprobante} mp=${c.metodo_pago} status=${c.status} source=${c.source}`);
|
||||
console.log(` receptor=${c.rfc_receptor} (${c.nombre_receptor})`);
|
||||
console.log(` total=${c.total} total_mxn=${c.total_mxn}`);
|
||||
console.log(` fecha_emision=${c.fecha_emision?.toISOString?.() || c.fecha_emision}`);
|
||||
console.log(` facturapi_id=${c.facturapi_id}`);
|
||||
}
|
||||
|
||||
const { rows: [anyEmitido] } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS total,
|
||||
SUM(CASE WHEN source='facturapi' THEN 1 ELSE 0 END)::int AS via_facturapi,
|
||||
SUM(CASE WHEN source='facturapi' AND status NOT IN ('Cancelado','0') THEN 1 ELSE 0 END)::int AS vigentes
|
||||
FROM cfdis
|
||||
WHERE UPPER(rfc_emisor) = $1`,
|
||||
[RFC_CARLOS],
|
||||
);
|
||||
console.log(`\nResumen total CFDIs con rfc_emisor=${RFC_CARLOS}:`);
|
||||
console.log(` total=${anyEmitido.total} via_facturapi=${anyEmitido.via_facturapi} vigentes_facturapi=${anyEmitido.vigentes}`);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
console.log(`\nNo se encontró contribuyente con RFC ${RFC_CARLOS} en ningún tenant.`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
72
apps/api/scripts/check-carlos-lco.ts
Normal file
72
apps/api/scripts/check-carlos-lco.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { env } from '../src/config/env.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
// 1. Last CSF stored for Carlos (source of truth on what SAT sees)
|
||||
const { rows: csfs } = await pool.query(
|
||||
`SELECT rfc, created_at, datos->'regimenes' AS regimenes, datos->'obligaciones' AS obligaciones,
|
||||
datos->>'estatusPadron' AS estatus, datos->>'fechaInicioOperaciones' AS fecha_inicio,
|
||||
datos->'domicilio' AS domicilio
|
||||
FROM constancias_situacion_fiscal
|
||||
WHERE UPPER(rfc) = 'TORC9611214CA'
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
);
|
||||
console.log(`\n=== CSF más reciente de Carlos ===`);
|
||||
if (csfs.length === 0) {
|
||||
console.log('NO HAY CSF descargada para este RFC. Eso explica el error de LCO si el contribuyente no ha sincronizado con SAT.');
|
||||
} else {
|
||||
const c = csfs[0];
|
||||
console.log(`created_at: ${c.created_at}`);
|
||||
console.log(`estatusPadron: ${c.estatus}`);
|
||||
console.log(`fechaInicioOper: ${c.fecha_inicio}`);
|
||||
console.log(`Regímenes (CSF):`);
|
||||
if (Array.isArray(c.regimenes)) for (const r of c.regimenes) console.log(' ', r);
|
||||
console.log(`Obligaciones (CSF):`);
|
||||
if (Array.isArray(c.obligaciones)) for (const o of c.obligaciones) console.log(' ', o);
|
||||
}
|
||||
|
||||
// 2. Contribuyente data en BD (lo que estamos usando para llenar la org)
|
||||
const { rows: contrib } = await pool.query(
|
||||
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
|
||||
FROM contribuyentes c
|
||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||
WHERE UPPER(c.rfc) = 'TORC9611214CA'`,
|
||||
);
|
||||
console.log(`\n=== Contribuyente en BD ===`);
|
||||
console.log(contrib[0]);
|
||||
|
||||
// 3. Facturapi org actual (lo que Facturapi está enviando al SAT)
|
||||
const { rows: org } = await pool.query(
|
||||
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
|
||||
[contrib[0]?.entidad_id],
|
||||
);
|
||||
if (org.length > 0 && env.FACTURAPI_USER_KEY) {
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${org[0].facturapi_org_id}`, {
|
||||
headers: { 'Authorization': `Bearer ${env.FACTURAPI_USER_KEY}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const o = await res.json() as any;
|
||||
console.log(`\n=== Facturapi Organization ===`);
|
||||
console.log(`orgId: ${o.id}`);
|
||||
console.log(`name: ${o.name}`);
|
||||
console.log(`legal:`);
|
||||
console.log(` legal_name: ${o.legal?.legal_name}`);
|
||||
console.log(` tax_system: ${o.legal?.tax_system}`);
|
||||
console.log(` name: ${o.legal?.name}`);
|
||||
console.log(` address: ${JSON.stringify(o.legal?.address)}`);
|
||||
console.log(`certificate:`);
|
||||
console.log(` has_certificate: ${o.certificate?.has_certificate}`);
|
||||
console.log(` serial_number: ${o.certificate?.serial_number}`);
|
||||
console.log(` valid_until: ${o.certificate?.valid_until}`);
|
||||
} else {
|
||||
console.log(`Facturapi GET failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
112
apps/api/scripts/check-ieps-inflation.ts
Normal file
112
apps/api/scripts/check-ieps-inflation.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Detecta complementos P cuya ieps_traslado_pago_mxn parece inflada
|
||||
* respecto al monto pagado y respecto a la factura referenciada.
|
||||
*
|
||||
* Heurísticas:
|
||||
* 1. IEPS del P > monto_pago × 1.6 (tasa máxima teórica SAT para bebidas
|
||||
* con alto contenido alcohólico; cualquier cosa arriba es sospechoso).
|
||||
* 2. IEPS del P > IEPS de la factura original a la que se refiere
|
||||
* (imposible — un pago parcial no puede transferir más IEPS que el total).
|
||||
* 3. Ratio IEPS / monto_pago vs IEPS_original / total_original, donde la
|
||||
* proporción del P excede la del original por >5pp (señal de error
|
||||
* del proveedor).
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try {
|
||||
pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
|
||||
|
||||
// Heurística 1: IEPS > 160% del monto
|
||||
const { rows: h1 } = await pool.query(`
|
||||
SELECT uuid, rfc_emisor, rfc_receptor, monto_pago_mxn, ieps_traslado_pago_mxn,
|
||||
(ieps_traslado_pago_mxn / NULLIF(monto_pago_mxn, 0))::numeric(10,4) AS ratio
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'P'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND COALESCE(ieps_traslado_pago_mxn, 0) > 0
|
||||
AND COALESCE(monto_pago_mxn, 0) > 0
|
||||
AND ieps_traslado_pago_mxn > monto_pago_mxn * 1.6
|
||||
ORDER BY ieps_traslado_pago_mxn DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
console.log(`\n-- H1: IEPS > monto_pago × 1.6 (${h1.length}) --`);
|
||||
for (const r of h1) {
|
||||
console.log(` ${r.uuid.substring(0, 8)} ${r.rfc_emisor}→${r.rfc_receptor} pago=${Number(r.monto_pago_mxn).toFixed(2)} IEPS=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} ratio=${r.ratio}`);
|
||||
}
|
||||
|
||||
// Heurística 2: IEPS del P > IEPS de la factura referenciada (imposible)
|
||||
// uuid_relacionado es pipe-separated; normalizar
|
||||
const { rows: h2 } = await pool.query(`
|
||||
SELECT p.uuid AS p_uuid, p.rfc_emisor, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
|
||||
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps
|
||||
FROM cfdis p
|
||||
JOIN cfdis i
|
||||
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
|
||||
AND i.status NOT IN ('Cancelado', '0')
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND p.status NOT IN ('Cancelado', '0')
|
||||
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
|
||||
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > COALESCE(i.ieps_traslado_mxn, 0)
|
||||
ORDER BY p.ieps_traslado_pago_mxn DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
console.log(`\n-- H2: IEPS del P > IEPS de la factura referenciada (${h2.length}) --`);
|
||||
for (const r of h2) {
|
||||
const ratio = r.i_ieps > 0 ? Number(r.ieps_traslado_pago_mxn) / Number(r.i_ieps) : 0;
|
||||
console.log(` P=${r.p_uuid.substring(0, 8)} IEPS_P=${Number(r.ieps_traslado_pago_mxn).toFixed(2)} I=${r.i_uuid.substring(0, 8)} IEPS_I=${Number(r.i_ieps || 0).toFixed(2)} ratio=${ratio.toFixed(2)}x`);
|
||||
}
|
||||
|
||||
// Heurística 3: ratio IEPS/pago del P muy distinto del ratio IEPS/total del I
|
||||
const { rows: h3 } = await pool.query(`
|
||||
SELECT p.uuid AS p_uuid, p.monto_pago_mxn, p.ieps_traslado_pago_mxn,
|
||||
i.uuid AS i_uuid, i.total_mxn AS i_total, i.ieps_traslado_mxn AS i_ieps,
|
||||
(p.ieps_traslado_pago_mxn / NULLIF(p.monto_pago_mxn, 0))::numeric(6,4) AS ratio_p,
|
||||
(i.ieps_traslado_mxn / NULLIF(i.total_mxn, 0))::numeric(6,4) AS ratio_i
|
||||
FROM cfdis p
|
||||
JOIN cfdis i
|
||||
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(COALESCE(p.uuid_relacionado, '')), '|'))
|
||||
AND i.status NOT IN ('Cancelado', '0')
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND p.status NOT IN ('Cancelado', '0')
|
||||
AND COALESCE(p.ieps_traslado_pago_mxn, 0) > 0
|
||||
AND COALESCE(i.ieps_traslado_mxn, 0) > 0
|
||||
AND COALESCE(p.monto_pago_mxn, 0) > 0
|
||||
AND COALESCE(i.total_mxn, 0) > 0
|
||||
AND ABS(
|
||||
(p.ieps_traslado_pago_mxn / p.monto_pago_mxn)
|
||||
- (i.ieps_traslado_mxn / i.total_mxn)
|
||||
) > 0.05
|
||||
ORDER BY p.ieps_traslado_pago_mxn DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
console.log(`\n-- H3: ratio_P − ratio_I > 5pp (${h3.length}) --`);
|
||||
for (const r of h3) {
|
||||
console.log(` P=${r.p_uuid.substring(0, 8)} ratio_P=${r.ratio_p} I=${r.i_uuid.substring(0, 8)} ratio_I=${r.ratio_i} delta=${(Number(r.ratio_p) - Number(r.ratio_i)).toFixed(4)}`);
|
||||
}
|
||||
|
||||
// Resumen: total de P con IEPS > 0
|
||||
const { rows: [summary] } = await pool.query(`
|
||||
SELECT COUNT(*) FILTER (WHERE COALESCE(ieps_traslado_pago_mxn, 0) > 0)::int AS p_con_ieps,
|
||||
COUNT(*) FILTER (WHERE tipo_comprobante = 'P')::int AS p_total
|
||||
FROM cfdis
|
||||
WHERE status NOT IN ('Cancelado', '0')
|
||||
`);
|
||||
console.log(`\nResumen: ${summary.p_con_ieps} P con IEPS > 0 (de ${summary.p_total} P totales)`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
76
apps/api/scripts/check-recent-facturapi.ts
Normal file
76
apps/api/scripts/check-recent-facturapi.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) {
|
||||
console.log('Tenant no encontrado');
|
||||
return;
|
||||
}
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== Tenant ${TENANT_RFC} ===\n`);
|
||||
|
||||
// 1) CFDIs emitidos via Facturapi (cualquier emisor) últimos 7 días
|
||||
console.log(`>> CFDIs con source='facturapi' o facturapi_id no nulo, últimos 7 días:`);
|
||||
const { rows: recientes } = await pool.query(
|
||||
`SELECT uuid, rfc_emisor, rfc_receptor, nombre_receptor, tipo_comprobante, metodo_pago,
|
||||
total, total_mxn, status, fecha_emision, source, facturapi_id
|
||||
FROM cfdis
|
||||
WHERE (source = 'facturapi' OR facturapi_id IS NOT NULL)
|
||||
AND fecha_emision >= NOW() - interval '7 days'
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 20`,
|
||||
);
|
||||
if (recientes.length === 0) console.log(' (ninguno)');
|
||||
for (const r of recientes) {
|
||||
const emisor = r.rfc_emisor || '<NULL>';
|
||||
const receptor = r.rfc_receptor || '<NULL>';
|
||||
console.log(` ${r.uuid}`);
|
||||
console.log(` EMISOR=${emisor} RECEPTOR=${receptor} (${r.nombre_receptor})`);
|
||||
console.log(` tipo=${r.tipo_comprobante}/${r.metodo_pago} total=${r.total} status=${r.status} source=${r.source}`);
|
||||
console.log(` fecha_emision=${r.fecha_emision?.toISOString?.() || r.fecha_emision}`);
|
||||
console.log(` facturapi_id=${r.facturapi_id}`);
|
||||
}
|
||||
|
||||
// 2) CFDIs totales en últimas 2 horas (cualquier emisor, cualquier source)
|
||||
console.log(`\n>> CFDIs insertados en últimas 2 horas (cualquier source):`);
|
||||
const { rows: ultimas } = await pool.query(
|
||||
`SELECT uuid, rfc_emisor, rfc_receptor, tipo_comprobante, total,
|
||||
status, fecha_emision, source, facturapi_id
|
||||
FROM cfdis
|
||||
WHERE fecha_emision >= NOW() - interval '2 hours'
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 20`,
|
||||
);
|
||||
if (ultimas.length === 0) console.log(' (ninguno)');
|
||||
for (const r of ultimas) {
|
||||
console.log(` ${r.uuid} | ${r.rfc_emisor} → ${r.rfc_receptor}`);
|
||||
console.log(` tipo=${r.tipo_comprobante} total=${r.total} status=${r.status} source=${r.source}`);
|
||||
console.log(` facturapi_id=${r.facturapi_id || 'null'}`);
|
||||
}
|
||||
|
||||
// 3) Distribución de source en toda la BD
|
||||
console.log(`\n>> Distribución de 'source' en cfdis:`);
|
||||
const { rows: dist } = await pool.query(
|
||||
`SELECT source, COUNT(*)::int AS cnt
|
||||
FROM cfdis
|
||||
GROUP BY source
|
||||
ORDER BY cnt DESC`,
|
||||
);
|
||||
for (const r of dist) {
|
||||
console.log(` source=${r.source || 'NULL'} → ${r.cnt}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
36
apps/api/scripts/check-rfc-emisor.ts
Normal file
36
apps/api/scripts/check-rfc-emisor.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM rfcs WHERE id IN (23709, 1) ORDER BY id`,
|
||||
);
|
||||
for (const r of rows) {
|
||||
console.log(`\nrfcs id=${r.id}:`);
|
||||
for (const k of Object.keys(r).sort()) {
|
||||
console.log(` ${k} = ${r[k]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also look at all 4 Facturapi CFDIs' emisor fields
|
||||
const { rows: all4 } = await pool.query(
|
||||
`SELECT uuid, rfc_emisor, nombre_emisor, rfc_emisor_id, regimen_fiscal_emisor,
|
||||
rfc_receptor, nombre_receptor, subtotal, total, xml_original IS NULL AS no_xml
|
||||
FROM cfdis WHERE source='facturapi' ORDER BY fecha_emision DESC`,
|
||||
);
|
||||
console.log(`\n=== Todas las CFDIs source=facturapi (${all4.length}) ===`);
|
||||
for (const r of all4) {
|
||||
console.log(` ${r.uuid} | emisor='${r.rfc_emisor}' (id=${r.rfc_emisor_id}, nombre='${r.nombre_emisor}', regimen=${r.regimen_fiscal_emisor})`);
|
||||
console.log(` receptor='${r.rfc_receptor}' (${r.nombre_receptor}) subtotal=${r.subtotal} total=${r.total} xml_missing=${r.no_xml}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
63
apps/api/scripts/check-saldo.ts
Normal file
63
apps/api/scripts/check-saldo.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const uuid = (process.argv[2] || '5c874749-748f-11f0-96b1-2b9310891836').toLowerCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT
|
||||
c.uuid, c.total_mxn,
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(p.monto_pago_mxn, 0))
|
||||
FROM cfdis p
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
|
||||
AND p.status NOT IN ('Cancelado', '0')
|
||||
), 0) AS pagos_p,
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(e.total_mxn, 0))
|
||||
FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E'
|
||||
AND COALESCE(e.cfdi_tipo_relacion, '') <> '07'
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND e.status NOT IN ('Cancelado', '0')
|
||||
), 0) AS ncs,
|
||||
CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
|
||||
COALESCE((
|
||||
SELECT SUM(COALESCE(a.total_mxn, 0))
|
||||
FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados), '|'))
|
||||
AND a.status NOT IN ('Cancelado', '0')
|
||||
), 0) ELSE 0 END AS anticipo_aplicado,
|
||||
(
|
||||
COALESCE(c.total_mxn, 0)
|
||||
- COALESCE((SELECT SUM(COALESCE(p.monto_pago_mxn, 0)) FROM cfdis p
|
||||
WHERE p.tipo_comprobante = 'P'
|
||||
AND LOWER(COALESCE(p.uuid_relacionado, '')) LIKE '%' || LOWER(c.uuid) || '%'
|
||||
AND p.status NOT IN ('Cancelado', '0')), 0)
|
||||
- COALESCE((SELECT SUM(COALESCE(e.total_mxn, 0)) FROM cfdis e
|
||||
WHERE e.tipo_comprobante = 'E' AND COALESCE(e.cfdi_tipo_relacion,'') <> '07'
|
||||
AND e.cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(c.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
AND e.status NOT IN ('Cancelado','0')), 0)
|
||||
- CASE WHEN c.cfdi_tipo_relacion = '07' AND c.cfdis_relacionados IS NOT NULL THEN
|
||||
COALESCE((SELECT SUM(COALESCE(a.total_mxn,0)) FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY(string_to_array(LOWER(c.cfdis_relacionados),'|'))
|
||||
AND a.status NOT IN ('Cancelado','0')), 0)
|
||||
ELSE 0 END
|
||||
) AS saldo_computado
|
||||
FROM cfdis c WHERE LOWER(c.uuid) = $1`,
|
||||
[uuid],
|
||||
);
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`[${t.rfc}]`, rows[0]);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async (e) => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
37
apps/api/scripts/compare-iva-full.ts
Normal file
37
apps/api/scripts/compare-iva-full.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
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 tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const año = Number(process.argv[4] || '2025');
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== IVA trasladado/acreditable vs ingresos/gastos — ${año} contrib=${contribuyenteId} ===\n`);
|
||||
console.log('Mes | Ingresos | IVA tras | Ratio | Gastos | IVA acred | Ratio ');
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const lastDay = new Date(año, m, 0).getDate();
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const fi = `${año}-${mm}-01`;
|
||||
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [ing, gas, iva] = await Promise.all([
|
||||
calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
|
||||
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
|
||||
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
|
||||
]);
|
||||
const rTras = ing.total > 0 ? (iva.trasladado / ing.total) * 100 : 0;
|
||||
const rAcr = gas.total > 0 ? (iva.acreditable / gas.total) * 100 : 0;
|
||||
const flagT = Math.abs(rTras - 16) > 3 && ing.total > 0 ? '⚠️' : '';
|
||||
const flagA = Math.abs(rAcr - 16) > 3 && gas.total > 0 ? '⚠️' : '';
|
||||
console.log(`${mm} | ${ing.total.toFixed(2).padStart(12)} | ${iva.trasladado.toFixed(2).padStart(13)} | ${rTras.toFixed(1).padStart(5)}%${flagT} | ${gas.total.toFixed(2).padStart(12)} | ${iva.acreditable.toFixed(2).padStart(13)} | ${rAcr.toFixed(1).padStart(5)}%${flagA}`);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
36
apps/api/scripts/compare-iva-gastos.ts
Normal file
36
apps/api/scripts/compare-iva-gastos.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const año = Number(process.argv[4] || '2025');
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== IVA acreditable vs Gastos por mes — ${año} contrib=${contribuyenteId} ===\n`);
|
||||
console.log('Mes | Gastos | IVA acreditable | Ratio | Esperado (16%) | Diff');
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const lastDay = new Date(año, m, 0).getDate();
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const fi = `${año}-${mm}-01`;
|
||||
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [gastos, iva] = await Promise.all([
|
||||
calcularEgresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId),
|
||||
getResumenIva(pool, fi, ff, tenant.id, false, contribuyenteId),
|
||||
]);
|
||||
const ratio = gastos.total > 0 ? (iva.acreditable / gastos.total) * 100 : 0;
|
||||
const esperado = gastos.total * 0.16;
|
||||
const diff = iva.acreditable - esperado;
|
||||
const flag = Math.abs(ratio - 16) > 3 && gastos.total > 0 ? ' ⚠️' : '';
|
||||
console.log(`${mm} | ${gastos.total.toFixed(2).padStart(13)} | ${iva.acreditable.toFixed(2).padStart(15)} | ${ratio.toFixed(2)}% | ${esperado.toFixed(2).padStart(13)} | ${diff.toFixed(2)}${flag}`);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
22
apps/api/scripts/count-07-types.ts
Normal file
22
apps/api/scripts/count-07-types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
console.log(`\n=== ${t.rfc} ===`);
|
||||
const { rows } = await pool.query(`
|
||||
SELECT tipo_comprobante, metodo_pago, COUNT(*)::int AS cnt
|
||||
FROM cfdis
|
||||
WHERE cfdi_tipo_relacion = '07' AND status NOT IN ('Cancelado','0')
|
||||
GROUP BY tipo_comprobante, metodo_pago
|
||||
ORDER BY cnt DESC
|
||||
`);
|
||||
for (const r of rows) {
|
||||
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || 'null'}: ${r.cnt}`);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
27
apps/api/scripts/count-husberto-07.ts
Normal file
27
apps/api/scripts/count-husberto-07.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const RFC = 'TOAH680201RA2';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
const { rows } = await pool.query(`
|
||||
SELECT tipo_comprobante, metodo_pago, cfdi_tipo_relacion, COUNT(*)::int AS cnt
|
||||
FROM cfdis
|
||||
WHERE (UPPER(rfc_emisor) = $1 OR UPPER(rfc_receptor) = $1)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND cfdi_tipo_relacion IS NOT NULL
|
||||
GROUP BY tipo_comprobante, metodo_pago, cfdi_tipo_relacion
|
||||
ORDER BY cnt DESC`,
|
||||
[RFC]);
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n=== ${t.rfc} (${RFC}) ===`);
|
||||
for (const r of rows) {
|
||||
console.log(` ${r.tipo_comprobante}/${r.metodo_pago || '?'}/rel=${r.cfdi_tipo_relacion}: ${r.cnt}`);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
26
apps/api/scripts/create-carlos.ts
Normal file
26
apps/api/scripts/create-carlos.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { hashPassword } from '../src/utils/password.js';
|
||||
|
||||
async function main() {
|
||||
const ivan = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { tenant: true } });
|
||||
if (!ivan) { console.error('Ivan not found'); process.exit(1); }
|
||||
|
||||
console.log('Tenant:', ivan.tenant.nombre, '(', ivan.tenant.id, ')');
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: 'carlos@horuxfin.com' } });
|
||||
if (existing) { console.log('Carlos already exists:', existing.id); process.exit(0); }
|
||||
|
||||
const hash = await hashPassword('Aasi940812');
|
||||
const carlos = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: ivan.tenantId,
|
||||
email: 'carlos@horuxfin.com',
|
||||
passwordHash: hash,
|
||||
nombre: 'Carlos Horux',
|
||||
role: 'admin',
|
||||
}
|
||||
});
|
||||
console.log('Carlos created:', carlos.id, carlos.email, carlos.role);
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });
|
||||
88
apps/api/scripts/debug-i07.ts
Normal file
88
apps/api/scripts/debug-i07.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Desglosa cada I/07 recibida de un contribuyente en un rango, mostrando:
|
||||
* - NETO_CUSTOM(I/07)
|
||||
* - UUIDs en cfdis_relacionados
|
||||
* - NETO_CUSTOM de cada relacionada vigente
|
||||
* - Contribución neta de la I/07 al gasto
|
||||
*
|
||||
* Útil para detectar:
|
||||
* - Múltiples I/07 que referencian el mismo anticipo (doble-resta)
|
||||
* - Anticipos fuera del periodo que dominan la compensación
|
||||
* - UUIDs relacionados incorrectos (apuntan a CFDIs enormes no-anticipo)
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const yearMonth = process.argv[4] || '2025-07';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const NETO = (a: string) => `(
|
||||
COALESCE(${a}.total_mxn,0) - COALESCE(${a}.iva_traslado_mxn,0) + COALESCE(${a}.iva_retencion_mxn,0)
|
||||
+ COALESCE(${a}.isr_retencion_mxn,0)
|
||||
- COALESCE(${a}.ieps_traslado_mxn,0) + COALESCE(${a}.ieps_retencion_mxn,0)
|
||||
- COALESCE(${a}.impuestos_locales_trasladado_mxn,0) + COALESCE(${a}.impuestos_locales_retenidos_mxn,0)
|
||||
)`;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.uuid, c.fecha_emision, c.total_mxn, c.rfc_emisor, c.cfdis_relacionados,
|
||||
${NETO('c')} AS neto_i07
|
||||
FROM cfdis c
|
||||
WHERE c.type='RECIBIDO' AND c.tipo_comprobante='I' AND c.metodo_pago='PUE'
|
||||
AND c.cfdi_tipo_relacion='07'
|
||||
AND c.status NOT IN ('Cancelado','0')
|
||||
AND c.fecha_emision >= $1::date AND c.fecha_emision < ($2::date + interval '1 day')
|
||||
AND c.contribuyente_id = $3
|
||||
ORDER BY c.fecha_emision`,
|
||||
[fi, ff, contribuyenteId],
|
||||
);
|
||||
|
||||
console.log(`\n=== I/07 RECIBIDAS en ${fi} a ${ff} ===`);
|
||||
console.log(`Total I/07: ${rows.length}`);
|
||||
|
||||
let sumContrib = 0;
|
||||
for (const r of rows) {
|
||||
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
|
||||
console.log(`\n I/07 ${r.uuid.substring(0,8)} — fecha=${r.fecha_emision.toISOString().slice(0,10)} — emisor=${r.rfc_emisor}`);
|
||||
console.log(` total_mxn: ${Number(r.total_mxn).toFixed(2)}`);
|
||||
console.log(` NETO(I/07): ${Number(r.neto_i07).toFixed(2)}`);
|
||||
console.log(` relacionados (${relsUuids.length}):`);
|
||||
|
||||
let sumRel = 0;
|
||||
if (relsUuids.length > 0) {
|
||||
const { rows: rels } = await pool.query(
|
||||
`SELECT uuid, fecha_emision, total_mxn, tipo_comprobante, metodo_pago, status, ${NETO('a')} AS neto_rel
|
||||
FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY($1::text[])`,
|
||||
[relsUuids],
|
||||
);
|
||||
for (const rel of rels) {
|
||||
const vig = rel.status === 'Vigente' ? '✓' : '✗';
|
||||
console.log(` ${vig} ${rel.uuid.substring(0,8)} ${rel.tipo_comprobante} ${rel.metodo_pago || '-'} fecha=${rel.fecha_emision?.toISOString?.().slice(0,10) || '-'} total=${Number(rel.total_mxn).toFixed(2)} NETO=${Number(rel.neto_rel).toFixed(2)}`);
|
||||
if (rel.status === 'Vigente') sumRel += Number(rel.neto_rel);
|
||||
}
|
||||
const missing = relsUuids.filter((u: string) => !rels.find((x: any) => x.uuid.toLowerCase() === u));
|
||||
if (missing.length > 0) {
|
||||
console.log(` ⚠️ ${missing.length} UUID(s) relacionados NO están en BD:`);
|
||||
for (const m of missing) console.log(` ${m}`);
|
||||
}
|
||||
}
|
||||
const contrib = Number(r.neto_i07) - sumRel;
|
||||
sumContrib += contrib;
|
||||
console.log(` Σ NETO(rel vigentes): ${sumRel.toFixed(2)}`);
|
||||
console.log(` CONTRIB: ${contrib.toFixed(2)} ${contrib < 0 ? '⚠️ NEGATIVA' : ''}`);
|
||||
}
|
||||
|
||||
console.log(`\nSuma total contribuciones I/07: ${sumContrib.toFixed(2)}`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal file
104
apps/api/scripts/debug-ingresos-horux-may-wider.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Amplía la inspección: lista TODOS los CFDIs de mayo-2025 donde Horux 360
|
||||
* aparece como emisor o receptor, marcando cuáles entran al bucket ingresos
|
||||
* y cuáles no + por qué.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const FI = '2025-05-01';
|
||||
const FF = '2025-05-31';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC }, select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
|
||||
|
||||
console.log(`\n=== TODOS los CFDIs de Horux 360 en mayo-2025 (como emisor o receptor) ===\n`);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, type, tipo_comprobante, metodo_pago, status,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
rfc_emisor, rfc_receptor, nombre_receptor, nombre_emisor,
|
||||
total_mxn, monto_pago_mxn, cfdi_tipo_relacion, fecha_emision, source
|
||||
FROM cfdis
|
||||
WHERE ((${ctx.esEmisor}) OR (${ctx.esReceptor}))
|
||||
AND fecha_emision >= $1::date
|
||||
AND fecha_emision < ($2::date + interval '1 day')
|
||||
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC`,
|
||||
[FI, FF],
|
||||
);
|
||||
|
||||
console.log(`Total CFDIs encontrados: ${rows.length}\n`);
|
||||
|
||||
const buckets: Record<string, any[]> = {
|
||||
ingresosG1: [],
|
||||
ingresosG3: [],
|
||||
ingresosSueldos: [],
|
||||
noIncluye_canceladoOinvalido: [],
|
||||
noIncluye_regimenFuera: [],
|
||||
noIncluye_comoReceptor: [],
|
||||
noIncluye_otroMotivo: [],
|
||||
};
|
||||
|
||||
const G1 = ['606', '612', '621', '625', '626'];
|
||||
const G3 = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
|
||||
|
||||
for (const r of rows) {
|
||||
const cancel = ['Cancelado', '0'].includes(r.status);
|
||||
const esEmisorRow = String(r.rfc_emisor).toUpperCase() === 'HTS240708LJA';
|
||||
const regE = r.regimen_fiscal_emisor;
|
||||
const regR = r.regimen_fiscal_receptor;
|
||||
|
||||
if (cancel) { buckets.noIncluye_canceladoOinvalido.push(r); continue; }
|
||||
|
||||
if (esEmisorRow) {
|
||||
if (G1.includes(regE)) {
|
||||
if ((r.tipo_comprobante === 'I' && r.metodo_pago === 'PUE') ||
|
||||
(r.tipo_comprobante === 'P') ||
|
||||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
|
||||
buckets.ingresosG1.push(r); continue;
|
||||
}
|
||||
}
|
||||
if (G3.includes(regE)) {
|
||||
if ((r.tipo_comprobante === 'I' && ['PUE', 'PPD'].includes(r.metodo_pago)) ||
|
||||
(r.tipo_comprobante === 'E' && r.metodo_pago === 'PUE')) {
|
||||
buckets.ingresosG3.push(r); continue;
|
||||
}
|
||||
}
|
||||
if (!G1.includes(regE) && !G3.includes(regE)) {
|
||||
buckets.noIncluye_regimenFuera.push({ ...r, reason: `emisor régimen ${regE} fuera de grupo` });
|
||||
continue;
|
||||
}
|
||||
buckets.noIncluye_otroMotivo.push({ ...r, reason: `emisor tipo=${r.tipo_comprobante}/${r.metodo_pago} no matchea` });
|
||||
continue;
|
||||
}
|
||||
|
||||
// No emisor → receptor
|
||||
if (r.tipo_comprobante === 'N' && r.metodo_pago === 'PUE' && regR === '605') {
|
||||
buckets.ingresosSueldos.push(r); continue;
|
||||
}
|
||||
buckets.noIncluye_comoReceptor.push({ ...r, reason: 'es receptor, no cuenta como ingreso (salvo N/605)' });
|
||||
}
|
||||
|
||||
const fmt = (n: any) => Number(n || 0).toFixed(2);
|
||||
|
||||
for (const [name, list] of Object.entries(buckets)) {
|
||||
if (list.length === 0) continue;
|
||||
console.log(`\n--- ${name} (${list.length}) ---`);
|
||||
for (const r of list) {
|
||||
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
|
||||
const reason = r.reason ? ` | ${r.reason}` : '';
|
||||
console.log(` ${fe} ${r.tipo_comprobante}/${r.metodo_pago || '-'} status=${r.status} regE=${r.regimen_fiscal_emisor} regR=${r.regimen_fiscal_receptor} ${r.rfc_emisor}→${r.rfc_receptor} total=${fmt(r.total_mxn)} mp=${fmt(r.monto_pago_mxn)} ${r.uuid.substring(0,8)}${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal file
111
apps/api/scripts/debug-ingresos-horux-may.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Debug ingresos Horux 360 mayo-2025 post-Método A:
|
||||
* - Llama al KPI (calcularIngresosPorRegimen)
|
||||
* - Lista los CFDIs que entran al drill-down (mismos filtros del controller)
|
||||
* - Suma manualmente para ver dónde está la discrepancia
|
||||
*/
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen, GRUPO_PF_EMPRESARIAL, GRUPO_PM_OTROS } from '../src/services/dashboard.service.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const CONTRIB_ID = 'b3761db6-0b8d-4251-8078-4ddc31e9c75b'; // Horux 360
|
||||
const FI = '2025-05-01';
|
||||
const FF = '2025-05-31';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, CONTRIB_ID);
|
||||
|
||||
console.log(`\n=== KPI calcularIngresosPorRegimen ===`);
|
||||
const kpi = await calcularIngresosPorRegimen(
|
||||
pool, tenant.id, FI, FF, undefined, undefined, false, CONTRIB_ID,
|
||||
);
|
||||
console.log(`Total KPI: ${kpi.total.toFixed(2)}`);
|
||||
for (const r of kpi.porRegimen) {
|
||||
console.log(` ${r.regimenClave} ${r.regimenDescripcion.substring(0, 40).padEnd(40)} ${r.monto.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// Replica de los filtros del drill-down bucket 'ingresos' (cfdi.controller.ts:163-187)
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
const CLAVES = `('84121603','93161608','85101501','85121800')`;
|
||||
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0)-COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ${CLAVES}),0)`;
|
||||
|
||||
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
|
||||
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
|
||||
|
||||
const drillSql = `
|
||||
SELECT id, uuid, type, tipo_comprobante, metodo_pago, regimen_fiscal_emisor,
|
||||
regimen_fiscal_receptor, rfc_emisor, rfc_receptor, nombre_receptor,
|
||||
total_mxn, iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
|
||||
monto_pago_mxn, iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
|
||||
cfdi_tipo_relacion, fecha_emision, fecha_pago_p, source,
|
||||
-- neto (lo que "contribuye" a ingresos según grupo)
|
||||
CASE
|
||||
WHEN tipo_comprobante='I' THEN (COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
|
||||
WHEN tipo_comprobante='E' THEN -(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO}))
|
||||
WHEN tipo_comprobante='P' THEN (COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}))
|
||||
WHEN tipo_comprobante='N' THEN COALESCE(total_mxn,0)
|
||||
ELSE 0
|
||||
END AS aporte
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND (
|
||||
(${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g1}) AND (
|
||||
(tipo_comprobante='I' AND metodo_pago='PUE')
|
||||
OR (tipo_comprobante='P')
|
||||
OR (tipo_comprobante='E' AND metodo_pago='PUE')
|
||||
))
|
||||
OR (${ctx.esReceptor} AND tipo_comprobante='N' AND metodo_pago='PUE' AND regimen_fiscal_receptor='605')
|
||||
OR (${ctx.esEmisor} AND regimen_fiscal_emisor IN (${g3}) AND (
|
||||
(tipo_comprobante='I' AND metodo_pago IN ('PUE','PPD'))
|
||||
OR (tipo_comprobante='E' AND metodo_pago='PUE')
|
||||
))
|
||||
)
|
||||
ORDER BY fecha_emision, tipo_comprobante, total_mxn DESC
|
||||
`;
|
||||
|
||||
const { rows } = await pool.query(drillSql, [FI, FF]);
|
||||
console.log(`\n=== Drill-down (${rows.length} CFDIs) ===`);
|
||||
|
||||
let sumDrill = 0;
|
||||
const perRegimen: Record<string, number> = {};
|
||||
|
||||
for (const r of rows) {
|
||||
const aporte = Number(r.aporte || 0);
|
||||
sumDrill += aporte;
|
||||
const reg = r.regimen_fiscal_emisor || r.regimen_fiscal_receptor || '?';
|
||||
perRegimen[reg] = (perRegimen[reg] || 0) + aporte;
|
||||
|
||||
const fe = r.fecha_emision?.toISOString?.()?.slice(0, 10) || r.fecha_emision;
|
||||
const rel07 = r.cfdi_tipo_relacion === '07' ? ' [07]' : '';
|
||||
const src = r.source === 'facturapi' ? ' [facturapi]' : '';
|
||||
console.log(
|
||||
` ${fe} ${r.tipo_comprobante}/${r.metodo_pago}${rel07}${src} ` +
|
||||
`reg=${reg} ${String(r.rfc_emisor).padEnd(14)}→${String(r.rfc_receptor).padEnd(14)} ` +
|
||||
`total=${Number(r.total_mxn || 0).toFixed(2).padStart(10)} ` +
|
||||
`aporte=${aporte.toFixed(2).padStart(10)} ${r.uuid.substring(0,8)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n=== Suma de aportes del drill-down: ${sumDrill.toFixed(2)} ===`);
|
||||
console.log(`Por régimen (drill-down):`);
|
||||
for (const [reg, monto] of Object.entries(perRegimen).sort()) {
|
||||
console.log(` ${reg}: ${monto.toFixed(2)}`);
|
||||
}
|
||||
|
||||
console.log(`\n=== Diferencia KPI − drill: ${(kpi.total - sumDrill).toFixed(2)} ===`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
82
apps/api/scripts/decrypt-fiel.ts
Normal file
82
apps/api/scripts/decrypt-fiel.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI script to decrypt FIEL credentials from filesystem backup.
|
||||
* Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>
|
||||
*
|
||||
* Decrypted files are written to /tmp/horux-fiel-<RFC>/ and auto-deleted after 30 minutes.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { createDecipheriv, createHash } from 'crypto';
|
||||
|
||||
const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
|
||||
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;
|
||||
|
||||
const rfc = process.argv[2];
|
||||
if (!rfc) {
|
||||
console.error('Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!FIEL_KEY) {
|
||||
console.error('Error: FIEL_ENCRYPTION_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function deriveKey(): Buffer {
|
||||
return createHash('sha256').update(FIEL_KEY!).digest();
|
||||
}
|
||||
|
||||
function decryptBuffer(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
|
||||
const key = deriveKey();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fielDir = join(FIEL_PATH, rfc.toUpperCase());
|
||||
const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;
|
||||
|
||||
console.log(`Reading encrypted FIEL from: ${fielDir}`);
|
||||
|
||||
// Read encrypted certificate
|
||||
const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
|
||||
const cerIv = await readFile(join(fielDir, 'certificate.cer.iv'));
|
||||
const cerTag = await readFile(join(fielDir, 'certificate.cer.tag'));
|
||||
|
||||
// Read encrypted private key
|
||||
const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));
|
||||
const keyIv = await readFile(join(fielDir, 'private_key.key.iv'));
|
||||
const keyTag = await readFile(join(fielDir, 'private_key.key.tag'));
|
||||
|
||||
// Read and decrypt metadata
|
||||
const metaEnc = await readFile(join(fielDir, 'metadata.json.enc'));
|
||||
const metaIv = await readFile(join(fielDir, 'metadata.json.iv'));
|
||||
const metaTag = await readFile(join(fielDir, 'metadata.json.tag'));
|
||||
|
||||
// Decrypt all
|
||||
const cerData = decryptBuffer(cerEnc, cerIv, cerTag);
|
||||
const keyData = decryptBuffer(keyEnc, keyIv, keyTag);
|
||||
const metadata = JSON.parse(decryptBuffer(metaEnc, metaIv, metaTag).toString('utf-8'));
|
||||
|
||||
// Write decrypted files
|
||||
await mkdir(outputDir, { recursive: true, mode: 0o700 });
|
||||
await writeFile(join(outputDir, 'certificate.cer'), cerData, { mode: 0o600 });
|
||||
await writeFile(join(outputDir, 'private_key.key'), keyData, { mode: 0o600 });
|
||||
await writeFile(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
||||
|
||||
console.log(`\nDecrypted files written to: ${outputDir}`);
|
||||
console.log('Metadata:', metadata);
|
||||
console.log('\nFiles will be auto-deleted in 30 minutes.');
|
||||
|
||||
// Auto-delete after 30 minutes
|
||||
setTimeout(async () => {
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
console.log(`Cleaned up ${outputDir}`);
|
||||
process.exit(0);
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to decrypt FIEL:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
101
apps/api/scripts/deep-egresos.ts
Normal file
101
apps/api/scripts/deep-egresos.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Compara paso a paso los 3 componentes del cálculo de egresos 612 en Feb 2025:
|
||||
* 1) Query exacto que usa calcularEgresosPorRegimen (con FECHA_RANGO / FECHA_PAGO_RANGO)
|
||||
* 2) Vs el drill-down usando fecha efectiva por fila
|
||||
* Detalle al CFDI para encontrar discrepancias.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const fi = '2025-02-01';
|
||||
const ff = '2025-02-28';
|
||||
const contrib = 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const reg = '612';
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const EXCL = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
|
||||
|
||||
// QUERY 1 FACTURAS (idéntico a calcularEgresosPorRegimen)
|
||||
const f = await pool.query(
|
||||
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
|
||||
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
|
||||
cfdi_tipo_relacion AS rel
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='I' AND metodo_pago='PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4
|
||||
ORDER BY fecha_emision`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumF = f.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(`FACTURAS I PUE reg=${reg}: n=${f.rows.length} sum_neto=${sumF.toFixed(2)}`);
|
||||
|
||||
// QUERY 2 PAGOS P
|
||||
const p = await pool.query(
|
||||
`SELECT uuid, monto_pago_mxn, (${IMP_TRAS_PAGO}) AS imp,
|
||||
COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
|
||||
fecha_pago_p, fecha_emision
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4
|
||||
ORDER BY fecha_pago_p`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumP = p.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(`PAGOS P reg=${reg} (fecha_pago_p): n=${p.rows.length} sum_neto=${sumP.toFixed(2)}`);
|
||||
|
||||
// También probar con fecha_emision del P (alternativo)
|
||||
const pEmis = await pool.query(
|
||||
`SELECT uuid, COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO}) AS neto,
|
||||
fecha_pago_p, fecha_emision
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4
|
||||
ORDER BY fecha_emision`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumPe = pEmis.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(` (alt) PAGOS P filtrados por fecha_emision: n=${pEmis.rows.length} sum_neto=${sumPe.toFixed(2)}`);
|
||||
|
||||
// QUERY 3 NC
|
||||
const n = await pool.query(
|
||||
`SELECT uuid, total_mxn, (${IMP_TRAS}) AS imp, (${EXCL}) AS excl,
|
||||
COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL}) AS neto,
|
||||
cfdi_tipo_relacion AS rel
|
||||
FROM cfdis
|
||||
WHERE type='RECIBIDO' AND tipo_comprobante='E' AND metodo_pago='PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion,'') <> '07'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND regimen_fiscal_receptor = $3
|
||||
AND contribuyente_id = $4`,
|
||||
[fi, ff, reg, contrib],
|
||||
);
|
||||
const sumN = n.rows.reduce((s, r) => s + Number(r.neto), 0);
|
||||
console.log(`NC E PUE excl 07 reg=${reg}: n=${n.rows.length} sum_neto=${sumN.toFixed(2)}`);
|
||||
|
||||
console.log(`\nTotal ON-THE-FLY (reg 612): ${(sumF + sumP - sumN).toFixed(2)}`);
|
||||
console.log(`Cache dice: 446180.10`);
|
||||
console.log(`Delta: ${((sumF + sumP - sumN) - 446180.10).toFixed(2)}`);
|
||||
|
||||
// Detalle de los P para investigar — fecha_emision vs fecha_pago_p
|
||||
console.log(`\nDetalle PAGOS P (filtrados por fecha_pago_p):`);
|
||||
for (const r of p.rows) {
|
||||
console.log(` ${r.uuid.substring(0,8)} monto=${Number(r.monto_pago_mxn).toFixed(2)} neto=${Number(r.neto).toFixed(2)} fecha_pago_p=${r.fecha_pago_p?.toISOString?.()?.slice(0,10)} fecha_emision=${r.fecha_emision?.toISOString?.()?.slice(0,10)}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
55
apps/api/scripts/detail-ingresos.ts
Normal file
55
apps/api/scripts/detail-ingresos.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Detalle neto de cada CFDI del dashboard para Horux 360 mayo 2025. */
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: 'DESPACHO_MO3NI6U8_B9VGG' }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, 'b3761db6-0b8d-4251-8078-4ddc31e9c75b');
|
||||
|
||||
// Facturas I PUE (rendición con la misma lógica de g1Facturas)
|
||||
const { rows: fact } = await pool.query(
|
||||
`SELECT uuid, total_mxn,
|
||||
iva_traslado_mxn, ieps_traslado_mxn, impuestos_locales_trasladado_mxn,
|
||||
iva_retencion_mxn, isr_retencion_mxn, ieps_retencion_mxn, impuestos_locales_retenidos_mxn,
|
||||
cfdi_tipo_relacion,
|
||||
(COALESCE(total_mxn,0) - COALESCE(iva_traslado_mxn,0) - COALESCE(ieps_traslado_mxn,0) - COALESCE(impuestos_locales_trasladado_mxn,0)) AS neto_normal
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esEmisor} AND tipo_comprobante='I' AND metodo_pago='PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= '2025-05-01'::date AND fecha_emision < '2025-05-31'::date + interval '1 day'
|
||||
AND regimen_fiscal_emisor = '626'
|
||||
ORDER BY fecha_emision`,
|
||||
);
|
||||
console.log(`\nI PUE régimen 626:`);
|
||||
for (const r of fact) {
|
||||
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} iva_tras=${Number(r.iva_traslado_mxn).toFixed(2)} iva_ret=${Number(r.iva_retencion_mxn).toFixed(2)} isr_ret=${Number(r.isr_retencion_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)} rel=${r.cfdi_tipo_relacion || '-'}`);
|
||||
}
|
||||
const factNeto = fact.reduce((s, r) => s + Number(r.neto_normal), 0);
|
||||
console.log(` Suma neto facturas: ${factNeto.toFixed(2)}`);
|
||||
|
||||
// Pagos P
|
||||
const { rows: pagos } = await pool.query(
|
||||
`SELECT uuid, fecha_pago_p, monto_pago_mxn,
|
||||
iva_traslado_pago_mxn, ieps_traslado_pago_mxn,
|
||||
iva_retencion_pago_mxn, isr_retencion_pago_mxn, ieps_retencion_pago_mxn,
|
||||
(COALESCE(monto_pago_mxn,0) - COALESCE(iva_traslado_pago_mxn,0) - COALESCE(ieps_traslado_pago_mxn,0)) AS neto_normal
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esEmisor} AND tipo_comprobante='P'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_pago_p >= '2025-05-01'::date AND fecha_pago_p < '2025-05-31'::date + interval '1 day'
|
||||
AND regimen_fiscal_emisor = '626'
|
||||
ORDER BY fecha_pago_p`,
|
||||
);
|
||||
console.log(`\nPagos P régimen 626:`);
|
||||
for (const r of pagos) {
|
||||
console.log(` ${r.uuid.substring(0,8)} monto_pago=${Number(r.monto_pago_mxn).toFixed(2)} iva_tras_pago=${Number(r.iva_traslado_pago_mxn).toFixed(2)} iva_ret_pago=${Number(r.iva_retencion_pago_mxn).toFixed(2)} neto=${Number(r.neto_normal).toFixed(2)}`);
|
||||
}
|
||||
const pagosNeto = pagos.reduce((s, r) => s + Number(r.neto_normal), 0);
|
||||
console.log(` Suma neto pagos: ${pagosNeto.toFixed(2)}`);
|
||||
|
||||
console.log(`\nTOTAL facturas + pagos: ${(factNeto + pagosNeto).toFixed(2)}`);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
68
apps/api/scripts/detail-iva-mes.ts
Normal file
68
apps/api/scripts/detail-iva-mes.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/** Breakdown: qué CFDIs contribuyen al IVA acreditable vs al gasto. */
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const yearMonth = process.argv[4] || '2025-12';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
|
||||
// I PUE recibidas
|
||||
const { rows: facturas } = await pool.query(
|
||||
`SELECT uuid, total_mxn, iva_traslado_mxn, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
(COALESCE(total_mxn,0) - (${IMP_TRAS})) AS neto_normal
|
||||
FROM cfdis
|
||||
WHERE ${ctx.esReceptor} AND tipo_comprobante='I' AND metodo_pago='PUE'
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')
|
||||
ORDER BY total_mxn DESC`,
|
||||
[fi, ff],
|
||||
);
|
||||
|
||||
console.log(`\n=== I PUE recibidas ${yearMonth} ===`);
|
||||
console.log(`# | UUID | total | IVA | neto_normal | rel | cfdis_relacionados`);
|
||||
for (const r of facturas) {
|
||||
const rel = r.cfdi_tipo_relacion || '-';
|
||||
const cr = r.cfdis_relacionados ? ` → ${r.cfdis_relacionados.substring(0,36)}` : '';
|
||||
console.log(` ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2).padStart(12)} IVA=${Number(r.iva_traslado_mxn).toFixed(2).padStart(10)} neto=${Number(r.neto_normal).toFixed(2).padStart(12)} rel=${rel.padEnd(3)}${cr}`);
|
||||
}
|
||||
|
||||
// I PUE recibidas con relación 07 — verificar si el anticipo está en otro mes
|
||||
const i07 = facturas.filter((r: any) => r.cfdi_tipo_relacion === '07');
|
||||
if (i07.length > 0) {
|
||||
console.log(`\nI/07 recibidas en ${yearMonth}: ${i07.length}`);
|
||||
for (const r of i07) {
|
||||
const relsUuids = (r.cfdis_relacionados || '').split('|').filter(Boolean).map((u: string) => u.toLowerCase());
|
||||
if (relsUuids.length > 0) {
|
||||
const { rows: rels } = await pool.query(
|
||||
`SELECT uuid, fecha_emision, total_mxn, iva_traslado_mxn
|
||||
FROM cfdis a
|
||||
WHERE LOWER(a.uuid) = ANY($1::text[])
|
||||
AND a.status NOT IN ('Cancelado','0')`,
|
||||
[relsUuids],
|
||||
);
|
||||
console.log(`\n I/07 ${r.uuid.substring(0,8)} total=${Number(r.total_mxn).toFixed(2)} IVA=${Number(r.iva_traslado_mxn).toFixed(2)}`);
|
||||
for (const a of rels) {
|
||||
const fecha = a.fecha_emision.toISOString().slice(0,10);
|
||||
const fuera = fecha.substring(0,7) !== yearMonth ? ' ← FUERA DEL MES' : '';
|
||||
console.log(` anticipo ${a.uuid.substring(0,8)} fecha=${fecha} total=${Number(a.total_mxn).toFixed(2)} IVA=${Number(a.iva_traslado_mxn).toFixed(2)}${fuera}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
88
apps/api/scripts/drill-ingresos.ts
Normal file
88
apps/api/scripts/drill-ingresos.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Simula el drill-down bucket=ingresos para un contribuyente/mes y muestra
|
||||
* cada CFDI que aparecería en el drill. Permite comparar con el total del
|
||||
* dashboard.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { resolveContribuyenteContext } from '../src/utils/contribuyente-context.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const yearMonth = process.argv[4] || '2025-05';
|
||||
|
||||
const GRUPO_PF_EMPRESARIAL = ['606', '612', '621', '625', '626'];
|
||||
const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614', '615', '620', '622', '623', '624'];
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const ctx = await resolveContribuyenteContext(pool, tenant.id, contribuyenteId);
|
||||
const esEmisor = ctx.esEmisor;
|
||||
const esReceptor = ctx.esReceptor;
|
||||
const g1 = GRUPO_PF_EMPRESARIAL.map(r => `'${r}'`).join(',');
|
||||
const g3 = GRUPO_PM_OTROS.map(r => `'${r}'`).join(',');
|
||||
|
||||
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
|
||||
|
||||
// Query idéntico al drill-down bucket=ingresos
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, tipo_comprobante, metodo_pago,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
cfdi_tipo_relacion,
|
||||
total_mxn, monto_pago_mxn,
|
||||
fecha_emision, fecha_pago_p
|
||||
FROM cfdis
|
||||
WHERE 1=1
|
||||
AND (
|
||||
(
|
||||
${esEmisor}
|
||||
AND regimen_fiscal_emisor IN (${g1})
|
||||
AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago = 'PUE')
|
||||
OR tipo_comprobante = 'P'
|
||||
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE' AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
)
|
||||
)
|
||||
OR (
|
||||
${esReceptor}
|
||||
AND tipo_comprobante = 'N' AND metodo_pago = 'PUE'
|
||||
AND regimen_fiscal_receptor = '605'
|
||||
)
|
||||
OR (
|
||||
${esEmisor}
|
||||
AND regimen_fiscal_emisor IN (${g3})
|
||||
AND (
|
||||
(tipo_comprobante = 'I' AND metodo_pago IN ('PUE','PPD'))
|
||||
OR (tipo_comprobante = 'E' AND metodo_pago = 'PUE')
|
||||
)
|
||||
)
|
||||
)
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND ${FECHA_EFECTIVA} >= $1::date
|
||||
AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')
|
||||
ORDER BY ${FECHA_EFECTIVA}`,
|
||||
[fi, ff],
|
||||
);
|
||||
|
||||
console.log(`\n=== Drill bucket=ingresos ${yearMonth} contrib=${ctx.rfc} ===`);
|
||||
console.log(`Filas: ${rows.length}\n`);
|
||||
let sumTotal = 0, sumPago = 0;
|
||||
for (const r of rows) {
|
||||
console.log(` ${r.uuid.substring(0,8)} ${r.tipo_comprobante}${r.metodo_pago ? '/' + r.metodo_pago : ''}${r.cfdi_tipo_relacion ? ' rel=' + r.cfdi_tipo_relacion : ''} reg=${r.regimen_fiscal_emisor || r.regimen_fiscal_receptor} total=${Number(r.total_mxn || 0).toFixed(2)} pago=${Number(r.monto_pago_mxn || 0).toFixed(2)}`);
|
||||
sumTotal += Number(r.total_mxn || 0);
|
||||
sumPago += Number(r.monto_pago_mxn || 0);
|
||||
}
|
||||
console.log(`\nSuma total_mxn (bruto drill): ${sumTotal.toFixed(2)}`);
|
||||
console.log(`Suma monto_pago_mxn: ${sumPago.toFixed(2)}`);
|
||||
console.log(`(Total bruto cuenta I + E a total, y P a monto_pago)`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
79
apps/api/scripts/extract-terminos.mjs
Normal file
79
apps/api/scripts/extract-terminos.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extrae el texto del PDF de términos y condiciones y lo convierte en un
|
||||
* módulo TypeScript para que el frontend lo renderice sin tener que parsear
|
||||
* el PDF en runtime.
|
||||
*
|
||||
* Además copia el PDF original a `apps/web/public/legal/` para servirlo como
|
||||
* descarga.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm legal:sync
|
||||
*
|
||||
* Cuando se actualiza el documento legal:
|
||||
* 1. Reemplazar `docs/legal/Terminos y condiciones.pdf` por la nueva versión
|
||||
* (mismo nombre de archivo).
|
||||
* 2. Correr `pnpm legal:sync`.
|
||||
* 3. Commit de los cambios (PDF, terminos.ts, PDF copy).
|
||||
*/
|
||||
import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const ROOT = resolve(__dirname, '../../../');
|
||||
const SRC_PDF = resolve(ROOT, 'docs/legal/Terminos y condiciones.pdf');
|
||||
const DEST_PDF = resolve(ROOT, 'apps/web/public/legal/terminos-y-condiciones.pdf');
|
||||
const DEST_TS = resolve(ROOT, 'apps/web/content/terminos.ts');
|
||||
|
||||
async function main() {
|
||||
console.log('[legal:sync] Leyendo:', SRC_PDF);
|
||||
const buf = readFileSync(SRC_PDF);
|
||||
|
||||
const parser = new PDFParse({ data: buf });
|
||||
const textResult = await parser.getText();
|
||||
await parser.destroy();
|
||||
|
||||
const rawText = (textResult.text ?? '').trim();
|
||||
const pages = textResult.total ?? textResult.pages?.length ?? 0;
|
||||
|
||||
if (!rawText) {
|
||||
console.error('[legal:sync] ERROR: el PDF no contiene texto extraíble (¿escaneado sin OCR?).');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Copia el PDF a public/ para que sea descargable
|
||||
mkdirSync(dirname(DEST_PDF), { recursive: true });
|
||||
copyFileSync(SRC_PDF, DEST_PDF);
|
||||
|
||||
// Escribe el texto como módulo TypeScript. Escapa backticks para que el
|
||||
// template literal no rompa si el PDF los contiene.
|
||||
const escaped = rawText.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||
const extractedAt = new Date().toISOString();
|
||||
const content = `// AUTO-GENERADO por \`pnpm legal:sync\`. NO editar a mano.
|
||||
// Fuente: docs/legal/Terminos y condiciones.pdf
|
||||
// Regenerar tras actualizar el PDF.
|
||||
|
||||
export const TERMINOS_TEXT = \`${escaped}\`;
|
||||
|
||||
export const TERMINOS_META = {
|
||||
extractedAt: '${extractedAt}',
|
||||
pages: ${pages},
|
||||
chars: ${rawText.length},
|
||||
} as const;
|
||||
`;
|
||||
|
||||
mkdirSync(dirname(DEST_TS), { recursive: true });
|
||||
writeFileSync(DEST_TS, content, 'utf8');
|
||||
|
||||
console.log(`[legal:sync] OK: ${rawText.length} chars extraídos, ${pages} páginas.`);
|
||||
console.log(`[legal:sync] → ${DEST_PDF}`);
|
||||
console.log(`[legal:sync] → ${DEST_TS}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[legal:sync] FAIL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
28
apps/api/scripts/find-contribuyente.ts
Normal file
28
apps/api/scripts/find-contribuyente.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const term = (process.argv[2] || '').toLowerCase();
|
||||
if (!term) { console.error('Usage: tsx scripts/find-contribuyente.ts <texto>'); process.exit(1); }
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows: cols } = await pool.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_name='contribuyentes'`,
|
||||
);
|
||||
const colNames = cols.map((c: any) => c.column_name);
|
||||
const nameCols = colNames.filter(n => n.includes('nombre') || n.includes('razon'));
|
||||
const filterSql = nameCols.map(c => `LOWER(${c}) LIKE '%${term}%'`).join(' OR ');
|
||||
if (!filterSql) continue;
|
||||
const { rows } = await pool.query(`SELECT entidad_id, rfc, ${nameCols.join(',')} FROM contribuyentes WHERE ${filterSql}`);
|
||||
if (rows.length > 0) {
|
||||
console.log(`\n[${t.rfc}]`);
|
||||
for (const r of rows) console.log(r);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal file
75
apps/api/scripts/find-i07-ppd-cases.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Encuentra E que referencien directamente a una I/07 PPD vía
|
||||
* `cfdis_relacionados`. Patrón real observado: la E "ajusta" la I/07 PPD,
|
||||
* no al anticipo original. La I/07 PPD apunta al anticipo, la E apunta a
|
||||
* la I/07 PPD.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TARGET_RFC = process.argv[2];
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
console.log(`\n=== ${t.rfc}${TARGET_RFC ? ` (RFC=${TARGET_RFC})` : ''} ===`);
|
||||
|
||||
const rfcFilter = TARGET_RFC
|
||||
? `AND (UPPER(i.rfc_emisor) = UPPER('${TARGET_RFC}') OR UPPER(i.rfc_receptor) = UPPER('${TARGET_RFC}'))`
|
||||
: '';
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
i.uuid AS i_uuid, i.fecha_emision AS i_fecha, i.total_mxn AS i_total,
|
||||
i.iva_traslado_mxn AS i_iva, i.rfc_emisor AS i_emisor, i.rfc_receptor AS i_receptor,
|
||||
i.type AS i_type,
|
||||
e.uuid AS e_uuid, e.cfdi_tipo_relacion AS e_rel, e.metodo_pago AS e_mp,
|
||||
e.fecha_emision AS e_fecha, e.total_mxn AS e_total, e.iva_traslado_mxn AS e_iva,
|
||||
ABS(EXTRACT(EPOCH FROM (e.fecha_emision - i.fecha_emision)) / 86400)::int AS diff_dias,
|
||||
EXTRACT(YEAR FROM i.fecha_emision)::int * 12 + EXTRACT(MONTH FROM i.fecha_emision)::int AS i_periodo,
|
||||
EXTRACT(YEAR FROM e.fecha_emision)::int * 12 + EXTRACT(MONTH FROM e.fecha_emision)::int AS e_periodo
|
||||
FROM cfdis i
|
||||
JOIN cfdis e
|
||||
ON LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
|
||||
WHERE i.cfdi_tipo_relacion = '07'
|
||||
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
|
||||
AND i.status NOT IN ('Cancelado','0')
|
||||
AND e.tipo_comprobante = 'E'
|
||||
AND e.status NOT IN ('Cancelado','0')
|
||||
${rfcFilter}
|
||||
ORDER BY i.fecha_emision DESC
|
||||
`);
|
||||
|
||||
console.log(`Total pares: ${rows.length}`);
|
||||
|
||||
const buckets = { mismoMes: 0, eDespues1: 0, eDespuesMas: 0, eAntes: 0 };
|
||||
for (const r of rows) {
|
||||
const diff = Number(r.e_periodo) - Number(r.i_periodo);
|
||||
if (diff < 0) buckets.eAntes++;
|
||||
else if (diff === 0) buckets.mismoMes++;
|
||||
else if (diff === 1) buckets.eDespues1++;
|
||||
else buckets.eDespuesMas++;
|
||||
}
|
||||
console.log(` Mismo mes: ${buckets.mismoMes}`);
|
||||
console.log(` E 1 mes después: ${buckets.eDespues1}`);
|
||||
console.log(` E ≥2 meses después: ${buckets.eDespuesMas}`);
|
||||
console.log(` E antes: ${buckets.eAntes}`);
|
||||
|
||||
if (rows.length > 0) {
|
||||
console.log(`\n Detalle (top ${Math.min(rows.length, 10)}):`);
|
||||
for (const r of rows.slice(0, 10)) {
|
||||
const fi = new Date(r.i_fecha).toISOString().slice(0, 10);
|
||||
const fe = new Date(r.e_fecha).toISOString().slice(0, 10);
|
||||
const i_base = Number(r.i_total) - Number(r.i_iva || 0);
|
||||
const e_base = Number(r.e_total) - Number(r.e_iva || 0);
|
||||
const diff = Number(r.e_periodo) - Number(r.i_periodo);
|
||||
console.log(` I/07 PPD ${r.i_uuid.substring(0,8)} ${fi} base=${i_base.toFixed(2)} ${r.i_emisor}→${r.i_receptor} (${r.i_type})`);
|
||||
console.log(` E/${r.e_rel ?? 'null'}/${r.e_mp || '?'} ${r.e_uuid.substring(0,8)} ${fe} base=${e_base.toFixed(2)} diffMeses=${diff} (${r.diff_dias}d)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
12
apps/api/scripts/find-uuid.ts
Normal file
12
apps/api/scripts/find-uuid.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
const prefix = process.argv[2];
|
||||
async function main() {
|
||||
const ts = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of ts) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(`SELECT uuid FROM cfdis WHERE uuid LIKE $1 || '%'`, [prefix]);
|
||||
for (const r of rows) console.log(t.rfc, r.uuid);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
104
apps/api/scripts/import-lista-negra.ts
Normal file
104
apps/api/scripts/import-lista-negra.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const SITUACIONES_VALIDAS = ['Definitivo', 'Presunto', 'Desvirtuado', 'Sentencia Favorable'];
|
||||
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (c === '"') {
|
||||
if (inQuotes && line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (c === ',' && !inQuotes) {
|
||||
fields.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += c;
|
||||
}
|
||||
}
|
||||
fields.push(current.trim());
|
||||
return fields;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const filePath = resolve(__dirname, '..', '..', '..', 'lista_negra', 'Listado_completo_69-B.csv');
|
||||
console.log('📂 Leyendo:', filePath);
|
||||
|
||||
const data = readFileSync(filePath, 'latin1');
|
||||
const lines = data.split('\n');
|
||||
console.log(`📄 ${lines.length} líneas en el archivo`);
|
||||
|
||||
// Parsear registros (saltar headers: líneas 0, 1, 2)
|
||||
const registros: { rfc: string; nombre: string; situacion: string }[] = [];
|
||||
|
||||
for (let i = 3; i < lines.length; i++) {
|
||||
const line = lines[i].replace(/\r/g, '').trim();
|
||||
if (!line) continue;
|
||||
|
||||
const fields = parseCsvLine(line);
|
||||
if (fields.length < 4) continue;
|
||||
|
||||
const rfc = fields[1]?.trim();
|
||||
const nombre = fields[2]?.trim();
|
||||
const situacion = fields[3]?.trim();
|
||||
|
||||
if (!rfc || !rfc.match(/^[A-Z0-9&]{10,13}$/)) continue;
|
||||
if (!SITUACIONES_VALIDAS.includes(situacion)) continue;
|
||||
|
||||
registros.push({ rfc, nombre, situacion });
|
||||
}
|
||||
|
||||
console.log(`✅ ${registros.length} registros válidos parseados`);
|
||||
|
||||
// Contar por situación
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of registros) {
|
||||
counts[r.situacion] = (counts[r.situacion] || 0) + 1;
|
||||
}
|
||||
console.log(' Situaciones:', counts);
|
||||
|
||||
// Sincronizar: limpiar y reinsertar todo
|
||||
console.log('🔄 Sincronizando con base de datos...');
|
||||
|
||||
await prisma.listaNegra.deleteMany();
|
||||
|
||||
// Insertar en batches de 500
|
||||
const BATCH = 500;
|
||||
let inserted = 0;
|
||||
|
||||
for (let i = 0; i < registros.length; i += BATCH) {
|
||||
const batch = registros.slice(i, i + BATCH);
|
||||
|
||||
// Deduplicar por RFC (quedarse con el último)
|
||||
const unique = new Map<string, typeof batch[0]>();
|
||||
for (const r of batch) unique.set(r.rfc, r);
|
||||
|
||||
await prisma.listaNegra.createMany({
|
||||
data: Array.from(unique.values()),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
inserted += unique.size;
|
||||
if ((i + BATCH) % 5000 === 0 || i + BATCH >= registros.length) {
|
||||
console.log(` ${Math.min(i + BATCH, registros.length)}/${registros.length}...`);
|
||||
}
|
||||
}
|
||||
|
||||
const total = await prisma.listaNegra.count();
|
||||
console.log(`\n🎉 Lista negra actualizada: ${total} registros en la base de datos`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
26
apps/api/scripts/inspect-cfdi-full.ts
Normal file
26
apps/api/scripts/inspect-cfdi-full.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const rawUuid = process.argv[2];
|
||||
if (!rawUuid) { console.error('Usage: tsx scripts/inspect-cfdi-full.ts <uuid>'); process.exit(1); }
|
||||
const uuid = rawUuid.toLowerCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM cfdis WHERE LOWER(uuid) = $1`,
|
||||
[uuid],
|
||||
);
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n[${t.rfc}] CFDI:`);
|
||||
console.log(rows[0]);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
90
apps/api/scripts/inspect-cfdi.ts
Normal file
90
apps/api/scripts/inspect-cfdi.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Inspecciona el estado de un CFDI y sus relacionados (pagos + E/07) en todos
|
||||
* los tenants. Útil para debug de saldos pendientes.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/inspect-cfdi.ts <uuid>
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const rawUuid = process.argv[2];
|
||||
if (!rawUuid) {
|
||||
console.error('Usage: tsx scripts/inspect-cfdi.ts <uuid>');
|
||||
process.exit(1);
|
||||
}
|
||||
const uuid = rawUuid.toLowerCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
orderBy: { rfc: 'asc' },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const { rows: base } = await pool.query(
|
||||
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, status, fecha_emision,
|
||||
total, total_mxn, monto_pago, monto_pago_mxn,
|
||||
saldo_insoluto, saldo_pendiente, saldo_pendiente_mxn,
|
||||
uuid_relacionado, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
rfc_emisor, rfc_receptor, conciliado, id_conciliacion,
|
||||
source, facturapi_id
|
||||
FROM cfdis WHERE LOWER(uuid) = $1`,
|
||||
[uuid],
|
||||
);
|
||||
|
||||
if (base.length === 0) continue;
|
||||
|
||||
console.log(`\n=== Tenant ${t.rfc} (${t.databaseName}) ===`);
|
||||
console.log('CFDI base:');
|
||||
console.log(base[0]);
|
||||
|
||||
// P complements que apuntan a este UUID via uuid_relacionado (DoctoRelacionado)
|
||||
const { rows: pagosP } = await pool.query(
|
||||
`SELECT id, uuid, type, tipo_comprobante, fecha_emision, fecha_pago_p,
|
||||
monto_pago, monto_pago_mxn, num_parcialidad,
|
||||
uuid_relacionado, status
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'P' AND LOWER(uuid_relacionado) = $1
|
||||
ORDER BY fecha_pago_p NULLS LAST, id`,
|
||||
[uuid],
|
||||
);
|
||||
console.log(`\nComplementos P que referencian este UUID (DoctoRelacionado): ${pagosP.length}`);
|
||||
for (const r of pagosP) console.log(' ', r);
|
||||
|
||||
// E CFDIs con cfdis_relacionados que contengan este UUID (TipoRelacion=07 típicamente)
|
||||
const { rows: ecfdis } = await pool.query(
|
||||
`SELECT id, uuid, type, tipo_comprobante, metodo_pago, fecha_emision,
|
||||
total, total_mxn, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
status
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante = 'E'
|
||||
AND cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(cfdis_relacionados) LIKE $1
|
||||
ORDER BY fecha_emision, id`,
|
||||
[`%${uuid}%`],
|
||||
);
|
||||
console.log(`\nCFDIs tipo E con este UUID en cfdis_relacionados: ${ecfdis.length}`);
|
||||
for (const r of ecfdis) console.log(' ', r);
|
||||
|
||||
// Si el base está conciliado, traer la fila
|
||||
if (base[0].id_conciliacion) {
|
||||
const { rows: conc } = await pool.query(
|
||||
`SELECT * FROM conciliaciones WHERE id = $1`,
|
||||
[base[0].id_conciliacion],
|
||||
);
|
||||
console.log(`\nConciliación vinculada:`);
|
||||
for (const r of conc) console.log(' ', r);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal file
66
apps/api/scripts/inspect-facturapi-invoice.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Inspect the shape of the response from Facturapi invoices.retrieve
|
||||
* for a recent emission, to know what fields are actually populated.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { env } from '../src/config/env.js';
|
||||
|
||||
const CONTRIB_ID = '414b22a8-c6e2-4f39-be0f-7537a848107e';
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const INVOICE_ID = '69ebc61f87f122486514c3b4'; // latest
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
// Fetch org API key
|
||||
const { rows } = await pool.query<{ facturapi_org_id: string }>(
|
||||
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id=$1 AND active=true`,
|
||||
[CONTRIB_ID],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
console.log('No facturapi_org_id found');
|
||||
return;
|
||||
}
|
||||
const orgId = rows[0].facturapi_org_id;
|
||||
|
||||
// Get the org's API key (HTTP direct because SDK has issues)
|
||||
const userKey = env.FACTURAPI_USER_KEY;
|
||||
const keyRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
||||
headers: { Authorization: `Bearer ${userKey}` },
|
||||
});
|
||||
const keyData = await keyRes.json();
|
||||
const apiKey = typeof keyData === 'string' ? keyData : keyData.apikey || keyData.key;
|
||||
|
||||
// Retrieve the invoice
|
||||
const invRes = await fetch(`https://www.facturapi.io/v2/invoices/${INVOICE_ID}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
const invoice = await invRes.json();
|
||||
|
||||
console.log('=== FACTURAPI INVOICE RESPONSE ===');
|
||||
console.log('Top-level keys:', Object.keys(invoice).sort().join(', '));
|
||||
console.log('');
|
||||
console.log('invoice.id =', invoice.id);
|
||||
console.log('invoice.uuid =', invoice.uuid);
|
||||
console.log('invoice.date =', invoice.date);
|
||||
console.log('invoice.subtotal =', invoice.subtotal);
|
||||
console.log('invoice.total =', invoice.total);
|
||||
console.log('invoice.series =', invoice.series);
|
||||
console.log('invoice.folio_number =', invoice.folio_number);
|
||||
console.log('invoice.issuer =', JSON.stringify(invoice.issuer, null, 2));
|
||||
console.log('invoice.issuer_info =', JSON.stringify(invoice.issuer_info, null, 2));
|
||||
console.log('invoice.issuer_type =', invoice.issuer_type);
|
||||
console.log('invoice.organization =', JSON.stringify(invoice.organization, null, 2));
|
||||
console.log('invoice.customer =', JSON.stringify(invoice.customer, null, 2));
|
||||
console.log('invoice.taxes =', JSON.stringify(invoice.taxes, null, 2));
|
||||
console.log('invoice.items =', JSON.stringify(invoice.items?.slice(0, 2), null, 2));
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal file
41
apps/api/scripts/inspect-latest-facturapi.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
// Get the full latest Facturapi CFDI with ALL fields
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM cfdis
|
||||
WHERE source = 'facturapi'
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
console.log('No hay CFDIs Facturapi');
|
||||
return;
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
console.log('UUID:', r.uuid);
|
||||
console.log('');
|
||||
console.log('Campos relevantes de emisor/receptor:');
|
||||
const keys = Object.keys(r).sort();
|
||||
for (const k of keys) {
|
||||
if (/emisor|receptor|regimen|contribuyente|type|tipo|facturapi|uso_cfdi|forma|metodo|total|iva|lugar|fecha|status|version|uuid|id|source|serie|folio|xml_original/i.test(k)) {
|
||||
const v = r[k];
|
||||
const val = typeof v === 'string' && v.length > 200 ? v.substring(0, 200) + '…' : v;
|
||||
console.log(` ${k} = ${val instanceof Date ? val.toISOString() : String(val).substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
52
apps/api/scripts/inspect-pair.ts
Normal file
52
apps/api/scripts/inspect-pair.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const I_UUID = '5c874749-748f-11f0-96b1-2b9310891836';
|
||||
const E_UUID = '7163da3b-748f-11f0-9853-e97a8e1dedd9';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ select: { id: true, rfc: true, databaseName: true } });
|
||||
|
||||
for (const t of tenants) {
|
||||
let pool;
|
||||
try { pool = await tenantDb.getPool(t.id, t.databaseName); } catch { continue; }
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT uuid, tipo_comprobante, metodo_pago, cfdi_tipo_relacion, cfdis_relacionados,
|
||||
status, fecha_emision, total_mxn, iva_traslado_mxn,
|
||||
rfc_emisor, rfc_receptor, contribuyente_id, type
|
||||
FROM cfdis WHERE LOWER(uuid) IN (LOWER($1), LOWER($2))`,
|
||||
[I_UUID, E_UUID],
|
||||
);
|
||||
|
||||
if (rows.length === 0) continue;
|
||||
console.log(`\n=== ${t.rfc} ===`);
|
||||
for (const r of rows) {
|
||||
const fe = new Date(r.fecha_emision).toISOString().slice(0, 10);
|
||||
console.log(`\n UUID: ${r.uuid}`);
|
||||
console.log(` tipo: ${r.tipo_comprobante}/${r.metodo_pago || '?'} rel=${r.cfdi_tipo_relacion ?? 'null'} status=${r.status} type=${r.type}`);
|
||||
console.log(` fecha: ${fe} total=${r.total_mxn} IVA=${r.iva_traslado_mxn}`);
|
||||
console.log(` ${r.rfc_emisor} → ${r.rfc_receptor} contrib_id=${r.contribuyente_id}`);
|
||||
console.log(` cfdis_relacionados: ${r.cfdis_relacionados ?? 'NULL'}`);
|
||||
}
|
||||
|
||||
// Si están ambos, verificar match de cfdis_relacionados
|
||||
if (rows.length === 2) {
|
||||
const i = rows.find((x: any) => x.uuid.toLowerCase() === I_UUID.toLowerCase());
|
||||
const e = rows.find((x: any) => x.uuid.toLowerCase() === E_UUID.toLowerCase());
|
||||
if (i && e) {
|
||||
const iRels = (i.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
|
||||
const eRels = (e.cfdis_relacionados || '').split('|').map((u: string) => u.trim().toLowerCase()).filter(Boolean);
|
||||
const overlap = iRels.filter((u: string) => eRels.includes(u));
|
||||
console.log(`\n I refs (${iRels.length}): ${iRels.join(', ').substring(0, 200)}`);
|
||||
console.log(` E refs (${eRels.length}): ${eRels.join(', ').substring(0, 200)}`);
|
||||
console.log(` Overlap (${overlap.length}): ${overlap.join(', ')}`);
|
||||
|
||||
// Cruz: ¿la E referencia a la I directamente, o viceversa?
|
||||
if (eRels.includes(I_UUID.toLowerCase())) console.log(` → E.cfdis_relacionados INCLUYE el UUID de I/07 PPD`);
|
||||
if (iRels.includes(E_UUID.toLowerCase())) console.log(` → I.cfdis_relacionados INCLUYE el UUID de E`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
48
apps/api/scripts/inspect-rfc.ts
Normal file
48
apps/api/scripts/inspect-rfc.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const rawRfc = process.argv[2];
|
||||
if (!rawRfc) {
|
||||
console.error('Usage: tsx scripts/inspect-rfc.ts <rfc>');
|
||||
process.exit(1);
|
||||
}
|
||||
const rfc = rawRfc.toUpperCase();
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
|
||||
const { rows: contrib } = await pool.query(
|
||||
`SELECT * FROM contribuyentes WHERE UPPER(rfc) = $1`,
|
||||
[rfc],
|
||||
);
|
||||
if (contrib.length > 0) {
|
||||
console.log(`\n[${t.rfc}] Contribuyente ${rfc}:`);
|
||||
console.log(contrib[0]);
|
||||
}
|
||||
|
||||
const { rows: rfcEntry } = await pool.query(
|
||||
`SELECT id, rfc, razon_social, regimen_fiscal, codigo_postal FROM rfcs WHERE UPPER(rfc) = $1`,
|
||||
[rfc],
|
||||
);
|
||||
if (rfcEntry.length > 0) {
|
||||
console.log(`[${t.rfc}] rfcs table:`, rfcEntry[0]);
|
||||
}
|
||||
|
||||
if (contrib.length > 0) {
|
||||
const { rows: org } = await pool.query(
|
||||
`SELECT facturapi_org_id, csd_uploaded, active FROM facturapi_orgs WHERE contribuyente_id = $1`,
|
||||
[contrib[0].entidad_id],
|
||||
);
|
||||
if (org.length > 0) console.log(`[${t.rfc}] facturapi_orgs:`, org[0]);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
159
apps/api/scripts/invalidate-metricas-all.ts
Normal file
159
apps/api/scripts/invalidate-metricas-all.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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<PerTenantResult> {
|
||||
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);
|
||||
});
|
||||
26
apps/api/scripts/list-contribuyentes.ts
Normal file
26
apps/api/scripts/list-contribuyentes.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { active: true }, select: { id: true, rfc: true, databaseName: true } });
|
||||
for (const t of tenants) {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
// descubrir tablas con 'entidad' o 'contribuyente' en el nombre
|
||||
const { rows: tbls } = await pool.query(`SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND (table_name LIKE '%entidad%' OR table_name LIKE '%contribuyente%') ORDER BY table_name`);
|
||||
console.log(`\n[${t.rfc}] tablas:`, tbls.map((r: any) => r.table_name).join(', '));
|
||||
|
||||
// Join con rfcs si existe
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.entidad_id, c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal
|
||||
FROM contribuyentes c
|
||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||
ORDER BY r.razon_social NULLS LAST, c.rfc`,
|
||||
);
|
||||
for (const r of rows) console.log(' ', r);
|
||||
} catch (e: any) {
|
||||
console.log(' ERR:', e.message);
|
||||
}
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
33
apps/api/scripts/migrate-tenants.ts
Normal file
33
apps/api/scripts/migrate-tenants.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Eager tenant migration script.
|
||||
* Run: pnpm --filter @horux/api db:migrate-tenants
|
||||
* Or: pnpm db:migrate-tenants (from monorepo root via Turborepo)
|
||||
*
|
||||
* Applies pending SQL migrations to all active tenant databases.
|
||||
*/
|
||||
import { migrateAll } from '../src/config/tenant-migrations.js';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Tenant Schema Migration (Eager) ===\n');
|
||||
|
||||
const start = Date.now();
|
||||
const result = await migrateAll();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\n=== Done in ${elapsed}s ===`);
|
||||
console.log(` Migrated: ${result.success}`);
|
||||
console.log(` Up-to-date: ${result.skipped}`);
|
||||
console.log(` Failed: ${result.failed}`);
|
||||
|
||||
if (result.failed > 0) {
|
||||
console.error('\nSome tenants failed migration. Check logs above.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
27
apps/api/scripts/otf-ingresos.ts
Normal file
27
apps/api/scripts/otf-ingresos.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
process.env.METRICAS_BYPASS_CACHE = '1';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'b3761db6-0b8d-4251-8078-4ddc31e9c75b';
|
||||
const yearMonth = process.argv[4] || '2025-05';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) return;
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const r = await calcularIngresosPorRegimen(pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId);
|
||||
console.log(`\n=== Ingresos ${yearMonth} contrib=${contribuyenteId} (BYPASS_CACHE=1) ===`);
|
||||
console.log(`Total: ${r.total.toFixed(2)}`);
|
||||
for (const p of r.porRegimen) {
|
||||
console.log(` ${p.regimenClave} (${p.regimenDescripcion}): ${p.monto.toFixed(2)}`);
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
164
apps/api/scripts/preview-emails.mjs
Normal file
164
apps/api/scripts/preview-emails.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Genera los 8 templates de email como archivos HTML estáticos en
|
||||
* `apps/api/email-previews/` para revisar el diseño en el navegador
|
||||
* sin necesidad de SMTP configurado.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm email:preview
|
||||
*
|
||||
* Tras correr, abre `apps/api/email-previews/index.html` para ver
|
||||
* el listado con links a cada template.
|
||||
*/
|
||||
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..');
|
||||
const OUT_DIR = resolve(ROOT, 'email-previews');
|
||||
|
||||
// Datos de ejemplo realistas para cada template
|
||||
const SAMPLES = {
|
||||
'welcome.html': {
|
||||
label: 'Bienvenida',
|
||||
fixture: { nombre: 'Carlos Hernández', email: 'carlos@empresa.com', tempPassword: 'a3f2c891' },
|
||||
importPath: '../src/services/email/templates/welcome.ts',
|
||||
fnName: 'welcomeEmail',
|
||||
},
|
||||
'password-reset.html': {
|
||||
label: 'Recuperación de contraseña',
|
||||
fixture: { nombre: 'Carlos Hernández', resetUrl: 'https://horuxfin.com/reset-password?token=a8e4f...' },
|
||||
importPath: '../src/services/email/templates/password-reset.ts',
|
||||
fnName: 'passwordResetEmail',
|
||||
},
|
||||
'payment-confirmed.html': {
|
||||
label: 'Pago confirmado',
|
||||
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA', date: new Date().toLocaleDateString('es-MX') },
|
||||
importPath: '../src/services/email/templates/payment-confirmed.ts',
|
||||
fnName: 'paymentConfirmedEmail',
|
||||
},
|
||||
'payment-failed.html': {
|
||||
label: 'Pago rechazado',
|
||||
fixture: { nombre: 'Carlos Hernández', amount: 780, plan: 'Business + IA' },
|
||||
importPath: '../src/services/email/templates/payment-failed.ts',
|
||||
fnName: 'paymentFailedEmail',
|
||||
},
|
||||
'subscription-cancelled.html': {
|
||||
label: 'Suscripción cancelada',
|
||||
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA' },
|
||||
importPath: '../src/services/email/templates/subscription-cancelled.ts',
|
||||
fnName: 'subscriptionCancelledEmail',
|
||||
},
|
||||
'subscription-expiring.html': {
|
||||
label: 'Suscripción por vencer',
|
||||
fixture: { nombre: 'Carlos Hernández', plan: 'Business + IA', expiresAt: '15 de mayo, 2026' },
|
||||
importPath: '../src/services/email/templates/subscription-expiring.ts',
|
||||
fnName: 'subscriptionExpiringEmail',
|
||||
},
|
||||
'fiel-notification.html': {
|
||||
label: 'e.firma cargada (admin)',
|
||||
fixture: { clienteNombre: 'Empresa Demo SA de CV', clienteRfc: 'EDE123456AB1' },
|
||||
importPath: '../src/services/email/templates/fiel-notification.ts',
|
||||
fnName: 'fielNotificationEmail',
|
||||
},
|
||||
'weekly-update.html': {
|
||||
label: 'Actualización semanal',
|
||||
fixture: {
|
||||
nombre: 'Carlos Hernández',
|
||||
empresa: 'Empresa Demo SA de CV',
|
||||
periodoLabel: 'Abril 2026',
|
||||
kpis: {
|
||||
ingresos: 285430.50,
|
||||
egresos: 142900.00,
|
||||
utilidad: 142530.50,
|
||||
margen: 49.9,
|
||||
ivaBalance: 18420.00,
|
||||
ivaAFavorAcumulado: 32100.00,
|
||||
cfdisEmitidos: 47,
|
||||
cfdisRecibidos: 23,
|
||||
},
|
||||
alertas: [
|
||||
{ titulo: 'Cliente en lista negra', mensaje: '1 cliente con situación SAT "Definitivo".', prioridad: 'alta' },
|
||||
{ titulo: 'Concentración alta de proveedores', mensaje: 'IHH = 6,840. Más del 50% del gasto en 1 proveedor.', prioridad: 'media' },
|
||||
{ titulo: 'Pago en efectivo', mensaje: '3 facturas recibidas con forma de pago "01-Efectivo" este mes.', prioridad: 'baja' },
|
||||
],
|
||||
discrepanciasPorMes: [
|
||||
{ label: 'Abril 2026', count: 2 },
|
||||
{ label: 'Marzo 2026', count: 5 },
|
||||
{ label: 'Febrero 2026', count: 0 },
|
||||
{ label: 'Enero 2026', count: 1 },
|
||||
],
|
||||
fechaGeneracion: new Date().toLocaleString('es-MX', { dateStyle: 'long', timeStyle: 'short' }),
|
||||
},
|
||||
importPath: '../src/services/email/templates/weekly-update.ts',
|
||||
fnName: 'weeklyUpdateEmail',
|
||||
},
|
||||
'new-client-admin.html': {
|
||||
label: 'Nuevo cliente registrado (admin)',
|
||||
fixture: {
|
||||
clienteNombre: 'Empresa Demo SA de CV',
|
||||
clienteRfc: 'EDE123456AB1',
|
||||
adminEmail: 'admin@empresademo.com',
|
||||
adminNombre: 'Carlos Hernández',
|
||||
tempPassword: 'a3f2c891',
|
||||
databaseName: 'horux_ede123456ab1',
|
||||
plan: 'business_ia',
|
||||
},
|
||||
importPath: '../src/services/email/templates/new-client-admin.ts',
|
||||
fnName: 'newClientAdminEmail',
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
// Limpia output previo y recrea
|
||||
try { rmSync(OUT_DIR, { recursive: true, force: true }); } catch {}
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
const generated = [];
|
||||
for (const [filename, sample] of Object.entries(SAMPLES)) {
|
||||
const modPath = resolve(__dirname, sample.importPath);
|
||||
const mod = await import(pathToFileURL(modPath).href);
|
||||
const fn = mod[sample.fnName];
|
||||
if (typeof fn !== 'function') {
|
||||
console.error(`[email:preview] FAIL: ${sample.fnName} no exportada en ${modPath}`);
|
||||
continue;
|
||||
}
|
||||
const html = fn(sample.fixture);
|
||||
const outPath = resolve(OUT_DIR, filename);
|
||||
writeFileSync(outPath, html, 'utf8');
|
||||
generated.push({ filename, label: sample.label });
|
||||
console.log(`[email:preview] ✓ ${filename}`);
|
||||
}
|
||||
|
||||
// Index navegable
|
||||
const indexHtml = `<!DOCTYPE html>
|
||||
<html lang="es"><head><meta charset="utf-8"><title>Email previews — Horux 360</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 720px; margin: 40px auto; padding: 0 24px; color: #1E293B; }
|
||||
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||
p.muted { color: #64748B; margin-top: 0; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li { margin: 8px 0; padding: 14px 18px; border: 1px solid #E2E8F0; border-radius: 8px; }
|
||||
li:hover { background: #F8FAFC; }
|
||||
a { color: #2563EB; text-decoration: none; font-weight: 500; }
|
||||
a:hover { text-decoration: underline; }
|
||||
small { color: #94A3B8; font-size: 12px; margin-left: 8px; }
|
||||
</style></head><body>
|
||||
<h1>Email previews — Horux 360</h1>
|
||||
<p class="muted">Generados desde los templates en <code>apps/api/src/services/email/templates/</code> con datos de ejemplo. Cada link abre el HTML renderizado tal como llegaría al inbox del cliente.</p>
|
||||
<ul>
|
||||
${generated.map(g => `<li><a href="${g.filename}">${g.label}</a> <small>(${g.filename})</small></li>`).join('\n ')}
|
||||
</ul>
|
||||
<p class="muted" style="margin-top:32px;font-size:13px;">Si modificas un template, vuelve a correr <code>pnpm email:preview</code> para regenerar.</p>
|
||||
</body></html>`;
|
||||
|
||||
writeFileSync(resolve(OUT_DIR, 'index.html'), indexHtml, 'utf8');
|
||||
console.log(`\n[email:preview] ${generated.length} templates generados.`);
|
||||
console.log(`[email:preview] Abre: ${resolve(OUT_DIR, 'index.html')}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[email:preview] FAIL:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
32
apps/api/scripts/process-metricas-now.ts
Normal file
32
apps/api/scripts/process-metricas-now.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Dispara manualmente el procesamiento de `metricas_invalidaciones` para todos
|
||||
* los tenants. Útil tras un `invalidate-metricas-all.ts` para no esperar al
|
||||
* cron (cada 15 min).
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/process-metricas-now.ts
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { processAllTenantsInvalidations } from '../src/services/metricas-compute.service.js';
|
||||
|
||||
async function main() {
|
||||
console.log('=== Procesar metricas_invalidaciones (all tenants) ===\n');
|
||||
const start = Date.now();
|
||||
const r = await processAllTenantsInvalidations();
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
console.log(
|
||||
`\nTenants revisados: ${r.tenantsRevisados}\n` +
|
||||
`Invalidaciones procesadas: ${r.totalProcesadas}\n` +
|
||||
`Filas metricas_mensuales escritas: ${r.totalFilasEscritas}\n` +
|
||||
`Errores: ${r.totalErrores}\n` +
|
||||
`Tiempo: ${elapsed}s`,
|
||||
);
|
||||
await prisma.$disconnect();
|
||||
process.exit(r.totalErrores > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
71
apps/api/scripts/setup-despachos-db.ts
Normal file
71
apps/api/scripts/setup-despachos-db.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Setting up horux_despachos database...');
|
||||
|
||||
// Create admin user
|
||||
const hash = await bcrypt.hash('Admin12345!', 12);
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'ivan@horuxfin.com',
|
||||
passwordHash: hash,
|
||||
nombre: 'Ivan Admin',
|
||||
},
|
||||
});
|
||||
console.log('✅ User created:', user.email);
|
||||
|
||||
// Find or create tenant
|
||||
let tenant = await prisma.tenant.findFirst();
|
||||
if (!tenant) {
|
||||
tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: 'Despacho Demo',
|
||||
rfc: 'DDE250101AAA',
|
||||
plan: 'business',
|
||||
databaseName: 'horux_dde250101aaa',
|
||||
verticalProfile: 'CONTABLE',
|
||||
dbMode: 'MANAGED',
|
||||
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
console.log('✅ Tenant created:', tenant.nombre);
|
||||
} else {
|
||||
console.log('✅ Tenant exists:', tenant.nombre);
|
||||
}
|
||||
|
||||
// Create membership
|
||||
await prisma.tenantMembership.upsert({
|
||||
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||
update: {},
|
||||
create: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: 1,
|
||||
isOwner: true,
|
||||
},
|
||||
});
|
||||
console.log('✅ Membership created (owner)');
|
||||
|
||||
// Set lastTenantId
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastTenantId: tenant.id },
|
||||
});
|
||||
|
||||
console.log('\n🎉 Setup complete!');
|
||||
console.log('Login: ivan@horuxfin.com / Admin12345!');
|
||||
console.log('Tenant:', tenant.nombre, `(${tenant.rfc})`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Setup failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal file
47
apps/api/scripts/sweep-stale-sat-jobs.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* CLI wrapper del watchdog. La lógica vive en
|
||||
* `src/services/sat/sweep-stale-jobs.service.ts` para que también se pueda
|
||||
* correr desde un cron (`sat-sync.job.ts`) sin duplicar código.
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts # dry-run
|
||||
* pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts --apply # ejecuta
|
||||
* STALE_RUNNING_HOURS=2 pnpm --filter @horux/api exec tsx scripts/sweep-stale-sat-jobs.ts
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { sweepStaleSatJobs } from '../src/services/sat/sweep-stale-jobs.service.js';
|
||||
|
||||
async function main() {
|
||||
const apply = process.argv.includes('--apply');
|
||||
const pendingHours = Number(process.env.STALE_PENDING_HOURS || 12);
|
||||
const runningHours = Number(process.env.STALE_RUNNING_HOURS || 4);
|
||||
const mode = apply ? 'APPLY' : 'DRY-RUN';
|
||||
console.log(`=== SAT stale-jobs watchdog [${mode}] ===`);
|
||||
console.log(` pending: nextRetryAt < now − ${pendingHours}h`);
|
||||
console.log(` running: startedAt < now − ${runningHours}h`);
|
||||
console.log();
|
||||
|
||||
const result = await sweepStaleSatJobs({ apply, pendingHours, runningHours });
|
||||
|
||||
console.log(`Encontrados:`);
|
||||
console.log(` pending stale: ${result.pendingFound}`);
|
||||
console.log(` running stale: ${result.runningFound}`);
|
||||
|
||||
for (const e of result.entries) {
|
||||
console.log(` ─ ${e.id} tenant=${e.tenantId} kind=${e.kind} edad=${e.ageHours}h`);
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log(`\n[DRY-RUN] No se aplicaron cambios. Pasa --apply para marcar como failed.`);
|
||||
} else {
|
||||
console.log(`\nMarcados como failed: pending=${result.pendingMarked} running=${result.runningMarked}`);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
96
apps/api/scripts/test-emails.ts
Normal file
96
apps/api/scripts/test-emails.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { emailService } from '../src/services/email/email.service.js';
|
||||
|
||||
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
|
||||
|
||||
async function sendAllSamples() {
|
||||
for (const to of recipients) {
|
||||
console.log(`\n=== Enviando a ${to} ===`);
|
||||
|
||||
// 1. Welcome
|
||||
console.log('1/6 Bienvenida...');
|
||||
await emailService.sendWelcome(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
email: 'ivan@horuxfin.com',
|
||||
tempPassword: 'TempPass123!',
|
||||
});
|
||||
|
||||
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
|
||||
console.log('2/6 Notificación FIEL...');
|
||||
// Send directly since sendFielNotification goes to admin
|
||||
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
|
||||
const { createTransport } = await import('nodemailer');
|
||||
const { env } = await import('../src/config/env.js');
|
||||
const transport = createTransport({
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT),
|
||||
secure: false,
|
||||
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
|
||||
});
|
||||
const fielHtml = fielNotificationEmail({
|
||||
clienteNombre: 'Horux 360',
|
||||
clienteRfc: 'CAS200101XXX',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: '[Horux 360] subió su FIEL (MUESTRA)',
|
||||
html: fielHtml,
|
||||
});
|
||||
|
||||
// 3. Payment confirmed
|
||||
console.log('3/6 Pago confirmado...');
|
||||
await emailService.sendPaymentConfirmed(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
date: '16 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 4. Payment failed
|
||||
console.log('4/6 Pago fallido...');
|
||||
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
|
||||
const failedHtml = paymentFailedEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
amount: 1499,
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
|
||||
html: failedHtml,
|
||||
});
|
||||
|
||||
// 5. Subscription expiring
|
||||
console.log('5/6 Suscripción por vencer...');
|
||||
await emailService.sendSubscriptionExpiring(to, {
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
expiresAt: '21 de marzo de 2026',
|
||||
});
|
||||
|
||||
// 6. Subscription cancelled
|
||||
console.log('6/6 Suscripción cancelada...');
|
||||
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
|
||||
const cancelledHtml = subscriptionCancelledEmail({
|
||||
nombre: 'Ivan Alcaraz',
|
||||
plan: 'Enterprise',
|
||||
});
|
||||
await transport.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
to,
|
||||
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
|
||||
html: cancelledHtml,
|
||||
});
|
||||
|
||||
console.log(`Listo: 6 correos enviados a ${to}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Todos los correos enviados ===');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
sendAllSamples().catch((err) => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal file
97
apps/api/scripts/validate-dashboard-impuestos.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Valida la alineación dashboard ≡ impuestos tras refactor de getResumenIva.
|
||||
* Para 5 muestras aleatorias por contribuyente, compara:
|
||||
* dashboard.calcularIvaBalancePorRegimen().total vs
|
||||
* impuestos.getResumenIva().resultado
|
||||
*
|
||||
* Deben coincidir céntimo por céntimo (Resultado = Trasladado − Acreditable − Retenido,
|
||||
* usando los mismos 6 buckets del dashboard).
|
||||
*
|
||||
* Uso:
|
||||
* pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
|
||||
* METRICAS_BYPASS_CACHE=1 pnpm --filter @horux/api exec tsx scripts/validate-dashboard-impuestos.ts
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import * as dashboard from '../src/services/dashboard.service.js';
|
||||
import { getResumenIva } from '../src/services/impuestos.service.js';
|
||||
|
||||
const TOL = 0.01;
|
||||
|
||||
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 main() {
|
||||
console.log('=== Validación dashboard.balance ≡ impuestos.resultado ===');
|
||||
console.log(` BYPASS_CACHE=${process.env.METRICAS_BYPASS_CACHE === '1' ? 'YES' : 'no'}\n`);
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
let pass = 0;
|
||||
let fail = 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(`[${t.rfc}] ${contribs.length} contribuyentes`);
|
||||
|
||||
for (const c of contribs) {
|
||||
const { rows: samples } = await pool.query<{ anio: number; mes: number }>(
|
||||
`SELECT anio, mes FROM (
|
||||
SELECT DISTINCT anio, mes FROM metricas_mensuales WHERE contribuyente_id = $1
|
||||
) t
|
||||
ORDER BY random() LIMIT 5`,
|
||||
[c.entidad_id],
|
||||
);
|
||||
console.log(` ${c.nombre}:`);
|
||||
|
||||
for (const s of samples) {
|
||||
total++;
|
||||
const fi = `${s.anio}-${String(s.mes).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(s.anio, s.mes, 0).getDate();
|
||||
const ff = `${s.anio}-${String(s.mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const bal = await dashboard.calcularIvaBalancePorRegimen(
|
||||
pool, t.id, fi, ff, [], undefined, false, c.entidad_id,
|
||||
);
|
||||
const resumen = await getResumenIva(pool, fi, ff, t.id, false, c.entidad_id);
|
||||
|
||||
const mesLabel = `${s.anio}-${String(s.mes).padStart(2, '0')}`;
|
||||
if (cmp(bal.total, resumen.resultado)) {
|
||||
pass++;
|
||||
console.log(` ✓ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)}`);
|
||||
} else {
|
||||
fail++;
|
||||
const delta = bal.total - resumen.resultado;
|
||||
console.log(` ✗ ${mesLabel} balance=$${fmt(bal.total)} resultado=$${fmt(resumen.resultado)} Δ=$${fmt(delta)}`);
|
||||
console.log(` T=$${fmt(resumen.trasladado)} A=$${fmt(resumen.acreditable)} R=$${fmt(resumen.retenido)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== Resumen ===`);
|
||||
console.log(` Muestras: ${total}`);
|
||||
console.log(` PASS: ${pass}`);
|
||||
console.log(` FAIL: ${fail}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
115
apps/api/scripts/validate-gastos.ts
Normal file
115
apps/api/scripts/validate-gastos.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Compara Gastos del Dashboard vs Drill-down para un mes/contribuyente.
|
||||
* Identifica discrepancias y rompe el detalle por lado (factura/pago/NC).
|
||||
*
|
||||
* Uso: tsx scripts/validate-gastos.ts <tenantRfc> <entidadId> <añoMes>
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularEgresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfcArg = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || 'd745a915-6a23-4818-944b-a7e1e18e536a';
|
||||
const yearMonth = process.argv[4] || '2025-02';
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({
|
||||
where: { rfc: tenantRfcArg, active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const [anio, mes] = yearMonth.split('-').map(Number);
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const fi = `${yearMonth}-01`;
|
||||
const ff = `${yearMonth}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
console.log(`\n=== Contribuyente ${contribuyenteId} — ${fi} a ${ff} ===\n`);
|
||||
|
||||
// 1. Dashboard (calcularEgresosPorRegimen)
|
||||
const dashboard = await calcularEgresosPorRegimen(
|
||||
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
|
||||
);
|
||||
console.log('DASHBOARD calcularEgresosPorRegimen:');
|
||||
console.log(` total: ${dashboard.total.toFixed(2)}`);
|
||||
for (const r of dashboard.porRegimen) {
|
||||
console.log(` ${r.regimenClave} (${r.regimenDescripcion}): ${r.monto.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// 2. Drill-down query (simulated — bucket=gastos uniforme)
|
||||
const IMP_TRAS = `COALESCE(iva_traslado_mxn,0) + COALESCE(ieps_traslado_mxn,0) + COALESCE(impuestos_locales_trasladado_mxn,0)`;
|
||||
const IMP_TRAS_PAGO = `COALESCE(iva_traslado_pago_mxn,0) + COALESCE(ieps_traslado_pago_mxn,0)`;
|
||||
const EXCL_MONTO = `COALESCE((SELECT SUM(COALESCE(cc.importe_mxn,0) - COALESCE(cc.descuento_mxn,0)) FROM cfdi_conceptos cc WHERE cc.cfdi_id = cfdis.id AND cc.clave_prod_serv IN ('84121603','93161608','85101501','85121800')), 0)`;
|
||||
|
||||
// bucket=gastos: RECIBIDO I PUE + RECIBIDO P + RECIBIDO E PUE (excl 07)
|
||||
// Sumamos tomando en cuenta el signo (E resta)
|
||||
const { rows: drillRows } = await pool.query(
|
||||
`SELECT
|
||||
type, tipo_comprobante, metodo_pago,
|
||||
COALESCE(cfdi_tipo_relacion, '') AS tipo_rel,
|
||||
COUNT(*)::int AS n,
|
||||
SUM(total_mxn) AS total_bruto,
|
||||
SUM(COALESCE(total_mxn,0) - (${IMP_TRAS}) - (${EXCL_MONTO})) AS total_neto,
|
||||
SUM(COALESCE(monto_pago_mxn,0) - (${IMP_TRAS_PAGO})) AS pago_neto
|
||||
FROM cfdis
|
||||
WHERE (
|
||||
(type = 'RECIBIDO' AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
OR (type = 'RECIBIDO' AND tipo_comprobante = 'P')
|
||||
OR (type = 'RECIBIDO' AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07')
|
||||
)
|
||||
AND regimen_fiscal_receptor IN ('605','606','612','621','625','626','601','603','607','608','610','611','614','615','620','622','623','624')
|
||||
AND status NOT IN ('Cancelado','0')
|
||||
AND ((tipo_comprobante='P' AND fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day'))
|
||||
OR (tipo_comprobante!='P' AND fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')))
|
||||
AND contribuyente_id = $3
|
||||
GROUP BY type, tipo_comprobante, metodo_pago, tipo_rel
|
||||
ORDER BY tipo_comprobante, metodo_pago`,
|
||||
[fi, ff, contribuyenteId],
|
||||
);
|
||||
|
||||
console.log(`\nDRILL-DOWN bucket=gastos (filas del drill por bucket):`);
|
||||
let drillSumaFacturas = 0, drillSumaPagos = 0, drillSumaNC = 0;
|
||||
for (const r of drillRows) {
|
||||
const tc = r.tipo_comprobante;
|
||||
const valor = tc === 'P' ? Number(r.pago_neto) : Number(r.total_neto);
|
||||
console.log(` ${r.type} ${tc} ${r.metodo_pago || '-'} rel=${r.tipo_rel || '-'} n=${r.n} total_bruto=${Number(r.total_bruto).toFixed(2)} valor_neto=${valor.toFixed(2)}`);
|
||||
if (tc === 'I') drillSumaFacturas += valor;
|
||||
else if (tc === 'P') drillSumaPagos += valor;
|
||||
else if (tc === 'E') drillSumaNC += valor;
|
||||
}
|
||||
const drillTotal = drillSumaFacturas + drillSumaPagos - drillSumaNC;
|
||||
console.log(` → facturas=${drillSumaFacturas.toFixed(2)} pagos=${drillSumaPagos.toFixed(2)} NC=${drillSumaNC.toFixed(2)}`);
|
||||
console.log(` → drill total = ${drillTotal.toFixed(2)}`);
|
||||
|
||||
// 3. Comparación
|
||||
const delta = dashboard.total - drillTotal;
|
||||
console.log(`\n=== COMPARATIVA ===`);
|
||||
console.log(` Dashboard: ${dashboard.total.toFixed(2)}`);
|
||||
console.log(` Drill-down: ${drillTotal.toFixed(2)}`);
|
||||
console.log(` Delta: ${delta.toFixed(2)}`);
|
||||
|
||||
if (Math.abs(delta) < 0.01) {
|
||||
console.log(` ✓ CUADRAN`);
|
||||
} else {
|
||||
console.log(` ✗ NO CUADRAN — investigar`);
|
||||
}
|
||||
|
||||
// 4. Régimenes del receptor que aparecen vs los ignorados
|
||||
const { rows: regsReceptor } = await pool.query(
|
||||
`SELECT DISTINCT regimen_fiscal_receptor
|
||||
FROM cfdis
|
||||
WHERE contribuyente_id = $1
|
||||
AND type = 'RECIBIDO'
|
||||
AND fecha_emision >= $2::date AND fecha_emision < ($3::date + interval '1 day')
|
||||
ORDER BY regimen_fiscal_receptor`,
|
||||
[contribuyenteId, fi, ff],
|
||||
);
|
||||
console.log(`\nRegímenes en CFDIs RECIBIDOS del periodo:`, regsReceptor.map(r => r.regimen_fiscal_receptor).join(', '));
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
39
apps/api/scripts/validate-ingresos.ts
Normal file
39
apps/api/scripts/validate-ingresos.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Paridad dashboard vs drill para INGRESOS de un contribuyente en un año.
|
||||
* Similar a validate-gastos pero para el lado emisor.
|
||||
*/
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
import { calcularIngresosPorRegimen } from '../src/services/dashboard.service.js';
|
||||
|
||||
const tenantRfc = process.argv[2] || 'DESPACHO_MO3NI6U8_B9VGG';
|
||||
const contribuyenteId = process.argv[3] || '414b22a8-c6e2-4f39-be0f-7537a848107e';
|
||||
const año = Number(process.argv[4] || '2025');
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findFirst({ where: { rfc: tenantRfc }, select: { id: true, databaseName: true } });
|
||||
if (!tenant) { console.error('Tenant not found'); process.exit(1); }
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`\n=== Ingresos ${año} Contribuyente ${contribuyenteId} ===\n`);
|
||||
console.log(`mes | total por régimen | total mes`);
|
||||
|
||||
let totalAño = 0;
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const lastDay = new Date(año, m, 0).getDate();
|
||||
const mm = String(m).padStart(2, '0');
|
||||
const fi = `${año}-${mm}-01`;
|
||||
const ff = `${año}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const ingresos = await calcularIngresosPorRegimen(
|
||||
pool, tenant.id, fi, ff, undefined, undefined, false, contribuyenteId,
|
||||
);
|
||||
|
||||
const porReg = ingresos.porRegimen.map(r => `${r.regimenClave}:${r.monto.toFixed(2)}`).join(' / ');
|
||||
console.log(`${mm} | ${porReg || '(sin datos)'} | ${ingresos.total.toFixed(2)}`);
|
||||
totalAño += ingresos.total;
|
||||
}
|
||||
console.log(`\nTotal año: ${totalAño.toFixed(2)}`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(async e => { console.error(e); await prisma.$disconnect().catch(() => {}); process.exit(1); });
|
||||
160
apps/api/scripts/validate-metricas.ts
Normal file
160
apps/api/scripts/validate-metricas.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user