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:
@@ -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