feat(sat): factura global + fecha_efectiva, fallback tenant-contribuyente, fix anio_global typo

Factura Global & fecha_efectiva:
- Migracion 045_factura_global.sql: periodicidad, meses_global, año_global, fecha_efectiva
- sat-parser.service.ts: extrae InformacionGlobal del XML
- sat.service.ts: calcFechaEfectiva con soporte bimestral (periodicidad 05)
- metricas-compute, dashboard, impuestos, cfdi, export, conciliacion, alertas:
  reemplaza fecha_emision-1h por COALESCE(fecha_efectiva, fecha_emision-1h)
- Script recalc-metricas.ts para recalculo manual

Fallback datos fiscales tenant → contribuyente:
- contribuyente.service.ts: fetchTenantFiscalData + mergeContribuyenteWithTenant
  rellena regimenFiscal, codigoPostal y domicilio cuando el contribuyente
  tiene el mismo RFC que el tenant y sus campos estan vacios
- contribuyente.controller.ts y contribuyente-config.controller.ts:
  pasan req.user!.tenantId al servicio

Fix critico SAT sync:
- sat.service.ts: anio_global → año_global en INSERT/UPDATE de CFDIs
  (la migracion creo 'año_global' con tilde; el codigo usaba 'anio_global',
   causando fallo en 100% de inserciones de CFDI)
- determineChunkMonths: salta sondeo si existe job previo con requestIds
- MAX_POLL_ATTEMPTS: 45 → 500 (~8h) para syncs iniciales grandes

Docs:
- docs/sessions/2026-05-22-factura-global-contribuyente-fallback.md
This commit is contained in:
Horux Dev
2026-05-22 15:52:10 +00:00
parent ba6004ebd6
commit 46846200da
33 changed files with 1128 additions and 171 deletions

View File

