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

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