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:
@@ -333,7 +333,7 @@ export async function getCancelados(req: Request, res: Response, next: NextFunct
|
||||
total_mxn as "totalMxn", fecha_cancelacion as "fechaCancelacion"
|
||||
FROM cfdis
|
||||
WHERE status IN ('Cancelado', '0')
|
||||
AND fecha_emision >= $1::date
|
||||
AND (fecha_emision - interval '1 hour') >= $1::date
|
||||
${cf}
|
||||
ORDER BY fecha_emision DESC
|
||||
`, [hace5.toISOString().split('T')[0]]);
|
||||
@@ -364,7 +364,7 @@ export async function getCancelacionesPeriodoAnterior(req: Request, res: Respons
|
||||
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}
|
||||
ORDER BY fecha_cancelacion DESC
|
||||
`, [inicioMes]);
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function uploadFiel(req: Request, res: Response, next: NextFunction
|
||||
return next(new AppError(400, 'cerFile, keyFile y password son requeridos'));
|
||||
}
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
|
||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
|
||||
const result = await fielService.uploadFielContribuyente(req.tenantPool!, contribuyenteId, cerFile, keyFile, password);
|
||||
@@ -62,7 +62,7 @@ export async function deleteFiel(req: Request, res: Response, next: NextFunction
|
||||
export async function createOrg(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const contribuyenteId = String(req.params.id);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId);
|
||||
const contrib = await getContribuyenteById(req.tenantPool!, contribuyenteId, req.user!.tenantId);
|
||||
if (!contrib) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
|
||||
const result = await facturapiService.createOrgContribuyente(req.tenantPool!, contribuyenteId, contrib.nombre);
|
||||
|
||||
@@ -40,14 +40,14 @@ const updateSchema = createSchema.partial();
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
|
||||
return res.json({ data: rows });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
export async function getById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id));
|
||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
|
||||
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
return res.json(row);
|
||||
} catch (err) { return next(err); }
|
||||
|
||||
@@ -118,6 +118,10 @@ CREATE TABLE IF NOT EXISTS cfdis (
|
||||
facturapi_id VARCHAR(50),
|
||||
regimen_fiscal_emisor VARCHAR(3),
|
||||
regimen_fiscal_receptor VARCHAR(3),
|
||||
periodicidad VARCHAR(2),
|
||||
meses_global VARCHAR(10),
|
||||
año_global VARCHAR(4),
|
||||
fecha_efectiva DATE,
|
||||
creado_en TIMESTAMP DEFAULT NOW(),
|
||||
actualizado_en TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
11
apps/api/src/migrations/tenant/045_factura_global.sql
Normal file
11
apps/api/src/migrations/tenant/045_factura_global.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Migration: 007_factura_global
|
||||
-- Description: Agrega campos de InformacionGlobal y fecha_efectiva para facturas globales
|
||||
|
||||
ALTER TABLE cfdis
|
||||
ADD COLUMN IF NOT EXISTS periodicidad VARCHAR(2),
|
||||
ADD COLUMN IF NOT EXISTS meses_global VARCHAR(10),
|
||||
ADD COLUMN IF NOT EXISTS año_global VARCHAR(4),
|
||||
ADD COLUMN IF NOT EXISTS fecha_efectiva DATE;
|
||||
|
||||
-- Crear índice para acelerar métricas que filtran por fecha_efectiva
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_efectiva ON cfdis(fecha_efectiva);
|
||||
23
apps/api/src/scripts/recalc-metricas.ts
Normal file
23
apps/api/src/scripts/recalc-metricas.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { tenantDb } from '../config/database.js';
|
||||
import { computeMetricaMensual } from '../services/metricas-compute.service.js';
|
||||
|
||||
async function main() {
|
||||
const tenantId = 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a';
|
||||
const dbName = 'horux_hts240708lja';
|
||||
const contribuyenteId = '4a1d6014-f705-424b-b185-7740be6a80c6';
|
||||
const pool = await tenantDb.getPool(tenantId, dbName);
|
||||
|
||||
for (const mes of [1, 2, 3]) {
|
||||
console.log(`Recalculando 2026-${String(mes).padStart(2, '0')}...`);
|
||||
const r = await computeMetricaMensual(pool, tenantId, contribuyenteId, 2026, mes);
|
||||
console.log(` Filas escritas: ${r.filasEscritas}`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user