@@ -176,7 +176,7 @@ async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string |
COUNT(*)::int as total,
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
FROM cfdis
WHERE fecha_emision >= $1::date
WHERE (fecha_emision - interval '1 hour') >= $1::date
${cf}
`, [fechaDesde]);
@@ -359,7 +359,7 @@ async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: st
FROM cfdis
WHERE status IN ('Cancelado', '0')
AND fecha_cancelacion >= $1::date
AND fecha_emision < $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < $1::date
${cf}
`, [inicioMes]);
@@ -529,7 +529,7 @@ async function alertaResicoPfLimiteIngresos(
FROM cfdis
WHERE type = 'EMITIDO'
AND status NOT IN ('Cancelado', '0')
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour')) = $1
AND contribuyente_id = $2
`, [año, safeId]);
@@ -659,8 +659,8 @@ export async function getDiscrepanciasPorMes(
const { rows } = await pool.query(`
SELECT
EXTRACT(YEAR FROM fecha_emision)::int as año,
EXTRACT(MONTH FROM fecha_emision)::int as mes,
EXTRACT(YEAR FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as año,
EXTRACT(MONTH FROM COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'))::int as mes,
COUNT(*)::int as count
FROM cfdis
WHERE type = 'RECIBIDO' AND ${VIGENTE}

View File

@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
}
if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
@@ -214,11 +214,11 @@ export async function getConceptosList(
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`;
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.rfc) {
@@ -746,7 +746,10 @@ export async function getReceptores(pool: Pool, search: string, limit: number =
}
export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) {
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND year = $1 AND month = $2`;
const fi = `${año}-${String(mes).padStart(2, '0')}-01`;
const lastDay = new Date(año, mes, 0).getDate();
const ff = `${año}-${String(mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $2::date`;
if (contribuyenteId) {
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
@@ -761,7 +764,7 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable
FROM cfdis
${whereClause}
`, [String(año), String(mes).padStart(2, '0')]);
`, [fi, ff]);
const r = rows[0];
return {

View File

@@ -68,11 +68,11 @@ export async function getCfdisConConciliacion(
}
if (filters.fechaInicio) {
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) >= $${idx++}::date`;
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END >= $${idx++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
where += ` AND COALESCE(c.fecha_pago_p, c.fecha_emision) <= ($${idx++}::date + interval '1 day')`;
where += ` AND CASE WHEN c.tipo_comprobante = 'P' THEN c.fecha_pago_p - interval '1 hour' ELSE COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') END <= ($${idx++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.regimen) {

View File

@@ -1,4 +1,5 @@
import type { Pool } from 'pg';
import { prisma } from '../config/database.js';
export interface CreateContribuyenteData {
rfc: string;
@@ -23,7 +24,61 @@ export interface ContribuyenteRow {
domicilio: Record<string, unknown> | null;
}
export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Promise<ContribuyenteRow[]> {
async function fetchTenantFiscalData(tenantId: string) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
rfc: true,
codigoPostal: true,
calle: true,
numExterior: true,
numInterior: true,
colonia: true,
ciudad: true,
municipio: true,
estado: true,
telefono: true,
},
});
if (!tenant) return null;
const regimenes = await prisma.tenantRegimenActivo.findMany({
where: { tenantId },
select: { regimen: { select: { clave: true } } },
});
const regimenFiscal = regimenes.map(r => r.regimen.clave).join(',') || null;
const hasAnyAddress = tenant.calle || tenant.colonia || tenant.ciudad || tenant.municipio || tenant.estado || tenant.codigoPostal;
const domicilio = hasAnyAddress
? {
calle: tenant.calle || '',
numExterior: tenant.numExterior || '',
numInterior: tenant.numInterior || '',
colonia: tenant.colonia || '',
ciudad: tenant.ciudad || '',
municipio: tenant.municipio || '',
estado: tenant.estado || '',
codigoPostal: tenant.codigoPostal || '',
telefono: tenant.telefono || '',
}
: null;
return { tenantRfc: tenant.rfc, regimenFiscal, codigoPostal: tenant.codigoPostal, domicilio };
}
function mergeContribuyenteWithTenant(
row: ContribuyenteRow,
tenantData: NonNullable<Awaited<ReturnType<typeof fetchTenantFiscalData>>>
): ContribuyenteRow {
return {
...row,
regimenFiscal: row.regimenFiscal || tenantData.regimenFiscal,
codigoPostal: row.codigoPostal || tenantData.codigoPostal,
domicilio: row.domicilio || tenantData.domicilio,
};
}
export async function listContribuyentes(pool: Pool, entidadIds?: string[], tenantId?: string): Promise<ContribuyenteRow[]> {
let query = `
SELECT
e.id, e.tipo, e.nombre, e.identificador,
@@ -45,10 +100,20 @@ export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Pro
query += ' ORDER BY e.created_at DESC';
const { rows } = await pool.query(query, params);
return rows;
if (!tenantId) return rows;
const tenantData = await fetchTenantFiscalData(tenantId);
if (!tenantData) return rows;
return rows.map((r: ContribuyenteRow) => {
if (r.rfc !== tenantData.tenantRfc) return r;
if (r.regimenFiscal && r.codigoPostal && r.domicilio) return r;
return mergeContribuyenteWithTenant(r, tenantData);
});
}
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> {
export async function getContribuyenteById(pool: Pool, id: string, tenantId?: string): Promise<ContribuyenteRow | null> {
const { rows } = await pool.query(`
SELECT
e.id, e.tipo, e.nombre, e.identificador,
@@ -60,7 +125,14 @@ export async function getContribuyenteById(pool: Pool, id: string): Promise<Cont
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.id = $1
`, [id]);
return rows[0] ?? null;
const row = rows[0] ?? null;
if (!row || !tenantId) return row;
const tenantData = await fetchTenantFiscalData(tenantId);
if (!tenantData || row.rfc !== tenantData.tenantRfc) return row;
if (row.regimenFiscal && row.codigoPostal && row.domicilio) return row;
return mergeContribuyenteWithTenant(row, tenantData);
}
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {

View File

@@ -109,13 +109,13 @@ export const GRUPO_PM_OTROS = ['601', '603', '607', '608', '610', '611', '614',
const TODOS_REGIMENES = [...GRUPO_PF_EMPRESARIAL, ...GRUPO_SUELDOS, ...GRUPO_PM_OTROS];
// Filtro de fecha por rango — normal o conciliación
const FECHA_RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const FECHA_RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
// Para CFDIs tipo P (complementos de pago): el ingreso/gasto se reconoce en la
// fecha_pago_p (cuándo el cliente realmente pagó), no cuando se emitió el
// complemento — el CFDI P puede emitirse hasta el día 5 del mes siguiente al
// pago, o incluso después, y cruzar meses (ej. pago de noviembre 2024 con
// complemento emitido en mayo 2025).
const FECHA_PAGO_RANGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
const FECHA_PAGO_RANGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
)`;
@@ -989,14 +989,14 @@ export async function calcularIvaBalancePorRegimen(
AND e.status NOT IN ('Cancelado', '0')
AND ${esEmisor.replace(/\brfc_emisor\b/g, 'e.rfc_emisor')}
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
)), 0) as monto
FROM cfdis i
WHERE ${esEmisor.replace(/\brfc_emisor\b/g, 'i.rfc_emisor')}
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
AND i.status NOT IN ('Cancelado','0')
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND i.regimen_fiscal_emisor = ANY($3)
GROUP BY i.regimen_fiscal_emisor
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);
@@ -1012,14 +1012,14 @@ export async function calcularIvaBalancePorRegimen(
AND e.status NOT IN ('Cancelado', '0')
AND ${esReceptor.replace(/\brfc_receptor\b/g, 'e.rfc_receptor')}
AND LOWER(i.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision) = date_trunc('month', i.fecha_emision)
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour')) = date_trunc('month', COALESCE(i.fecha_efectiva, i.fecha_emision - interval '1 hour'))
)), 0) as monto
FROM cfdis i
WHERE ${esReceptor.replace(/\brfc_receptor\b/g, 'i.rfc_receptor')}
AND i.tipo_comprobante = 'I' AND i.metodo_pago = 'PPD'
AND COALESCE(i.cfdi_tipo_relacion, '') = '07'
AND i.status NOT IN ('Cancelado','0')
AND ${FR.replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND ${FR.replace(/\bfecha_efectiva\b/g, 'i.fecha_efectiva').replace(/\bfecha_emision\b/g, 'i.fecha_emision')}
AND i.regimen_fiscal_receptor = ANY($3)
GROUP BY i.regimen_fiscal_receptor
`, [fechaInicio, fechaFin, TODOS_REGIMENES]);

View File

@@ -18,11 +18,11 @@ export async function exportCfdisToExcel(
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= $${paramIndex++}`;
params.push(filters.fechaFin);
}
@@ -74,7 +74,7 @@ export async function exportCfdisToExcel(
cfdis.forEach((cfdi: any) => {
sheet.addRow({
...cfdi,
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
fecha_emision: new Date(new Date(cfdi.fecha_emision).getTime() - 60*60*1000).toLocaleDateString('es-MX'),
subtotal: Number(cfdi.subtotal),
subtotal_mxn: Number(cfdi.subtotal_mxn),
iva_traslado: Number(cfdi.iva_traslado),
@@ -103,7 +103,7 @@ export async function exportReporteToExcel(
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos
FROM cfdis
WHERE status NOT IN ('Cancelado', '0') AND fecha_emision BETWEEN $1 AND $2
WHERE status NOT IN ('Cancelado', '0') AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1 AND $2
`, [fechaInicio, fechaFin]);
sheet.columns = [

View File

@@ -22,7 +22,7 @@ const VIGENTE = `status NOT IN ('Cancelado', '0')`;
// - otros tipos (I, E, T, N): fecha_emision del comprobante
// El CASE se evalúa por fila, garantizando que un P emitido en mayo por un pago
// real de noviembre quede contabilizado en noviembre.
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN fecha_pago_p ELSE fecha_emision END`;
const FECHA_EFECTIVA = `CASE WHEN tipo_comprobante = 'P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END`;
const FECHA_RANGO = `${FECHA_EFECTIVA} >= $1::date AND ${FECHA_EFECTIVA} < ($2::date + interval '1 day')`;
const FECHA_RANGO_CONCILIACION = `id_conciliacion IS NOT NULL AND id_conciliacion IN (
SELECT id FROM conciliaciones WHERE fecha_de_pago >= $1::date AND fecha_de_pago < ($2::date + interval '1 day')
@@ -114,8 +114,8 @@ const SUM_E_REFERENCING_TRAS = (
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
const SUM_E_REFERENCING_RET = (
esLadoE: string,
@@ -129,8 +129,8 @@ const SUM_E_REFERENCING_RET = (
AND e.status NOT IN ('Cancelado', '0')
AND ${esLadoE}
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(e.cfdis_relacionados), '|'))
AND date_trunc('month', e.fecha_emision)
= date_trunc('month', cfdis.fecha_emision)${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
AND date_trunc('month', COALESCE(e.fecha_efectiva, e.fecha_emision - interval '1 hour'))
= date_trunc('month', COALESCE(cfdis.fecha_efectiva, cfdis.fecha_emision - interval '1 hour'))${buildExtraFiltersAlias('e', considerarActivos, considerarNCs)}
), 0)`;
// Régimen del contribuyente según su lado: emisor/receptor del CFDI.
// Usa el RFC del contribuyente (via `ctx.esEmisor`/`ctx.esReceptor`) para

View File

@@ -92,8 +92,8 @@ export async function computeMetricaMensual(
COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes,
COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados
FROM cfdis
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $1
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $2
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $1
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN (fecha_pago_p - interval '1 hour') ELSE COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') END)) = $2
AND contribuyente_id = $3
GROUP BY 1, 2
`, [anio, mes, safeContrib]);
@@ -227,7 +227,7 @@ export async function backfillTenant(
for (const c of contribs) {
const { rows: [rango] } = await pool.query<{ min_anio: number | null }>(
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision))::int AS min_anio
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision - interval '1 hour'))::int AS min_anio
FROM cfdis WHERE contribuyente_id = $1`,
[c.entidad_id],
);

View File

@@ -94,12 +94,12 @@ export async function getFlujoEfectivo(
contribuyenteId?: string | null,
): Promise<FlujoEfectivo> {
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
const { rows: entradasPUE } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND ${VIGENTE} AND ${RANGO}
@@ -107,7 +107,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: entradasPago } = await pool.query(`
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'P'
AND ${VIGENTE} AND ${RANGO_PAGO}
@@ -115,7 +115,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: entradasNC } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
@@ -124,7 +124,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: salidasPUE } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
AND ${VIGENTE} AND ${RANGO}
@@ -132,7 +132,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: salidasPago } = await pool.query(`
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
SELECT TO_CHAR(fecha_pago_p - interval '1 hour', 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'P'
AND ${VIGENTE} AND ${RANGO_PAGO}
@@ -140,7 +140,7 @@ export async function getFlujoEfectivo(
`, [fechaInicio, fechaFin]);
const { rows: salidasNC } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
SELECT TO_CHAR(COALESCE(fecha_efectiva, fecha_emision - interval '1 hour'), 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
@@ -187,8 +187,8 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
const fi = `${año}-01-01`;
const ff = `${año}-12-31`;
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
const RANGO = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const RANGO_PAGO = `(fecha_pago_p - interval '1 hour') >= $1::date AND (fecha_pago_p - interval '1 hour') < ($2::date + interval '1 day')`;
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => {
@@ -198,7 +198,7 @@ async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: s
const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : '';
const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor;
const { rows } = await pool.query(`
SELECT EXTRACT(MONTH FROM ${fechaCol})::int as mes, COALESCE(SUM(${campo}), 0) as total
SELECT EXTRACT(MONTH FROM COALESCE(fecha_efectiva, ${fechaCol} - interval '1 hour'))::int as mes, COALESCE(SUM(${campo}), 0) as total
FROM cfdis
WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango}
GROUP BY mes
@@ -277,7 +277,7 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis"
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
AND fecha_emision BETWEEN $1::date AND $2::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY "totalFacturado" DESC
`, [fechaInicio, fechaFin]);
@@ -298,7 +298,7 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis"
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
AND fecha_emision BETWEEN $1::date AND $2::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY "totalFacturado" DESC
`, [fechaInicio, fechaFin]);
@@ -338,8 +338,8 @@ export async function getCuentasXPagar(
FROM cfdis
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')
AND fecha_emision >= $1::date
AND fecha_emision < ($2::date + interval '1 day')
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
${regimenFilter}
GROUP BY rfc_emisor, nombre_emisor
@@ -365,7 +365,7 @@ export async function getCuentasXPagar(
// ─────────────────────────────────────────────────────────────────────────────
const VIGENTE_ER = `status NOT IN ('Cancelado', '0')`;
const RANGO_FECHA = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
const RANGO_FECHA = `COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')`;
const BASE_MONTO = `COALESCE(subtotal_mxn, 0) - COALESCE(descuento_mxn, 0)`;
function sameDateLastYear(dateStr: string): string {
@@ -842,8 +842,8 @@ export async function getCuentasXCobrar(
FROM cfdis
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
AND status NOT IN ('Cancelado', '0')
AND fecha_emision >= $1::date
AND fecha_emision < ($2::date + interval '1 day')
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $1::date
AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') < ($2::date + interval '1 day')
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
${regimenFilter}
GROUP BY rfc_receptor, nombre_receptor

View File

@@ -69,6 +69,11 @@ interface CfdiParsed {
cfdisRelacionados: string | null;
conceptos: ConceptoParsed[];
xmlOriginal: string;
// Factura global (InformacionGlobal)
periodicidad: string | null;
mesesGlobal: string | null;
añoGlobal: string | null;
}
interface ConceptoParsed {
@@ -569,6 +574,9 @@ export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibid
...nominaData,
conceptos: extractConceptos(comprobante),
xmlOriginal: xmlContent,
periodicidad: comprobante.InformacionGlobal?.['@_Periodicidad'] || null,
mesesGlobal: comprobante.InformacionGlobal?.['@_Meses'] || null,
añoGlobal: comprobante.InformacionGlobal?.['@_Año'] || null,
};
if (!cfdi.uuid) {

View File

@@ -18,7 +18,7 @@ import * as fs from 'fs';
import * as path from 'path';
const POLL_INTERVAL_MS = 60000; // 60 segundos
const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s)
const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
/**
@@ -121,6 +121,35 @@ async function getOrCreateRfc(pool: Pool, rfc: string, razonSocial: string | nul
return rows[0].id;
}
/**
* Calcula la fecha efectiva de un CFDI para métricas.
* Si tiene InformacionGlobal, usa el año/mes declarado.
* Para bimestral (periodicidad 05), convierte el código 13-18 a mes 2-12.
*/
function calcFechaEfectiva(cfdi: CfdiParsed): Date | null {
if (!cfdi.añoGlobal || !cfdi.mesesGlobal) {
return null;
}
const anio = parseInt(cfdi.añoGlobal, 10);
if (isNaN(anio)) return null;
const mesesStr = cfdi.mesesGlobal;
const mesesParts = mesesStr.split(',').map((s: string) => s.trim());
const ultimoMesStr = mesesParts[mesesParts.length - 1];
let mes = parseInt(ultimoMesStr, 10);
if (isNaN(mes)) return null;
// Bimestral: códigos 13-18 → meses 2,4,6,8,10,12
if (cfdi.periodicidad === '05') {
if (mes >= 13 && mes <= 18) {
mes = (mes - 12) * 2;
}
}
if (mes < 1 || mes > 12) return null;
return new Date(anio, mes - 1, 1);
}
/**
* Guarda los XMLs extraídos del ZIP en disco para respaldo
*/
@@ -212,6 +241,10 @@ async function saveCfdis(
cfdi.subsidioCausado, m(cfdi.subsidioCausado),
cfdi.regimenFiscalEmisor, cfdi.regimenFiscalReceptor,
cfdi.codigoPostalReceptor,
cfdi.periodicidad,
cfdi.mesesGlobal,
cfdi.añoGlobal,
calcFechaEfectiva(cfdi),
cfdi.xmlOriginal,
cfdi.cfdiTipoRelacion, cfdi.cfdisRelacionados,
jobId,
@@ -261,16 +294,17 @@ async function saveCfdis(
subsidio_causado=$78, subsidio_causado_mxn=$79,
regimen_fiscal_emisor=$80, regimen_fiscal_receptor=$81,
codigo_postal_receptor=$82,
xml_original=$83,
cfdi_tipo_relacion=$84, cfdis_relacionados=$85,
last_sat_sync=NOW(), sat_sync_job_id=$86::uuid,
periodicidad=$83, meses_global=$84, año_global=$85, fecha_efectiva=$86,
xml_original=$87,
cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
actualizado_en=NOW()
WHERE uuid = $1`,
[cfdi.uuid, ...vals]
);
// Re-insert conceptos for updated CFDI
await pool.query(`DELETE FROM cfdi_conceptos WHERE cfdi_id = $1`, [existing[0].id]);
await saveConceptos(pool, existing[0].id, cfdi);
await saveConceptosWithRetry(pool, existing[0].id, cfdi);
updated++;
} else {
// $1-$83 = data fields (year..cfdis_relacionados), $84 = jobId, $85 = contribuyente_id
@@ -310,6 +344,7 @@ async function saveCfdis(
subsidio_causado, subsidio_causado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
codigo_postal_receptor,
periodicidad, meses_global, año_global, fecha_efectiva,
xml_original,
cfdi_tipo_relacion, cfdis_relacionados,
source, sat_sync_job_id, last_sat_sync, contribuyente_id
@@ -321,7 +356,7 @@ async function saveCfdis(
);
// Get the inserted cfdi id and save conceptos
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]);
if (newRow) await saveConceptos(pool, newRow.id, cfdi);
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
inserted++;
}
// Marcar el mes para recompute de métricas pre-calculadas. Para tipo P
@@ -404,6 +439,26 @@ async function saveConceptos(pool: Pool, cfdiId: number, cfdi: CfdiParsed): Prom
}
}
/** Reintenta saveConceptos con backoff exponencial para tolerar errores transitorios. */
async function saveConceptosWithRetry(pool: Pool, cfdiId: number, cfdi: CfdiParsed, maxRetries = 3): Promise<void> {
let lastError: any;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await saveConceptos(pool, cfdiId, cfdi);
return;
} catch (err: any) {
lastError = err;
if (attempt < maxRetries) {
const delay = 500 * attempt;
console.warn(`[SAT] saveConceptos falló (intento ${attempt}/${maxRetries}) para CFDI ${cfdi.uuid}, reintentando en ${delay}ms...`);
await new Promise(r => setTimeout(r, delay));
}
}
}
console.error(`[SAT] saveConceptos falló definitivamente después de ${maxRetries} intentos para CFDI ${cfdi.uuid}:`, lastError?.message || lastError);
throw lastError;
}
/**
* Guarda/actualiza CFDIs desde metadata del SAT.
* - Si el CFDI no existe: inserta con datos básicos de metadata (sin XML).
@@ -770,6 +825,26 @@ async function determineChunkMonths(
fechaInicio: Date,
fechaFin: Date,
): Promise<number> {
// Si el job previo del mismo tenant/contribuyente ya tenía chunks,
// inferimos que el volumen es alto y usamos 6 meses directamente
// para evitar el sondeo lento del SAT.
const previousJob = await prisma.satSyncJob.findFirst({
where: {
tenantId: ctx.tenantId,
contribuyenteId: ctx.contribuyenteId ?? null,
id: { not: jobId },
status: 'completed',
cfdisFound: { gt: 0 },
},
orderBy: { createdAt: 'desc' },
select: { satRequestIds: true, cfdisFound: true },
});
if (previousJob?.satRequestIds && Object.keys(previousJob.satRequestIds as Record<string, string>).length > 0) {
const chunkMonths = (previousJob.cfdisFound || 0) > 15_000 ? 3 : 6;
console.log(`[SAT] Reutilizando estrategia de job previo (${previousJob.cfdisFound} CFDIs) → bloques de ${chunkMonths} meses`);
return chunkMonths;
}
const THRESHOLD = 15_000;
let totalCfdis = 0;