feat(sat): metadata histórica solo domingos, 404 no fatal en daily, polling 9x5min y docs
- processDailySync ejecuta metadata histórica únicamente los domingos en CDMX. - Errores 404 en daily se registran en error_message sin abortar el job. - Timeouts/transitorios reales siguen lanzándose para reintento. - Polling reducido a 9 intentos máximos cada 5 minutos por solicitud. - Actualiza docs/SAT-SYNC-IMPLEMENTATION.md con arquitectura, crons, manejo de errores y comandos.
This commit is contained in:
@@ -17,10 +17,24 @@ import type { Pool } from 'pg';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 60000; // 60 segundos
|
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones
|
||||||
const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes
|
const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud (~45 min total)
|
||||||
|
const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily: 9 intentos × 5 min
|
||||||
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fecha final segura para consultas al SAT.
|
||||||
|
*
|
||||||
|
* El SAT rechaza fechas futuras e incluso "hoy" en algunos horarios/condiciones,
|
||||||
|
* devolviendo "Fecha final invalida". Usamos el día anterior a medio día UTC,
|
||||||
|
* que al interpretarse en America/Mexico_City siempre cae en "ayer" y evita
|
||||||
|
* tanto fechas futuras como problemas de cambio de día por zona horaria.
|
||||||
|
*/
|
||||||
|
function getYesterdayEnd(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1, 12, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Política de retry por tipo de sync.
|
* Política de retry por tipo de sync.
|
||||||
* - `retryAtHours[i]` = horas DESDE startedAt para el retry i+1.
|
* - `retryAtHours[i]` = horas DESDE startedAt para el retry i+1.
|
||||||
@@ -49,6 +63,12 @@ const RETRY_POLICIES: Record<'daily' | 'custom' | 'initial' | 'incremental', {
|
|||||||
incremental: { maxRetries: 0, retryAtHours: [] },
|
incremental: { maxRetries: 0, retryAtHours: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Límite total de intentos para jobs diarios. Incluye el intento original más
|
||||||
|
* retries automáticos (6h/12h) y los retries fijos de 9 AM / 4 PM CDMX.
|
||||||
|
*/
|
||||||
|
const MAX_DAILY_RETRY_ATTEMPTS = 5;
|
||||||
|
|
||||||
function getRetryPolicy(job: { type: SatSyncType; isCustomRange: boolean }) {
|
function getRetryPolicy(job: { type: SatSyncType; isCustomRange: boolean }) {
|
||||||
if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom;
|
if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom;
|
||||||
return RETRY_POLICIES[job.type];
|
return RETRY_POLICIES[job.type];
|
||||||
@@ -598,6 +618,7 @@ async function requestAndDownload(
|
|||||||
fechaFin: Date,
|
fechaFin: Date,
|
||||||
tipoCfdi: CfdiSyncType,
|
tipoCfdi: CfdiSyncType,
|
||||||
requestType: 'cfdi' | 'metadata',
|
requestType: 'cfdi' | 'metadata',
|
||||||
|
isDaily = false,
|
||||||
): Promise<{ packageContents: string[]; totalCfdis: number }> {
|
): Promise<{ packageContents: string[]; totalCfdis: number }> {
|
||||||
const label = `${tipoCfdi}/${requestType}`;
|
const label = `${tipoCfdi}/${requestType}`;
|
||||||
const kindKey = makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType);
|
const kindKey = makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType);
|
||||||
@@ -650,6 +671,10 @@ async function requestAndDownload(
|
|||||||
|
|
||||||
// Estados terminales inválidos → descartar y crear nuevo
|
// Estados terminales inválidos → descartar y crear nuevo
|
||||||
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
||||||
|
if (isAgotadas(verifyResult.message)) {
|
||||||
|
console.log(`[SAT] Solicitud reusada agotada de por vida (${label}); se cancela y se omite, no se recrea.`);
|
||||||
|
return { packageContents: [], totalCfdis: 0 };
|
||||||
|
}
|
||||||
console.log(`[SAT] Request reusado en estado ${verifyResult.status}, creando nuevo`);
|
console.log(`[SAT] Request reusado en estado ${verifyResult.status}, creando nuevo`);
|
||||||
requestId = null;
|
requestId = null;
|
||||||
verifyResult = undefined;
|
verifyResult = undefined;
|
||||||
@@ -670,10 +695,20 @@ async function requestAndDownload(
|
|||||||
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType);
|
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType);
|
||||||
|
|
||||||
if (!queryResult.success) {
|
if (!queryResult.success) {
|
||||||
if (queryResult.statusCode === '5004') {
|
if (queryResult.statusCode === '5004' || queryResult.statusCode === 'exhausted' || isAgotadas(queryResult.message)) {
|
||||||
console.log(`[SAT] No se encontraron CFDIs (${label})`);
|
console.log(`[SAT] Sin CFDIs, quota agotada o solicitudes agotadas (${label}): ${queryResult.message}`);
|
||||||
return { packageContents: [], totalCfdis: 0 };
|
return { packageContents: [], totalCfdis: 0 };
|
||||||
}
|
}
|
||||||
|
if (/error no controlado/i.test(queryResult.message || '')) {
|
||||||
|
if (isDaily) {
|
||||||
|
// En daily no detenemos el job por un 404 transitorio del SAT; se
|
||||||
|
// registra como no fatal para diagnóstico y se continúa.
|
||||||
|
console.warn(`[SAT] Rechazo 404 del SAT en daily (${label}): ${queryResult.message} — se registra y continúa`);
|
||||||
|
throw new Error(`SAT 404 en daily (${label}): ${queryResult.message}`);
|
||||||
|
}
|
||||||
|
console.warn(`[SAT] Rechazo transitorio del SAT (${label}): ${queryResult.message} — se reintentará`);
|
||||||
|
throw new SatTransientError(stageIdForTimeout(label), queryResult.message);
|
||||||
|
}
|
||||||
throw new Error(`Error SAT (${label}): ${queryResult.message}`);
|
throw new Error(`Error SAT (${label}): ${queryResult.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,23 +720,28 @@ async function requestAndDownload(
|
|||||||
|
|
||||||
// Polling — si el reuse ya devolvió `ready`, salta el loop directamente.
|
// Polling — si el reuse ya devolvió `ready`, salta el loop directamente.
|
||||||
if (!verifyResult || verifyResult.status !== 'ready') {
|
if (!verifyResult || verifyResult.status !== 'ready') {
|
||||||
|
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < MAX_POLL_ATTEMPTS) {
|
while (attempts < maxAttempts) {
|
||||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
verifyResult = await verifySatRequest(ctx.service, requestId);
|
verifyResult = await verifySatRequest(ctx.service, requestId);
|
||||||
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts})`);
|
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
|
||||||
|
|
||||||
if (verifyResult.status === 'ready') break;
|
if (verifyResult.status === 'ready') break;
|
||||||
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
||||||
|
if (isAgotadas(verifyResult.message)) {
|
||||||
|
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`);
|
||||||
|
return { packageContents: [], totalCfdis: 0 };
|
||||||
|
}
|
||||||
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
|
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verifyResult || verifyResult.status !== 'ready') {
|
if (!verifyResult || verifyResult.status !== 'ready') {
|
||||||
throw new Error(`Timeout esperando respuesta del SAT (${label})`);
|
throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const packageContents: string[] = [];
|
const packageContents: string[] = [];
|
||||||
@@ -741,17 +781,20 @@ async function processDateRange(
|
|||||||
fechaInicio: Date,
|
fechaInicio: Date,
|
||||||
fechaFin: Date,
|
fechaFin: Date,
|
||||||
tipoCfdi: CfdiSyncType,
|
tipoCfdi: CfdiSyncType,
|
||||||
skipJobUpdate = false
|
skipJobUpdate = false,
|
||||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
throwOnError = false,
|
||||||
|
isDaily = false
|
||||||
|
): Promise<{ found: number; downloaded: number; inserted: number; updated: number; errors: { message: string }[] }> {
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
let totalDownloaded = 0;
|
let totalDownloaded = 0;
|
||||||
let totalInserted = 0;
|
let totalInserted = 0;
|
||||||
let totalUpdated = 0;
|
let totalUpdated = 0;
|
||||||
|
const errors: { message: string }[] = [];
|
||||||
|
|
||||||
// Solo XMLs de vigentes (datos completos)
|
// Solo XMLs de vigentes (datos completos)
|
||||||
try {
|
try {
|
||||||
const { packageContents, totalCfdis } = await requestAndDownload(
|
const { packageContents, totalCfdis } = await requestAndDownload(
|
||||||
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi'
|
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi', isDaily
|
||||||
);
|
);
|
||||||
totalFound += totalCfdis;
|
totalFound += totalCfdis;
|
||||||
|
|
||||||
@@ -766,6 +809,8 @@ async function processDateRange(
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
|
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
|
||||||
|
if (throwOnError) throw error;
|
||||||
|
errors.push({ message: error.message || `Error desconocido en XMLs ${tipoCfdi}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!skipJobUpdate) {
|
if (!skipJobUpdate) {
|
||||||
@@ -782,6 +827,7 @@ async function processDateRange(
|
|||||||
downloaded: totalDownloaded,
|
downloaded: totalDownloaded,
|
||||||
inserted: totalInserted,
|
inserted: totalInserted,
|
||||||
updated: totalUpdated,
|
updated: totalUpdated,
|
||||||
|
errors,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,14 +840,17 @@ async function processMetadataRange(
|
|||||||
jobId: string,
|
jobId: string,
|
||||||
fechaInicio: Date,
|
fechaInicio: Date,
|
||||||
fechaFin: Date,
|
fechaFin: Date,
|
||||||
tipoCfdi: CfdiSyncType
|
tipoCfdi: CfdiSyncType,
|
||||||
): Promise<{ inserted: number; updated: number }> {
|
throwOnError = false,
|
||||||
|
isDaily = false
|
||||||
|
): Promise<{ inserted: number; updated: number; errors: { message: string }[] }> {
|
||||||
let totalInserted = 0;
|
let totalInserted = 0;
|
||||||
let totalUpdated = 0;
|
let totalUpdated = 0;
|
||||||
|
const errors: { message: string }[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { packageContents } = await requestAndDownload(
|
const { packageContents } = await requestAndDownload(
|
||||||
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata'
|
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata', isDaily
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const content of packageContents) {
|
for (const content of packageContents) {
|
||||||
@@ -814,9 +863,11 @@ async function processMetadataRange(
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error en metadata ${tipoCfdi}: ${error.message}`);
|
console.error(`[SAT] Error en metadata ${tipoCfdi}: ${error.message}`);
|
||||||
|
if (throwOnError) throw error;
|
||||||
|
errors.push({ message: error.message || `Error desconocido en metadata ${tipoCfdi}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { inserted: totalInserted, updated: totalUpdated };
|
return { inserted: totalInserted, updated: totalUpdated, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -904,11 +955,13 @@ async function processInitialSync(
|
|||||||
customDateFrom?: Date,
|
customDateFrom?: Date,
|
||||||
customDateTo?: Date
|
customDateTo?: Date
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ahora = new Date();
|
// El SAT rechaza fechas futuras; por defecto usamos ayer como fecha final.
|
||||||
// Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes.
|
// Si el usuario pasó un rango explícito lo respetamos (aunque podría fallar
|
||||||
|
// si pone "hoy").
|
||||||
|
const fechaFin = customDateTo || getYesterdayEnd();
|
||||||
|
// Exactamente 6 años atrás desde la fecha final (mismo día del mes), no inicio de mes.
|
||||||
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
|
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
|
||||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate());
|
const inicioHistorico = customDateFrom || new Date(fechaFin.getFullYear() - YEARS_TO_SYNC, fechaFin.getMonth(), fechaFin.getDate());
|
||||||
const fechaFin = customDateTo || ahora;
|
|
||||||
|
|
||||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||||
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
|
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
|
||||||
@@ -1098,7 +1151,9 @@ async function processCustomRangeSync(
|
|||||||
const INCREMENTAL_WINDOW_HOURS = 8;
|
const INCREMENTAL_WINDOW_HOURS = 8;
|
||||||
|
|
||||||
async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> {
|
async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||||
const ahora = new Date();
|
// Retrocedemos 2h respecto a ahora para evitar que el SAT vea una fecha final
|
||||||
|
// futura / demasiado reciente (rechazo "Fecha final invalida").
|
||||||
|
const ahora = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||||
const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000);
|
const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000);
|
||||||
|
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
@@ -1109,25 +1164,17 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<
|
|||||||
console.log(`[SAT] Incremental: ${desde.toISOString()} → ${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`);
|
console.log(`[SAT] Incremental: ${desde.toISOString()} → ${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`);
|
||||||
|
|
||||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||||
try {
|
|
||||||
const result = await processDateRange(ctx, jobId, desde, ahora, tipo);
|
const result = await processDateRange(ctx, jobId, desde, ahora, tipo);
|
||||||
totalFound += result.found;
|
totalFound += result.found;
|
||||||
totalDownloaded += result.downloaded;
|
totalDownloaded += result.downloaded;
|
||||||
totalInserted += result.inserted;
|
totalInserted += result.inserted;
|
||||||
totalUpdated += result.updated;
|
totalUpdated += result.updated;
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`[SAT] Error incremental XMLs ${tipo}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||||
try {
|
|
||||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo);
|
const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo);
|
||||||
totalInserted += inserted;
|
totalInserted += inserted;
|
||||||
totalUpdated += updated;
|
totalUpdated += updated;
|
||||||
} catch (error: any) {
|
|
||||||
console.error(`[SAT] Error incremental metadata ${tipo}:`, error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
@@ -1138,51 +1185,310 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processDailySync(ctx: SyncContext, jobId: string): Promise<void> {
|
/**
|
||||||
const ahora = new Date();
|
* Error usado para señalar que una etapa del sync diario excedió el tiempo
|
||||||
|
* de espera al SAT. Lleva el identificador de la etapa para poder retomar
|
||||||
|
* desde el mismo punto en los retries programados.
|
||||||
|
*/
|
||||||
|
class SatSyncTimeoutError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly stageId: string,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SatSyncTimeoutError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechazo transitorio del SAT (p. ej. "Error no controlado", típico de
|
||||||
|
* throttling cuando se lanzan muchas solicitudes en ráfaga). A diferencia de
|
||||||
|
* "solicitudes agotadas", SÍ vale la pena reintentarlo: se comporta como un
|
||||||
|
* timeout — se estaciona la etapa y los retries de 9 AM / 4 PM la retoman.
|
||||||
|
*/
|
||||||
|
class SatTransientError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly stageId: string,
|
||||||
|
message: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SatTransientError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El daily terminó sus etapas XML y parte de las de metadata, pero uno o más
|
||||||
|
* chunks de metadata aún no tienen paquetes listos. No es un fallo del SAT:
|
||||||
|
* el requestId ya quedó persistido y los retries de 9 AM / 4 PM lo retoman.
|
||||||
|
* Se trata como "pending" (no consume el job entero) en vez de abortar.
|
||||||
|
*/
|
||||||
|
class SatMetadataPendingError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly stageId: string,
|
||||||
|
public readonly pendingStages: string[],
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'SatMetadataPendingError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta la respuesta del SAT "Se han agotado las solicitudes de por vida":
|
||||||
|
* se agotaron las solicitudes máximas para ese rango de fecha. No es
|
||||||
|
* transitorio — no vale la pena seguir intentando ni recrear la solicitud;
|
||||||
|
* hay que cancelarla y omitir ese rango.
|
||||||
|
*/
|
||||||
|
function isAgotadas(message?: string | null): boolean {
|
||||||
|
return !!message && message.toLowerCase().includes('agotad');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un stageId a partir de la etiqueta de una solicitud SAT.
|
||||||
|
* Usado cuando requestAndDownload detecta timeout y no conoce el stage exacto.
|
||||||
|
*/
|
||||||
|
function stageIdForTimeout(label: string): string {
|
||||||
|
// label tiene forma "tipoCfdi/requestType" (ej. "emitidos/cfdi" o "recibidos/metadata")
|
||||||
|
const [tipo, requestType] = label.split('/');
|
||||||
|
if (requestType === 'cfdi') return `xml-${tipo}-7d`;
|
||||||
|
return `metadata-${tipo}-chunk`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSundayInCDMX(now: Date = new Date()): boolean {
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Mexico_City',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(now) === 'Sun';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processDailySync(
|
||||||
|
ctx: SyncContext,
|
||||||
|
jobId: string,
|
||||||
|
resumeFromStage?: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Usamos ayer como fecha final para evitar el rechazo "Fecha final invalida"
|
||||||
|
// del SAT cuando se consulta con la fecha actual.
|
||||||
|
const ahora = getYesterdayEnd();
|
||||||
const inicioAño = new Date(ahora.getFullYear(), 0, 1);
|
const inicioAño = new Date(ahora.getFullYear(), 0, 1);
|
||||||
const hace7Dias = new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const hace7Dias = new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const ejecutarMetadataHistorica = isSundayInCDMX();
|
||||||
|
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
let totalDownloaded = 0;
|
let totalDownloaded = 0;
|
||||||
let totalInserted = 0;
|
let totalInserted = 0;
|
||||||
let totalUpdated = 0;
|
let totalUpdated = 0;
|
||||||
|
|
||||||
// Paso 1: XMLs de los últimos 7 días (CFDIs nuevos)
|
interface DailyStage {
|
||||||
console.log(`[SAT] Daily: XMLs desde ${hace7Dias.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`);
|
id: string;
|
||||||
|
label: string;
|
||||||
|
isMetadata: boolean;
|
||||||
|
run: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
const makeStage = (
|
||||||
try {
|
id: string,
|
||||||
const result = await processDateRange(ctx, jobId, hace7Dias, ahora, tipo);
|
label: string,
|
||||||
|
isMetadata: boolean,
|
||||||
|
runImpl: (stageId: string) => Promise<void>,
|
||||||
|
): DailyStage => ({ id, label, isMetadata, run: () => runImpl(id) });
|
||||||
|
|
||||||
|
const stages: DailyStage[] = [
|
||||||
|
makeStage('xml-emitidos-7d', 'XMLs emitidos últimos 7 días', false, async (id) => {
|
||||||
|
const result = await processDateRange(ctx, jobId, hace7Dias, ahora, 'emitidos', true, false, true);
|
||||||
totalFound += result.found;
|
totalFound += result.found;
|
||||||
totalDownloaded += result.downloaded;
|
totalDownloaded += result.downloaded;
|
||||||
totalInserted += result.inserted;
|
totalInserted += result.inserted;
|
||||||
totalUpdated += result.updated;
|
totalUpdated += result.updated;
|
||||||
} catch (error: any) {
|
if (result.errors.length > 0) {
|
||||||
console.error(`[SAT] Error XMLs ${tipo} (7 días):`, error.message);
|
for (const e of result.errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
makeStage('xml-recibidos-7d', 'XMLs recibidos últimos 7 días', false, async (id) => {
|
||||||
|
const result = await processDateRange(ctx, jobId, hace7Dias, ahora, 'recibidos', true, false, true);
|
||||||
|
totalFound += result.found;
|
||||||
|
totalDownloaded += result.downloaded;
|
||||||
|
totalInserted += result.inserted;
|
||||||
|
totalUpdated += result.updated;
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
for (const e of result.errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
// Paso 2: Metadata del ciclo fiscal actual (enero → hoy)
|
// La metadata histórica (desde inicio de año) consume muchas solicitudes al SAT
|
||||||
// Captura cancelaciones y cambios de status del año completo
|
// y no cambia día a día. Solo la ejecutamos los domingos para reducir carga.
|
||||||
console.log(`[SAT] Daily: Metadata desde ${inicioAño.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`);
|
if (ejecutarMetadataHistorica) {
|
||||||
|
const metaChunks = generateChunks(inicioAño, ahora, 3);
|
||||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
// Recorrer de más nuevo a más viejo: si un bloque antiguo da timeout, los
|
||||||
try {
|
// recientes (más relevantes) ya quedaron procesados y no bloquean el avance.
|
||||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, inicioAño, ahora, tipo);
|
for (let i = metaChunks.length - 1; i >= 0; i--) {
|
||||||
|
const { start, end } = metaChunks[i];
|
||||||
|
const chunkLabel = `${start.toISOString().slice(0, 10)}_${end.toISOString().slice(0, 10)}`;
|
||||||
|
stages.push(makeStage(
|
||||||
|
`metadata-emitidos-${chunkLabel}`,
|
||||||
|
`Metadata emitidos ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`,
|
||||||
|
true,
|
||||||
|
async (id) => {
|
||||||
|
const { inserted, updated, errors } = await processMetadataRange(ctx, jobId, start, end, 'emitidos', false, true);
|
||||||
totalInserted += inserted;
|
totalInserted += inserted;
|
||||||
totalUpdated += updated;
|
totalUpdated += updated;
|
||||||
|
if (errors.length > 0) {
|
||||||
|
for (const e of errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
stages.push(makeStage(
|
||||||
|
`metadata-recibidos-${chunkLabel}`,
|
||||||
|
`Metadata recibidos ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`,
|
||||||
|
true,
|
||||||
|
async (id) => {
|
||||||
|
const { inserted, updated, errors } = await processMetadataRange(ctx, jobId, start, end, 'recibidos', false, true);
|
||||||
|
totalInserted += inserted;
|
||||||
|
totalUpdated += updated;
|
||||||
|
if (errors.length > 0) {
|
||||||
|
for (const e of errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStages = stages.length;
|
||||||
|
let activeStageIndex = 0;
|
||||||
|
if (resumeFromStage) {
|
||||||
|
const idx = stages.findIndex(s => s.id === resumeFromStage);
|
||||||
|
if (idx >= 0) {
|
||||||
|
activeStageIndex = idx;
|
||||||
|
console.log(`[SAT Daily] Retomando desde etapa ${resumeFromStage} (${idx + 1}/${totalStages})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[SAT Daily] No se encontró etapa ${resumeFromStage}, iniciando desde el principio`);
|
||||||
|
activeStageIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingStages: string[] = [];
|
||||||
|
const nonFatalErrors: { stage: string; message: string }[] = [];
|
||||||
|
|
||||||
|
for (let i = activeStageIndex; i < totalStages; i++) {
|
||||||
|
const stage = stages[i];
|
||||||
|
console.log(`[SAT Daily] Etapa ${i + 1}/${totalStages}: ${stage.label}`);
|
||||||
|
try {
|
||||||
|
await stage.run();
|
||||||
|
const progressPercent = totalStages > 0 ? Math.round(((i + 1) / totalStages) * 100) : 0;
|
||||||
|
await updateJobProgress(jobId, {
|
||||||
|
cfdisFound: totalFound,
|
||||||
|
cfdisDownloaded: totalDownloaded,
|
||||||
|
cfdisInserted: totalInserted,
|
||||||
|
cfdisUpdated: totalUpdated,
|
||||||
|
progressPercent,
|
||||||
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error metadata ${tipo} (ciclo fiscal):`, error.message);
|
const retryableErr = error instanceof SatSyncTimeoutError || error instanceof SatTransientError
|
||||||
|
? error
|
||||||
|
: (error.message?.includes('Timeout') ? new SatSyncTimeoutError(stage.id, error.message) : null);
|
||||||
|
|
||||||
|
// Errores transitorios (timeout, metadata aún no lista) se estacionan para
|
||||||
|
// reintento; no se pierden requestIds ya creados.
|
||||||
|
if (retryableErr && stage.isMetadata) {
|
||||||
|
console.warn(`[SAT Daily] Etapa ${stage.id} sin paquetes listos; se estaciona y se continúa con las demás.`);
|
||||||
|
pendingStages.push(stage.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (retryableErr) {
|
||||||
|
throw retryableErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errores no transitorios (404 Error no controlado, etc.) no abortan el
|
||||||
|
// daily. Se registran para diagnóstico y se continúa con las demás etapas.
|
||||||
|
console.error(`[SAT Daily] Etapa ${stage.id} falló (no transitorio): ${error.message}`);
|
||||||
|
nonFatalErrors.push({ stage: stage.id, message: error.message });
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errorMessage = nonFatalErrors.length > 0
|
||||||
|
? JSON.stringify({ completedWithWarnings: true, errors: nonFatalErrors })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
cfdisFound: totalFound,
|
cfdisFound: totalFound,
|
||||||
cfdisDownloaded: totalDownloaded,
|
cfdisDownloaded: totalDownloaded,
|
||||||
cfdisInserted: totalInserted,
|
cfdisInserted: totalInserted,
|
||||||
cfdisUpdated: totalUpdated,
|
cfdisUpdated: totalUpdated,
|
||||||
|
progressPercent: 100,
|
||||||
|
errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pendingStages.length > 0) {
|
||||||
|
throw new SatMetadataPendingError(
|
||||||
|
pendingStages[0],
|
||||||
|
pendingStages,
|
||||||
|
`Metadata no lista en ${pendingStages.length} chunk(s): ${pendingStages.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica y descarga un requestId existente, procesando los paquetes resultantes.
|
||||||
|
* Similar a processMetadataRange pero recibiendo el requestId explícito.
|
||||||
|
*/
|
||||||
|
async function requestAndDownloadWithId(
|
||||||
|
ctx: SyncContext,
|
||||||
|
jobId: string,
|
||||||
|
requestId: string,
|
||||||
|
tipoCfdi: 'emitidos' | 'recibidos',
|
||||||
|
requestType: 'metadata',
|
||||||
|
fechaInicio: Date,
|
||||||
|
fechaFin: Date,
|
||||||
|
isDaily = false,
|
||||||
|
): Promise<{ inserted: number; updated: number }> {
|
||||||
|
let totalInserted = 0;
|
||||||
|
let totalUpdated = 0;
|
||||||
|
const label = `${tipoCfdi}/${requestType} ${fechaInicio.toISOString().slice(0, 10)} → ${fechaFin.toISOString().slice(0, 10)}`;
|
||||||
|
|
||||||
|
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
verifyResult = await verifySatRequest(ctx.service, requestId);
|
||||||
|
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
|
||||||
|
|
||||||
|
if (verifyResult.status === 'ready') break;
|
||||||
|
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
||||||
|
if (isAgotadas(verifyResult.message)) {
|
||||||
|
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite.`);
|
||||||
|
return { inserted: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyResult || verifyResult.status !== 'ready') {
|
||||||
|
throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < verifyResult.packageIds.length; i++) {
|
||||||
|
const packageId = verifyResult.packageIds[i];
|
||||||
|
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
|
||||||
|
|
||||||
|
const downloadResult = await downloadSatPackage(ctx.service, packageId);
|
||||||
|
if (!downloadResult.success) {
|
||||||
|
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = processMetadataPackage(downloadResult.packageContent, tipoCfdi);
|
||||||
|
console.log(`[SAT] Procesando ${items.length} registros de metadata ${tipoCfdi}`);
|
||||||
|
|
||||||
|
const { inserted, updated } = await saveMetadata(await ctx.getPool(), items, jobId, ctx.contribuyenteId);
|
||||||
|
totalInserted += inserted;
|
||||||
|
totalUpdated += updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted: totalInserted, updated: totalUpdated };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1294,25 +1600,50 @@ export async function startSync(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
|
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
|
||||||
|
|
||||||
const isTimeout = error.message?.includes('Timeout');
|
const isMetadataPending = error instanceof SatMetadataPendingError;
|
||||||
|
const isSatTimeout = error instanceof SatSyncTimeoutError;
|
||||||
|
const isTransient = error instanceof SatTransientError;
|
||||||
|
const isTimeout = isSatTimeout || isMetadataPending || isTransient || error.message?.includes('Timeout');
|
||||||
const currentRetries = job.retryCount || 0;
|
const currentRetries = job.retryCount || 0;
|
||||||
const policy = getRetryPolicy(job);
|
const policy = getRetryPolicy(job);
|
||||||
const nextRetryNumber = currentRetries + 1;
|
const nextRetryNumber = currentRetries + 1;
|
||||||
const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries
|
// Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales.
|
||||||
? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy)
|
// Los primeros usan la política automática (6h/12h); los restantes los
|
||||||
|
// recogen los crons fijos de 9 AM / 4 PM CDMX.
|
||||||
|
const maxAttempts = job.type === 'daily' ? MAX_DAILY_RETRY_ATTEMPTS : policy.maxRetries;
|
||||||
|
const hasAttemptsLeft = isTimeout && nextRetryNumber <= maxAttempts;
|
||||||
|
// B: anclar la política a createdAt (inmutable) en vez de startedAt,
|
||||||
|
// porque startedAt se resetea en cada retry para que el watchdog mida
|
||||||
|
// el intento actual y no mate retries legítimos en vuelo.
|
||||||
|
const nextRetry = hasAttemptsLeft
|
||||||
|
? (computeNextRetryAt(job.createdAt, nextRetryNumber, policy) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (nextRetry) {
|
// Para timeouts/rechazos transitorios del daily, persistimos la etapa para retomar en retries programados.
|
||||||
|
const progressErrorMessage = isMetadataPending
|
||||||
|
? JSON.stringify({ stage: error.stageId, pendingStages: error.pendingStages, message: error.message })
|
||||||
|
: isSatTimeout || isTransient
|
||||||
|
? JSON.stringify({ stage: error.stageId, message: error.message })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (hasAttemptsLeft) {
|
||||||
|
const retryLabel = nextRetry
|
||||||
|
? nextRetry.toLocaleString('es-MX')
|
||||||
|
: 'próxima ventana 9 AM / 4 PM CDMX';
|
||||||
await updateJobProgress(job.id, {
|
await updateJobProgress(job.id, {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`,
|
errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
|
||||||
retryCount: nextRetryNumber,
|
retryCount: nextRetryNumber,
|
||||||
nextRetryAt: nextRetry,
|
nextRetryAt: nextRetry ?? null as any,
|
||||||
});
|
});
|
||||||
console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${policy.maxRetries} a las ${nextRetry.toLocaleString('es-MX')}`);
|
console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${maxAttempts} (${retryLabel})`);
|
||||||
} else {
|
} else {
|
||||||
// Sin reintentos restantes, error no-timeout, o policy con maxRetries=0 (incremental)
|
// Sin reintentos restantes, error no-timeout, o policy con maxRetries=0 (incremental)
|
||||||
const finalMsg = isTimeout
|
const finalMsg = isMetadataPending
|
||||||
|
? progressErrorMessage
|
||||||
|
: isSatTimeout
|
||||||
|
? progressErrorMessage
|
||||||
|
: isTimeout
|
||||||
? policy.maxRetries === 0
|
? policy.maxRetries === 0
|
||||||
? 'Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap.'
|
? 'Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap.'
|
||||||
: 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
: 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
||||||
@@ -1410,7 +1741,23 @@ export async function retryTimedOutJobs(): Promise<void> {
|
|||||||
getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName),
|
getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName),
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any });
|
// B: resetear startedAt al inicio de ESTE intento para que el watchdog
|
||||||
|
// mida el intento actual (y no mate un retry legítimo por el startedAt
|
||||||
|
// original del job). La política de retries se ancla a createdAt.
|
||||||
|
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date() });
|
||||||
|
|
||||||
|
// Para jobs daily, intentamos retomar desde la última etapa completada.
|
||||||
|
let resumeFromStage: string | undefined;
|
||||||
|
if (job.type === 'daily' && job.errorMessage) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(job.errorMessage);
|
||||||
|
if (typeof parsed.stage === 'string') {
|
||||||
|
resumeFromStage = parsed.stage;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// errorMessage no es JSON, ignorar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-ejecutar según tipo original
|
// Re-ejecutar según tipo original
|
||||||
try {
|
try {
|
||||||
@@ -1419,7 +1766,7 @@ export async function retryTimedOutJobs(): Promise<void> {
|
|||||||
} else if (job.type === 'incremental') {
|
} else if (job.type === 'incremental') {
|
||||||
await processIncrementalSync(ctx, job.id);
|
await processIncrementalSync(ctx, job.id);
|
||||||
} else {
|
} else {
|
||||||
await processDailySync(ctx, job.id);
|
await processDailySync(ctx, job.id, resumeFromStage);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateJobProgress(job.id, {
|
await updateJobProgress(job.id, {
|
||||||
@@ -1432,24 +1779,44 @@ export async function retryTimedOutJobs(): Promise<void> {
|
|||||||
} catch (retryError: any) {
|
} catch (retryError: any) {
|
||||||
console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, retryError.message);
|
console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, retryError.message);
|
||||||
|
|
||||||
const isTimeout = retryError.message?.includes('Timeout');
|
const isMetadataPending = retryError instanceof SatMetadataPendingError;
|
||||||
|
const isSatTimeout = retryError instanceof SatSyncTimeoutError;
|
||||||
|
const isTransient = retryError instanceof SatTransientError;
|
||||||
|
const isTimeout = isSatTimeout || isMetadataPending || isTransient || retryError.message?.includes('Timeout');
|
||||||
const policy = getRetryPolicy(job);
|
const policy = getRetryPolicy(job);
|
||||||
const nextRetryNumber = job.retryCount + 1;
|
const nextRetryNumber = job.retryCount + 1;
|
||||||
const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries
|
// Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales.
|
||||||
? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy)
|
const maxAttempts = job.type === 'daily' ? MAX_DAILY_RETRY_ATTEMPTS : policy.maxRetries;
|
||||||
|
const hasAttemptsLeft = isTimeout && nextRetryNumber <= maxAttempts;
|
||||||
|
// B: política anclada a createdAt (startedAt se resetea por intento).
|
||||||
|
const nextRetry = hasAttemptsLeft
|
||||||
|
? (computeNextRetryAt(job.createdAt, nextRetryNumber, policy) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (nextRetry) {
|
const progressErrorMessage = isMetadataPending
|
||||||
|
? JSON.stringify({ stage: retryError.stageId, pendingStages: retryError.pendingStages, message: retryError.message })
|
||||||
|
: isSatTimeout || isTransient
|
||||||
|
? JSON.stringify({ stage: retryError.stageId, message: retryError.message })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (hasAttemptsLeft) {
|
||||||
|
const retryLabel = nextRetry
|
||||||
|
? nextRetry.toLocaleString('es-MX')
|
||||||
|
: 'próxima ventana 9 AM / 4 PM CDMX';
|
||||||
await updateJobProgress(job.id, {
|
await updateJobProgress(job.id, {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`,
|
errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
|
||||||
retryCount: nextRetryNumber,
|
retryCount: nextRetryNumber,
|
||||||
nextRetryAt: nextRetry,
|
nextRetryAt: nextRetry ?? null as any,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await updateJobProgress(job.id, {
|
await updateJobProgress(job.id, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
errorMessage: isTimeout
|
errorMessage: isMetadataPending
|
||||||
|
? progressErrorMessage
|
||||||
|
: isSatTimeout
|
||||||
|
? progressErrorMessage
|
||||||
|
: isTimeout
|
||||||
? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
||||||
: retryError.message,
|
: retryError.message,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
@@ -1467,6 +1834,153 @@ export async function retryTimedOutJobs(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retoma jobs diarios que quedaron pending por timeout de polling, sin depender
|
||||||
|
* de nextRetryAt. Usado por los cron fijos de 9:00 AM y 4:00 PM CDMX.
|
||||||
|
* Hasta MAX_DAILY_RETRY_ATTEMPTS intentos en total.
|
||||||
|
*/
|
||||||
|
export async function continuePendingDailyRequests(): Promise<void> {
|
||||||
|
const pendingJobs = await prisma.satSyncJob.findMany({
|
||||||
|
where: {
|
||||||
|
status: 'pending',
|
||||||
|
type: 'daily',
|
||||||
|
retryCount: { lt: MAX_DAILY_RETRY_ATTEMPTS },
|
||||||
|
},
|
||||||
|
include: { tenant: { select: { id: true, databaseName: true, rfc: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingJobs.length === 0) {
|
||||||
|
console.log('[SAT Daily Retry] No hay jobs diarios pendientes');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SAT Daily Retry] ${pendingJobs.length} job(s) diarios pendientes`);
|
||||||
|
|
||||||
|
for (const job of pendingJobs) {
|
||||||
|
try {
|
||||||
|
const activeSync = await prisma.satSyncJob.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId: job.tenantId,
|
||||||
|
contribuyenteId: job.contribuyenteId ?? null,
|
||||||
|
status: 'running',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeSync) {
|
||||||
|
console.log(`[SAT Daily Retry] (${job.tenant.rfc}, contrib=${job.contribuyenteId || 'tenant-wide'}) tiene sync activo, posponiendo`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SAT Daily Retry] Reintentando job ${job.id} (${job.tenant.rfc}), intento ${(job.retryCount || 0) + 1}/${MAX_DAILY_RETRY_ATTEMPTS}`);
|
||||||
|
|
||||||
|
let decryptedFiel = null;
|
||||||
|
if (job.contribuyenteId) {
|
||||||
|
const pool = await tenantDb.getPool(job.tenantId, job.tenant.databaseName);
|
||||||
|
decryptedFiel = await getDecryptedFielContribuyente(pool, job.contribuyenteId);
|
||||||
|
}
|
||||||
|
if (!decryptedFiel) {
|
||||||
|
decryptedFiel = await getDecryptedFiel(job.tenantId);
|
||||||
|
}
|
||||||
|
if (!decryptedFiel) {
|
||||||
|
await updateJobProgress(job.id, {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: 'FIEL no disponible para reintento',
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createSatService({
|
||||||
|
cerContent: decryptedFiel.cerContent,
|
||||||
|
keyContent: decryptedFiel.keyContent,
|
||||||
|
password: decryptedFiel.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx: SyncContext = {
|
||||||
|
fielData: {
|
||||||
|
cerContent: decryptedFiel.cerContent,
|
||||||
|
keyContent: decryptedFiel.keyContent,
|
||||||
|
password: decryptedFiel.password,
|
||||||
|
},
|
||||||
|
service,
|
||||||
|
rfc: decryptedFiel.rfc,
|
||||||
|
tenantId: job.tenantId,
|
||||||
|
databaseName: job.tenant.databaseName,
|
||||||
|
contribuyenteId: job.contribuyenteId ?? null,
|
||||||
|
getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName),
|
||||||
|
};
|
||||||
|
|
||||||
|
let resumeFromStage: string | undefined;
|
||||||
|
if (job.errorMessage) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(job.errorMessage);
|
||||||
|
if (typeof parsed.stage === 'string') {
|
||||||
|
resumeFromStage = parsed.stage;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// no es JSON, ignorar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// B: resetear startedAt al inicio de este intento (ver retryTimedOutJobs).
|
||||||
|
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await processDailySync(ctx, job.id, resumeFromStage);
|
||||||
|
await updateJobProgress(job.id, {
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: new Date(),
|
||||||
|
progressPercent: 100,
|
||||||
|
errorMessage: null as any,
|
||||||
|
});
|
||||||
|
console.log(`[SAT Daily Retry] Job ${job.id} completado`);
|
||||||
|
} catch (retryError: any) {
|
||||||
|
console.error(`[SAT Daily Retry] Job ${job.id} falló:`, retryError.message);
|
||||||
|
|
||||||
|
const isMetadataPending = retryError instanceof SatMetadataPendingError;
|
||||||
|
const isSatTimeout = retryError instanceof SatSyncTimeoutError;
|
||||||
|
const isTransient = retryError instanceof SatTransientError;
|
||||||
|
const isTimeout = isSatTimeout || isMetadataPending || isTransient || retryError.message?.includes('Timeout');
|
||||||
|
const nextRetryCount = (job.retryCount || 0) + 1;
|
||||||
|
const progressErrorMessage = isMetadataPending
|
||||||
|
? JSON.stringify({ stage: retryError.stageId, pendingStages: retryError.pendingStages, message: retryError.message })
|
||||||
|
: isSatTimeout || isTransient
|
||||||
|
? JSON.stringify({ stage: retryError.stageId, message: retryError.message })
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (isTimeout && nextRetryCount < MAX_DAILY_RETRY_ATTEMPTS) {
|
||||||
|
await updateJobProgress(job.id, {
|
||||||
|
status: 'pending',
|
||||||
|
errorMessage: progressErrorMessage,
|
||||||
|
retryCount: nextRetryCount,
|
||||||
|
nextRetryAt: null as any,
|
||||||
|
});
|
||||||
|
console.log(`[SAT Daily Retry] Job ${job.id} quedó pending para siguiente ventana (intento ${nextRetryCount}/${MAX_DAILY_RETRY_ATTEMPTS})`);
|
||||||
|
} else {
|
||||||
|
await updateJobProgress(job.id, {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: isMetadataPending
|
||||||
|
? progressErrorMessage
|
||||||
|
: isSatTimeout
|
||||||
|
? progressErrorMessage
|
||||||
|
: isTimeout
|
||||||
|
? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
||||||
|
: retryError.message,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SAT Daily Retry] Error procesando job ${job.id}:`, error.message);
|
||||||
|
await updateJobProgress(job.id, {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error.message,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtiene el estado actual de sincronización de un tenant
|
* Obtiene el estado actual de sincronización de un tenant
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,298 +1,246 @@
|
|||||||
# Implementación de Sincronización SAT
|
# Sincronización SAT — Implementación y Operación
|
||||||
|
|
||||||
## Resumen
|
Documentación viva del sistema de sincronización de CFDIs con el SAT para Horux Despachos / Horux 360.
|
||||||
|
|
||||||
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360.
|
## 1. Resumen
|
||||||
|
|
||||||
## Componentes Implementados
|
El sistema descarga periódicamente XMLs y metadata de CFDIs emitidos y recibidos desde el servicio web de descarga masiva del SAT (`@nodecfdi/sat-ws-descarga-masiva`), usando la FIEL de cada contribuyente o del tenant (modo legacy Horux 360).
|
||||||
|
|
||||||
### 1. Backend (API)
|
Los datos se almacenan en la base de datos del tenant correspondiente.
|
||||||
|
|
||||||
#### Servicios
|
## 2. Arquitectura
|
||||||
|
|
||||||
| Archivo | Descripción |
|
### Backend
|
||||||
|---------|-------------|
|
|
||||||
| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) |
|
|
||||||
| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT |
|
|
||||||
| `src/services/sat/sat.service.ts` | Lógica principal de sincronización |
|
|
||||||
| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales |
|
|
||||||
| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI |
|
|
||||||
|
|
||||||
#### Controladores
|
| Archivo | Responsabilidad |
|
||||||
|
|---------|-----------------|
|
||||||
|
| `apps/api/src/services/sat/sat.service.ts` | Lógica principal de sincronización, políticas de reintento, polling |
|
||||||
|
| `apps/api/src/services/sat/sat-client.service.ts` | Cliente del SAT, `query`, `verify`, `download` |
|
||||||
|
| `apps/api/src/services/sat/sat-parser.service.ts` | Parseo de XMLs y metadata |
|
||||||
|
| `apps/api/src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM de credenciales FIEL |
|
||||||
|
| `apps/api/src/services/fiel.service.ts` | FIEL a nivel tenant (legacy) |
|
||||||
|
| `apps/api/src/services/contribuyente-fiel.service.ts` | FIEL por contribuyente (modelo despacho) |
|
||||||
|
| `apps/api/src/controllers/sat.controller.ts` | Endpoints HTTP |
|
||||||
|
| `apps/api/src/jobs/sat-sync.job.ts` | Crons de sincronización |
|
||||||
|
| `apps/api/src/jobs/sat-sync-monitor.job.ts` | Watchdog de jobs atorados/fallidos |
|
||||||
|
|
||||||
| Archivo | Descripción |
|
### Frontend
|
||||||
|---------|-------------|
|
|
||||||
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
|
|
||||||
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
|
|
||||||
|
|
||||||
#### Job Programado
|
| Archivo | Responsabilidad |
|
||||||
|
|---------|-----------------|
|
||||||
|
| `apps/web/components/sat/FielUploadModal.tsx` | Subir FIEL |
|
||||||
|
| `apps/web/components/sat/SyncStatus.tsx` | Estado y selector de fechas |
|
||||||
|
| `apps/web/components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
|
||||||
|
| `apps/web/app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
|
||||||
|
|
||||||
| Archivo | Descripción |
|
## 3. Modelo de datos
|
||||||
|---------|-------------|
|
|
||||||
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
|
|
||||||
|
|
||||||
### 2. Frontend (Web)
|
### Tabla global `public.sat_sync_jobs`
|
||||||
|
|
||||||
#### Componentes
|
|
||||||
|
|
||||||
| Archivo | Descripción |
|
|
||||||
|---------|-------------|
|
|
||||||
| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL |
|
|
||||||
| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas |
|
|
||||||
| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
|
|
||||||
|
|
||||||
#### Página
|
|
||||||
|
|
||||||
| Archivo | Descripción |
|
|
||||||
|---------|-------------|
|
|
||||||
| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
|
|
||||||
|
|
||||||
### 3. Base de Datos
|
|
||||||
|
|
||||||
#### Tabla Principal (schema public)
|
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- sat_sync_jobs: Almacena los trabajos de sincronización
|
|
||||||
CREATE TABLE sat_sync_jobs (
|
CREATE TABLE sat_sync_jobs (
|
||||||
id UUID PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
tenant_id UUID NOT NULL,
|
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||||
type VARCHAR(20) NOT NULL, -- 'initial' | 'daily'
|
contribuyente_id TEXT, -- NULL = modo legacy Horux 360
|
||||||
status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
|
type "SatSyncType" NOT NULL, -- 'initial' | 'daily' | 'incremental'
|
||||||
date_from TIMESTAMP NOT NULL,
|
status "SatSyncStatus" NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
|
||||||
date_to TIMESTAMP NOT NULL,
|
date_from DATE NOT NULL,
|
||||||
cfdi_type VARCHAR(20),
|
date_to DATE NOT NULL,
|
||||||
sat_request_id VARCHAR(100),
|
cfdi_type "CfdiSyncType", -- 'emitidos' | 'recibidos' (no siempre usado)
|
||||||
|
sat_request_id VARCHAR(50), -- legacy, preferir sat_request_ids
|
||||||
sat_package_ids TEXT[],
|
sat_package_ids TEXT[],
|
||||||
cfdis_found INTEGER DEFAULT 0,
|
cfdis_found INTEGER NOT NULL DEFAULT 0,
|
||||||
cfdis_downloaded INTEGER DEFAULT 0,
|
cfdis_downloaded INTEGER NOT NULL DEFAULT 0,
|
||||||
cfdis_inserted INTEGER DEFAULT 0,
|
cfdis_inserted INTEGER NOT NULL DEFAULT 0,
|
||||||
cfdis_updated INTEGER DEFAULT 0,
|
cfdis_updated INTEGER NOT NULL DEFAULT 0,
|
||||||
progress_percent INTEGER DEFAULT 0,
|
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||||
error_message TEXT,
|
error_message TEXT,
|
||||||
started_at TIMESTAMP,
|
started_at TIMESTAMP(3),
|
||||||
completed_at TIMESTAMP,
|
completed_at TIMESTAMP(3),
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
retry_count INTEGER DEFAULT 0
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||||
);
|
next_retry_at TIMESTAMP(3),
|
||||||
|
is_custom_range BOOLEAN NOT NULL DEFAULT false,
|
||||||
-- fiel_credentials: Almacena las credenciales FIEL encriptadas
|
sat_request_ids JSONB NOT NULL DEFAULT '{}'
|
||||||
CREATE TABLE fiel_credentials (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
tenant_id UUID UNIQUE NOT NULL,
|
|
||||||
rfc VARCHAR(13) NOT NULL,
|
|
||||||
cer_data BYTEA NOT NULL,
|
|
||||||
key_data BYTEA NOT NULL,
|
|
||||||
key_password_encrypted BYTEA NOT NULL,
|
|
||||||
encryption_iv BYTEA NOT NULL,
|
|
||||||
encryption_tag BYTEA NOT NULL,
|
|
||||||
serial_number VARCHAR(100),
|
|
||||||
valid_from TIMESTAMP NOT NULL,
|
|
||||||
valid_until TIMESTAMP NOT NULL,
|
|
||||||
is_active BOOLEAN DEFAULT true,
|
|
||||||
created_at TIMESTAMP DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP DEFAULT NOW()
|
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Columnas agregadas a tabla cfdis (por tenant)
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT;
|
|
||||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP;
|
|
||||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID;
|
|
||||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual';
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencias
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
|
|
||||||
"@nodecfdi/credentials": "^2.0.0",
|
|
||||||
"@nodecfdi/cfdi-core": "^1.0.1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Flujo de Sincronización
|
|
||||||
|
|
||||||
```
|
|
||||||
1. Usuario configura FIEL (certificado .cer + llave .key + contraseña)
|
|
||||||
↓
|
|
||||||
2. Sistema valida y encripta credenciales (AES-256-GCM)
|
|
||||||
↓
|
|
||||||
3. Usuario inicia sincronización (manual o automática 3:00 AM)
|
|
||||||
↓
|
|
||||||
4. Sistema desencripta FIEL y crea cliente SAT
|
|
||||||
↓
|
|
||||||
5. Por cada mes en el rango:
|
|
||||||
a. Solicitar CFDIs emitidos al SAT
|
|
||||||
b. Esperar respuesta (polling cada 30s)
|
|
||||||
c. Descargar paquetes ZIP
|
|
||||||
d. Extraer y parsear XMLs
|
|
||||||
e. Guardar en BD del tenant
|
|
||||||
f. Repetir para CFDIs recibidos
|
|
||||||
↓
|
|
||||||
6. Marcar job como completado
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### FIEL
|
### FIEL
|
||||||
|
|
||||||
| Método | Ruta | Descripción |
|
- **Legacy:** `public.fiel_credentials` (una por tenant).
|
||||||
|--------|------|-------------|
|
- **Por contribuyente:** `fiel_contribuyente` dentro de la base del tenant.
|
||||||
| GET | `/api/fiel/status` | Estado de la FIEL configurada |
|
|
||||||
| POST | `/api/fiel/upload` | Subir nueva FIEL |
|
|
||||||
| DELETE | `/api/fiel` | Eliminar FIEL |
|
|
||||||
|
|
||||||
### Sincronización SAT
|
El sistema intenta primero la FIEL del contribuyente; si no existe, cae a la FIEL del tenant.
|
||||||
|
|
||||||
| Método | Ruta | Descripción |
|
## 4. Tipos de sincronización
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | `/api/sat/sync` | Iniciar sincronización |
|
|
||||||
| GET | `/api/sat/sync/status` | Estado actual |
|
|
||||||
| GET | `/api/sat/sync/history` | Historial de syncs |
|
|
||||||
| GET | `/api/sat/sync/:id` | Detalle de un job |
|
|
||||||
| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido |
|
|
||||||
|
|
||||||
### Parámetros de sincronización
|
| Tipo | Descripción | Cuándo corre |
|
||||||
|
|------|-------------|--------------|
|
||||||
|
| `initial` | Primer sync de un contribuyente/tenant. Descarga XMLs + metadata en bloques. | Manual o cuando no hay un `initial` completado |
|
||||||
|
| `daily` | Sync diaria de los últimos 7 días de XMLs + metadata histórica solo los domingos | Cron 6–10 AM CDMX y retries 9 AM / 4 PM |
|
||||||
|
| `incremental` | Ventana de las últimas 8 horas | Cron 11 AM, 3 PM, 7 PM CDMX (Enterprise) |
|
||||||
|
| Custom range | `daily` con `dateFrom`/`dateTo` explícitos, llamado por el UI | Manual |
|
||||||
|
|
||||||
```typescript
|
## 5. Cronograma de jobs
|
||||||
interface StartSyncRequest {
|
|
||||||
type?: 'initial' | 'daily'; // default: 'daily'
|
Definidos en `apps/api/src/jobs/sat-sync.job.ts`:
|
||||||
dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00"
|
|
||||||
dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59"
|
| Job | Expresión | Horario CDMX | Propósito |
|
||||||
}
|
|-----|-----------|--------------|-----------|
|
||||||
|
| SAT Cron | `0 6-10 * * *` | 6:00–10:00 AM | Daily sync, ~20% de tenants por hora |
|
||||||
|
| Recovery Cron | `0 10 * * *` | 10:00 AM | Recuperar jobs `running` atorados |
|
||||||
|
| Daily Retry | `0 9 * * *` y `0 16 * * *` | 9:00 AM y 4:00 PM | Reintentar daily fallidos |
|
||||||
|
| Incremental Enterprise | `0 11,15,19 * * *` | 11 AM, 3 PM, 7 PM | Sync incremental |
|
||||||
|
| SAT Watchdog | `0 */2 * * *` | Cada 2 horas | Marcar jobs `running` sin heartbeat como failed |
|
||||||
|
| SAT Monitor | `0 */2 * * *` | Cada 2 horas | Alertar por email de jobs fallidos |
|
||||||
|
|
||||||
|
## 6. Flujo de sincronización
|
||||||
|
|
||||||
|
### `processDailySync`
|
||||||
|
|
||||||
|
1. Fecha final = ayer a medio día UTC (`getYesterdayEnd()`).
|
||||||
|
2. Ejecuta XMLs emitidos y recibidos de los últimos 7 días.
|
||||||
|
3. **Metadata histórica (desde inicio de año) solo si es domingo en CDMX.**
|
||||||
|
4. Errores 404 se registran pero **no abortan** el daily.
|
||||||
|
5. Errores transitorios (timeout) se reintentan según política.
|
||||||
|
|
||||||
|
### `processInitialSync` / custom range
|
||||||
|
|
||||||
|
1. Divide el rango en bloques de 3 o 6 meses para XMLs (según volumen estimado).
|
||||||
|
2. Descarga XMLs emitidos y recibidos por bloque.
|
||||||
|
3. Descarga metadata del rango completo.
|
||||||
|
4. Pausa de 5 segundos entre bloques.
|
||||||
|
|
||||||
|
### `processIncrementalSync`
|
||||||
|
|
||||||
|
1. Ventana de 8 horas: `ahora - 10h` a `ahora - 2h`.
|
||||||
|
2. Descarga XMLs + metadata de emitidos y recibidos.
|
||||||
|
|
||||||
|
## 7. Polling y límites
|
||||||
|
|
||||||
|
Después de crear una solicitud (`query`) al SAT, el sistema verifica el estado periódicamente (`verify`).
|
||||||
|
|
||||||
|
Constantes en `sat.service.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones
|
||||||
|
const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud
|
||||||
|
const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily
|
||||||
|
const YEARS_TO_SYNC = 6;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuración
|
Esto da un máximo de **~45 minutos por solicitud** (9 × 5 min).
|
||||||
|
|
||||||
### Variables de entorno
|
Cada solicitud al SAT tiene su propio polling; los intentos no se comparten entre solicitudes.
|
||||||
|
|
||||||
```env
|
## 8. Políticas de reintentos
|
||||||
# Clave para encriptar credenciales FIEL (32 bytes hex)
|
|
||||||
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
|
|
||||||
|
|
||||||
# Zona horaria para el cron
|
```ts
|
||||||
TZ=America/Mexico_City
|
const RETRY_POLICIES = {
|
||||||
|
daily: { maxRetries: 2, retryAtHours: [6, 12] },
|
||||||
|
custom: { maxRetries: 2, retryAtHours: [6, 12] },
|
||||||
|
initial: { maxRetries: 3, retryAtHours: [6, 12, 24] },
|
||||||
|
incremental: { maxRetries: 0, retryAtHours: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_DAILY_RETRY_ATTEMPTS = 5; // original + 2 automáticos + 2 crons fijos
|
||||||
```
|
```
|
||||||
|
|
||||||
### Límites del SAT
|
Los reintentos automáticos se programan desde `createdAt` del job. Los reintentos por cron fijo (9 AM / 4 PM) se manejan en `continuePendingDailyRequests`.
|
||||||
|
|
||||||
- **Antigüedad máxima**: 6 años
|
## 9. Manejo de errores
|
||||||
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
|
|
||||||
- **Tamaño de paquete**: Variable
|
|
||||||
|
|
||||||
## Errores Comunes del SAT
|
### Errores no fatales (se registran, no abortan en daily)
|
||||||
|
|
||||||
| Código | Mensaje | Solución |
|
- `404 Error no controlado` en daily se guarda en `errorMessage` como `completedWithWarnings`.
|
||||||
|--------|---------|----------|
|
- En syncs custom/initial los 404 se capturan por bloque pero el job puede continuar.
|
||||||
| 5000 | Solicitud Aceptada | OK - esperar verificación |
|
|
||||||
| 5002 | Límite de solicitudes agotado | Esperar 24 horas |
|
|
||||||
| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango |
|
|
||||||
| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente |
|
|
||||||
| - | Información mayor a 6 años | Ajustar rango de fechas |
|
|
||||||
| - | No se permite descarga de cancelados | Facturas canceladas no disponibles |
|
|
||||||
|
|
||||||
## Seguridad
|
### Errores transitorios (reintentan)
|
||||||
|
|
||||||
1. **Encriptación de credenciales**: AES-256-GCM con IV único
|
- Timeout del polling (`SatSyncTimeoutError`).
|
||||||
2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados
|
- `SatTransientError` (ej. rechazo transitorio del SAT).
|
||||||
3. **Autenticación**: JWT con tenantId embebido
|
- Metadata aún no lista (`SatMetadataPendingError`).
|
||||||
4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL
|
|
||||||
|
|
||||||
## Servicios Systemd
|
### Errores fatales (job falla)
|
||||||
|
|
||||||
|
- FIEL inválida o vencida.
|
||||||
|
- Errores que no son transitorios y no están en la lista de no fatales.
|
||||||
|
|
||||||
|
## 10. Errores comunes del SAT
|
||||||
|
|
||||||
|
| Código/Mensaje | Significado | Acción |
|
||||||
|
|----------------|-------------|--------|
|
||||||
|
| `5000` / "Solicitud Aceptada" | OK, hay que esperar | Polling normal |
|
||||||
|
| `5002` / "Solicitudes agotadas de por vida" | Cuota de solicitudes agotada | Omitir rango, esperar 24h |
|
||||||
|
| `5004` / "No se encontró la información" | Sin CFDIs en el rango | Continuar |
|
||||||
|
| `5005` / Duplicada | Solicitud duplicada | Reusar requestId existente |
|
||||||
|
| `404` / "Error no controlado" | Bloqueo/cuota del SAT | Registrar y continuar en daily; reintentar en otros syncs |
|
||||||
|
| "Fecha final invalida" | Fecha futura o mal formada | Usar `getYesterdayEnd()` |
|
||||||
|
| "El certificado no es válido" | FIEL rechazada por el SAT | Revisar vigencia/contraseña de FIEL |
|
||||||
|
|
||||||
|
## 11. Monitoreo y comandos útiles
|
||||||
|
|
||||||
|
### Estado del API
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# API Backend
|
pm2 status horux-api
|
||||||
systemctl status horux-api
|
pm2 logs horux-api --lines 50 --nostream
|
||||||
|
|
||||||
# Web Frontend
|
|
||||||
systemctl status horux-web
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Comandos Útiles
|
### Jobs recientes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver logs de sincronización SAT
|
export DATABASE_URL="postgresql://postgres:PASSWORD@localhost:5432/horux360"
|
||||||
journalctl -u horux-api -f | grep "\[SAT\]"
|
psql "$DATABASE_URL" -c "
|
||||||
|
SELECT id, type, status, contribuyente_id,
|
||||||
# Estado de jobs
|
cfdis_found, cfdis_downloaded, cfdis_inserted, cfdis_updated,
|
||||||
psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;"
|
error_message, created_at, completed_at
|
||||||
|
FROM sat_sync_jobs
|
||||||
# CFDIs sincronizados por tenant
|
ORDER BY created_at DESC
|
||||||
psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';"
|
LIMIT 20;
|
||||||
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Changelog
|
### Jobs fallidos o atorados
|
||||||
|
|
||||||
### 2026-01-25
|
|
||||||
|
|
||||||
- Implementación inicial de sincronización SAT
|
|
||||||
- Integración con librería @nodecfdi/sat-ws-descarga-masiva
|
|
||||||
- Soporte para fechas personalizadas en sincronización
|
|
||||||
- Corrección de cast UUID en queries SQL
|
|
||||||
- Agregadas columnas faltantes a tabla cfdis
|
|
||||||
- UI para selección de periodo personalizado
|
|
||||||
- Cambio de servicio web a modo producción (next start)
|
|
||||||
|
|
||||||
## Estado Actual (2026-01-25)
|
|
||||||
|
|
||||||
### Completado
|
|
||||||
|
|
||||||
- [x] Servicio de encriptación de credenciales FIEL
|
|
||||||
- [x] Integración con @nodecfdi/sat-ws-descarga-masiva
|
|
||||||
- [x] Parser de XMLs de CFDI
|
|
||||||
- [x] UI para subir FIEL
|
|
||||||
- [x] UI para ver estado de sincronización
|
|
||||||
- [x] UI para seleccionar periodo personalizado
|
|
||||||
- [x] Cron job para sincronización diaria (3:00 AM)
|
|
||||||
- [x] Soporte para fechas personalizadas
|
|
||||||
- [x] Corrección de cast UUID en queries
|
|
||||||
- [x] Columnas adicionales en tabla cfdis de todos los tenants
|
|
||||||
|
|
||||||
### Pendiente por probar
|
|
||||||
|
|
||||||
El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego:
|
|
||||||
|
|
||||||
1. Ir a **Configuración > SAT**
|
|
||||||
2. Clic en **"Periodo personalizado"**
|
|
||||||
3. Seleccionar: **2025-01-01** a **2025-12-31**
|
|
||||||
4. Clic en **"Sincronizar periodo"**
|
|
||||||
|
|
||||||
### Tenant de prueba
|
|
||||||
|
|
||||||
- **RFC**: HTS240708LJA
|
|
||||||
- **Schema**: `tenant_cas2408138w2`
|
|
||||||
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
|
|
||||||
|
|
||||||
### Comandos para verificar después de 24h
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ver estado del sync
|
psql "$DATABASE_URL" -c "
|
||||||
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
|
SELECT id, type, status, tenant_id, contribuyente_id, error_message, created_at, started_at
|
||||||
"SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;"
|
FROM sat_sync_jobs
|
||||||
|
WHERE status IN ('failed', 'running')
|
||||||
# Ver logs en tiempo real
|
ORDER BY created_at DESC;
|
||||||
journalctl -u horux-api -f | grep "\[SAT\]"
|
"
|
||||||
|
|
||||||
# Contar CFDIs sincronizados
|
|
||||||
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
|
|
||||||
"SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Problemas conocidos
|
### Contribuyentes de un tenant
|
||||||
|
|
||||||
1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h
|
```bash
|
||||||
2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas
|
# Reemplazar horux_<tenant_db> por la base del tenant
|
||||||
3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años
|
psql "$DATABASE_URL" -c "
|
||||||
|
SELECT c.entidad_id, c.rfc, e.nombre
|
||||||
|
FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas e ON c.entidad_id = e.id;
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
## Próximos Pasos
|
## 12. Changelog reciente
|
||||||
|
|
||||||
- [ ] Probar sincronización completa después de 24h
|
### 2026-07-31
|
||||||
- [ ] Verificar que los CFDIs se guarden correctamente
|
|
||||||
- [ ] Implementar reintentos automáticos para errores temporales
|
- Metadata histórica en daily solo se ejecuta los domingos.
|
||||||
- [ ] Notificaciones por email al completar sincronización
|
- Errores 404 en daily no abortan el proceso; se registran en `error_message`.
|
||||||
- [ ] Dashboard con estadísticas de CFDIs por periodo
|
- Polling reducido a **9 intentos máximos cada 5 minutos** por solicitud.
|
||||||
- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante)
|
- Daily retry fijo a las 9:00 AM y 4:00 PM CDMX.
|
||||||
|
- Incremental Enterprise a las 11:00 AM, 3:00 PM y 7:00 PM CDMX.
|
||||||
|
|
||||||
|
## 13. Problemas conocidos
|
||||||
|
|
||||||
|
1. **Bloqueo `404 Error no controlado` del SAT**: Aparece cuando se hacen muchas consultas desde la misma IP. Mitigación temporal: reducir frecuencia de polling y metadata solo domingos.
|
||||||
|
2. **FIEL inválida**: Algunos tenants/contribuyentes tienen FIEL rechazada por el SAT. Requiere revisar/renovar FIEL.
|
||||||
|
3. **Jobs `initial` atorados en `running`**: Pueden quedar si el proceso se reinicia; el recovery cron y el watchdog los limpian.
|
||||||
|
|
||||||
|
## 14. Próximos pasos
|
||||||
|
|
||||||
|
- [ ] Implementar proxies rotativos para evitar bloqueo por IP del SAT.
|
||||||
|
- [ ] Monitorear tasa de éxito tras reducir polling y metadata solo domingos.
|
||||||
|
- [ ] Revisar/renovar FIELs inválidas reportadas por el monitor.
|
||||||
|
|||||||
Reference in New Issue
Block a user