feat(sat-sync): excluir tenant DEMO2501019X2 de sincronizaciones SAT
- Agrega SAT_SYNC_EXCLUDED_TENANTS en ecosystem.config.js con DEMO2501019X2. - Filtra tenants excluidos en getTenantsWithFiel() para crons automáticos. - Rechaza startSync() manual para tenants excluidos. - Incluye script check-daily-sync.js para monitorear cobertura diaria.
This commit is contained in:
143
apps/api/scripts/check-daily-sync.js
Normal file
143
apps/api/scripts/check-daily-sync.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
const { Client } = require('./node_modules/pg');
|
||||||
|
|
||||||
|
const HORUX_CONN = {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 5432,
|
||||||
|
user: 'postgres',
|
||||||
|
password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb',
|
||||||
|
database: 'horux360',
|
||||||
|
};
|
||||||
|
|
||||||
|
const todayStart = new Date('2026-08-26T06:00:00.000Z'); // 00:00 CDMX approx
|
||||||
|
const todayEnd = new Date('2026-08-27T06:00:00.000Z');
|
||||||
|
|
||||||
|
async function query(client, sql, params = []) {
|
||||||
|
const res = await client.query(sql, params);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const client = new Client(HORUX_CONN);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Tenants activos y su FIEL legacy (tenant-level, Horux 360 antiguo)
|
||||||
|
// Se excluyen cuentas demo/ficticias configuradas en SAT_SYNC_EXCLUDED_TENANTS.
|
||||||
|
const excluded = new Set(
|
||||||
|
(process.env.SAT_SYNC_EXCLUDED_TENANTS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim().toUpperCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const tenants = await query(client, `
|
||||||
|
SELECT t.id, t.rfc, t.nombre, t.database_name,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM fiel_credentials fc
|
||||||
|
WHERE fc.tenant_id = t.id AND fc.is_active = true AND fc.valid_until > NOW()
|
||||||
|
) AS has_legacy_fiel
|
||||||
|
FROM tenants t
|
||||||
|
WHERE t.active = true
|
||||||
|
ORDER BY t.rfc
|
||||||
|
`);
|
||||||
|
|
||||||
|
const visibleTenants = tenants.filter(t => !excluded.has(t.id) && !excluded.has(t.rfc.toUpperCase()));
|
||||||
|
|
||||||
|
// Daily jobs de hoy
|
||||||
|
const dailyJobs = await query(client, `
|
||||||
|
SELECT tenant_id, contribuyente_id, cfdis_found, status, created_at
|
||||||
|
FROM sat_sync_jobs
|
||||||
|
WHERE type = 'daily'
|
||||||
|
AND created_at >= $1
|
||||||
|
AND created_at < $2
|
||||||
|
`, [todayStart, todayEnd]);
|
||||||
|
|
||||||
|
const dailyByTenantContrib = new Map();
|
||||||
|
for (const j of dailyJobs) {
|
||||||
|
const key = j.contribuyente_id ? `${j.tenant_id}:${j.contribuyente_id}` : `${j.tenant_id}:`;
|
||||||
|
dailyByTenantContrib.set(key, j);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hadDaily = [];
|
||||||
|
const missing = [];
|
||||||
|
const noFiel = [];
|
||||||
|
|
||||||
|
for (const t of visibleTenants) {
|
||||||
|
if (!t.database_name) {
|
||||||
|
// Legacy tenant-wide (sin contribuyentes)
|
||||||
|
if (!t.has_legacy_fiel) {
|
||||||
|
noFiel.push({ tenantRfc: t.rfc, tenantName: t.nombre });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = `${t.id}:`;
|
||||||
|
const job = dailyByTenantContrib.get(key);
|
||||||
|
if (job) {
|
||||||
|
hadDaily.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: null, contribuyenteRfc: null, contribuyenteName: null, cfdisFound: job.cfdis_found, status: job.status });
|
||||||
|
} else {
|
||||||
|
missing.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: null, contribuyenteRfc: null, contribuyenteName: null, reason: 'tenant legacy con FIEL pero sin daily' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conectar a la BD del tenant
|
||||||
|
const tenantClient = new Client({ ...HORUX_CONN, database: t.database_name });
|
||||||
|
try {
|
||||||
|
await tenantClient.connect();
|
||||||
|
|
||||||
|
// Todos los contribuyentes con info de FIEL (per-contribuyente o legacy)
|
||||||
|
const contribuyentes = await query(tenantClient, `
|
||||||
|
SELECT c.entidad_id, c.rfc, eg.nombre AS razon_social,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM fiel_contribuyente f
|
||||||
|
WHERE f.contribuyente_id = c.entidad_id
|
||||||
|
AND f.is_active = true AND f.valid_until > NOW()
|
||||||
|
) AS has_fiel
|
||||||
|
FROM contribuyentes c
|
||||||
|
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||||
|
ORDER BY eg.nombre
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const c of contribuyentes) {
|
||||||
|
const eligible = c.has_fiel || t.has_legacy_fiel;
|
||||||
|
const key = `${t.id}:${c.entidad_id}`;
|
||||||
|
const job = dailyByTenantContrib.get(key);
|
||||||
|
|
||||||
|
if (job) {
|
||||||
|
hadDaily.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: c.entidad_id, contribuyenteRfc: c.rfc, contribuyenteName: c.razon_social, cfdisFound: job.cfdis_found, status: job.status });
|
||||||
|
} else if (eligible) {
|
||||||
|
missing.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: c.entidad_id, contribuyenteRfc: c.rfc, contribuyenteName: c.razon_social, reason: 'sin daily' });
|
||||||
|
} else {
|
||||||
|
noFiel.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: c.entidad_id, contribuyenteRfc: c.rfc, contribuyenteName: c.razon_social });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
missing.push({ tenantRfc: t.rfc, tenantName: t.nombre, contribuyenteId: null, contribuyenteRfc: null, contribuyenteName: null, reason: `error consultando BD: ${err.message}` });
|
||||||
|
} finally {
|
||||||
|
try { await tenantClient.end(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.end();
|
||||||
|
|
||||||
|
console.log('=== CONTRIBUYENTES/TENANTS CON DAILY HOY ===');
|
||||||
|
console.log(`Total con daily: ${hadDaily.length}`);
|
||||||
|
for (const h of hadDaily) {
|
||||||
|
const name = h.contribuyenteName ? `${h.contribuyenteName} (${h.contribuyenteRfc})` : h.tenantName;
|
||||||
|
console.log(`- ${h.tenantRfc}: ${name} | CFDIs: ${h.cfdisFound ?? 'N/A'} | status: ${h.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== CONTRIBUYENTES/TENANTS SIN DAILY HOY (con FIEL) ===');
|
||||||
|
console.log(`Total sin daily: ${missing.length}`);
|
||||||
|
for (const m of missing) {
|
||||||
|
const name = m.contribuyenteName ? `${m.contribuyenteName} (${m.contribuyenteRfc})` : m.tenantName;
|
||||||
|
console.log(`- ${m.tenantRfc}: ${name} [${m.reason}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== CONTRIBUYENTES/TENANTS SIN FIEL CONFIGURADA ===');
|
||||||
|
console.log(`Total sin FIEL: ${noFiel.length}`);
|
||||||
|
for (const n of noFiel) {
|
||||||
|
const name = n.contribuyenteName ? `${n.contribuyenteName} (${n.contribuyenteRfc})` : n.tenantName;
|
||||||
|
console.log(`- ${n.tenantRfc}: ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1); });
|
||||||
@@ -11,18 +11,40 @@ import { consultarConstancia, purgeConstanciasAntiguas } from '../services/const
|
|||||||
import { tenantDb } from '../config/database.js';
|
import { tenantDb } from '../config/database.js';
|
||||||
import type { Pool } from 'pg';
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
const SYNC_CRON_SCHEDULE = '0 6-10 * * *'; // 6:00–10:00 AM CDMX — ~20% de tenants por hora (5 grupos); el SAT cierra el servicio en la noche
|
const SYNC_CRON_SCHEDULE = '0 3-7 * * *'; // 3:00–7:00 AM CDMX — ~20% de tenants por hora (5 grupos); ventana de menor carga
|
||||||
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
const RECOVERY_CRON_SCHEDULE = '0 14 * * *'; // 2:00 PM todos los días
|
||||||
const RETRY_9AM_CRON_SCHEDULE = '0 9 * * *'; // 9:00 AM todos los días
|
|
||||||
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
|
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
|
||||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas (legacy, se mantiene por compatibilidad)
|
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas (legacy, se mantiene por compatibilidad)
|
||||||
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '10'); // Máximo de contribuyentes en paralelo
|
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '5'); // Máximo de contribuyentes en paralelo
|
||||||
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
||||||
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
||||||
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (6-10 AM) cubre
|
const INCREMENTAL_CRON_SCHEDULE = '0 12,16 * * *'; // 12:00 PM y 4:00 PM CDMX
|
||||||
|
const BATCH_JITTER_MS = Number(process.env.SAT_BATCH_JITTER_MS || '60000'); // Jitter máximo entre inicios de sync en un batch (default 60s)
|
||||||
const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials
|
const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials
|
||||||
const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d)
|
const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d)
|
||||||
|
|
||||||
|
// Tenants excluidos de sincronizaciones SAT (cuentas demo/ficticias).
|
||||||
|
// Ejemplo: SAT_SYNC_EXCLUDED_TENANTS=DEMO2501019X2,OTRO_RFC
|
||||||
|
const EXCLUDED_TENANTS = new Set(
|
||||||
|
(process.env.SAT_SYNC_EXCLUDED_TENANTS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim().toUpperCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
function isTenantExcluded(tenantId: string, tenantRfc?: string): boolean {
|
||||||
|
return EXCLUDED_TENANTS.has(tenantId) || (tenantRfc ? EXCLUDED_TENANTS.has(tenantRfc.toUpperCase()) : false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pausa aleatoria para desalinear el inicio de syncs dentro de un batch.
|
||||||
|
* El jitter se aplica antes de cada syncUnit para evitar ráfagas.
|
||||||
|
*/
|
||||||
|
function jitterSleep(): Promise<void> {
|
||||||
|
const ms = Math.floor(Math.random() * BATCH_JITTER_MS);
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let isIncrementalRunning = false;
|
let isIncrementalRunning = false;
|
||||||
let isRecoveryRunning = false;
|
let isRecoveryRunning = false;
|
||||||
@@ -65,12 +87,16 @@ async function hasAnyFielConfigured(tenantId: string, databaseName?: string | nu
|
|||||||
async function getTenantsWithFiel(): Promise<string[]> {
|
async function getTenantsWithFiel(): Promise<string[]> {
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
select: { id: true, databaseName: true },
|
select: { id: true, rfc: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const tenantsWithFiel: string[] = [];
|
const tenantsWithFiel: string[] = [];
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
for (const tenant of tenants) {
|
||||||
|
if (isTenantExcluded(tenant.id, tenant.rfc)) {
|
||||||
|
console.log(`[SAT Cron] Tenant ${tenant.rfc || tenant.id} excluido de sincronizaciones SAT`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
|
const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
|
||||||
if (hasFiel) {
|
if (hasFiel) {
|
||||||
tenantsWithFiel.push(tenant.id);
|
tenantsWithFiel.push(tenant.id);
|
||||||
@@ -288,8 +314,8 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* Ejecuta el job de sincronización para todos los tenants
|
* Ejecuta el job de sincronización para todos los tenants
|
||||||
*/
|
*/
|
||||||
const DAILY_GROUPS = 5; // ventanas 6,7,8,9,10 AM
|
const DAILY_GROUPS = 5; // ventanas 3,4,5,6,7 AM
|
||||||
const DAILY_WINDOW_START = 6; // primera ventana CDMX
|
const DAILY_WINDOW_START = 3; // primera ventana CDMX
|
||||||
|
|
||||||
/** Hash estable del tenantId → grupo 0..DAILY_GROUPS-1 (reparte ~20% por ventana) */
|
/** Hash estable del tenantId → grupo 0..DAILY_GROUPS-1 (reparte ~20% por ventana) */
|
||||||
function tenantGroup(tenantId: string): number {
|
function tenantGroup(tenantId: string): number {
|
||||||
@@ -328,7 +354,7 @@ async function runSyncJob(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hour = cdmxHour();
|
const hour = cdmxHour();
|
||||||
const groupIndex = hour - DAILY_WINDOW_START; // 6→0 … 10→4
|
const groupIndex = hour - DAILY_WINDOW_START; // 3→0 … 7→4
|
||||||
if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
|
if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
|
||||||
console.log(`[SAT Cron] Hora CDMX ${hour} fuera de ventana 6-10 AM, omitiendo`);
|
console.log(`[SAT Cron] Hora CDMX ${hour} fuera de ventana 6-10 AM, omitiendo`);
|
||||||
return;
|
return;
|
||||||
@@ -353,7 +379,10 @@ async function runSyncJob(): Promise<void> {
|
|||||||
// Procesar en lotes de contribuyentes para aprovechar los proxies
|
// Procesar en lotes de contribuyentes para aprovechar los proxies
|
||||||
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||||
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||||
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron]')));
|
await Promise.all(batch.map(async (unit) => {
|
||||||
|
await jitterSleep(); // desalinear inicios y reducir ráfagas
|
||||||
|
return syncUnit(unit, '[SAT Cron]');
|
||||||
|
}));
|
||||||
|
|
||||||
// Pequeña pausa entre lotes
|
// Pequeña pausa entre lotes
|
||||||
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||||
@@ -499,7 +528,10 @@ async function runIncrementalSyncJob(): Promise<void> {
|
|||||||
|
|
||||||
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||||
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||||
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron Inc]')));
|
await Promise.all(batch.map(async (unit) => {
|
||||||
|
await jitterSleep(); // desalinear inicios y reducir ráfagas
|
||||||
|
return syncUnit(unit, '[SAT Cron Inc]');
|
||||||
|
}));
|
||||||
|
|
||||||
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
@@ -742,7 +774,6 @@ async function runDailyRetryJob(): Promise<void> {
|
|||||||
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let retry9amTask: ReturnType<typeof cron.schedule> | null = null;
|
|
||||||
let retry4pmTask: ReturnType<typeof cron.schedule> | null = null;
|
let retry4pmTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
@@ -798,17 +829,7 @@ export function startSatSyncJob(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Retomar jobs diarios que quedaron pending por timeout de polling.
|
// Retomar jobs diarios que quedaron pending por timeout de polling.
|
||||||
// 9:00 AM y 4:00 PM CDMX, complemento a los retries automáticos de 6h/12h.
|
// 4:00 PM CDMX, complemento a los retries automáticos de 6h/12h.
|
||||||
retry9amTask = cron.schedule(RETRY_9AM_CRON_SCHEDULE, async () => {
|
|
||||||
try {
|
|
||||||
await runDailyRetryJob();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('[SAT Daily Retry 9AM] Error:', error.message);
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
timezone: 'America/Mexico_City',
|
|
||||||
});
|
|
||||||
|
|
||||||
retry4pmTask = cron.schedule(RETRY_4PM_CRON_SCHEDULE, async () => {
|
retry4pmTask = cron.schedule(RETRY_4PM_CRON_SCHEDULE, async () => {
|
||||||
try {
|
try {
|
||||||
await runDailyRetryJob();
|
await runDailyRetryJob();
|
||||||
@@ -925,7 +946,7 @@ export function startSatSyncJob(): void {
|
|||||||
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[SAT Cron] Retry programado cada hora`);
|
console.log(`[SAT Cron] Retry programado cada hora`);
|
||||||
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[SAT Daily Retry] Programado para: ${RETRY_9AM_CRON_SCHEDULE} y ${RETRY_4PM_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[SAT Daily Retry] Programado para: ${RETRY_4PM_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||||
@@ -949,10 +970,6 @@ export function stopSatSyncJob(): void {
|
|||||||
recoveryTask.stop();
|
recoveryTask.stop();
|
||||||
recoveryTask = null;
|
recoveryTask = null;
|
||||||
}
|
}
|
||||||
if (retry9amTask) {
|
|
||||||
retry9amTask.stop();
|
|
||||||
retry9amTask = null;
|
|
||||||
}
|
|
||||||
if (retry4pmTask) {
|
if (retry4pmTask) {
|
||||||
retry4pmTask.stop();
|
retry4pmTask.stop();
|
||||||
retry4pmTask = null;
|
retry4pmTask = null;
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
querySat,
|
querySat,
|
||||||
verifySatRequest,
|
verifySatRequest,
|
||||||
downloadSatPackage,
|
downloadSatPackage,
|
||||||
|
startOfMexicoDay,
|
||||||
|
SatNetworkError,
|
||||||
type FielData,
|
type FielData,
|
||||||
type ProxyInfo,
|
type ProxyInfo,
|
||||||
} from './sat-client.service.js';
|
} from './sat-client.service.js';
|
||||||
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js';
|
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js';
|
||||||
|
import { proxyManager } from './proxy.service.js';
|
||||||
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js';
|
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js';
|
||||||
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
|
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
|
||||||
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
|
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
|
||||||
@@ -18,10 +21,54 @@ 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 = 5 * 60 * 1000; // 5 minutos entre verificaciones
|
/**
|
||||||
const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud (~45 min total)
|
* Intervalos de polling con backoff corto (en minutos).
|
||||||
const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily: 9 intentos × 5 min
|
* Patrón: 5, 5, 5, 10, 10, 15, 15, 20, 20 minutos.
|
||||||
|
* Reduce la presión inicial sobre el SAT y sigue dando hasta ~105 min de espera.
|
||||||
|
*/
|
||||||
|
const POLL_INTERVALS_MIN = [5, 5, 5, 10, 10, 15, 15, 20, 20];
|
||||||
|
const POLL_INTERVALS_MS = POLL_INTERVALS_MIN.map(m => m * 60 * 1000);
|
||||||
|
const MAX_POLL_ATTEMPTS = POLL_INTERVALS_MS.length; // 9 intentos máximo por solicitud
|
||||||
|
const DAILY_MAX_POLL_ATTEMPTS = MAX_POLL_ATTEMPTS; // igual para daily
|
||||||
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
|
||||||
|
const SAT_MAX_RANGE_MONTHS = 71; // 5 años y 11 meses: margen seguro bajo el límite de 6 años del SAT
|
||||||
|
|
||||||
|
// Tenants excluidos de sincronizaciones SAT (cuentas demo/ficticias).
|
||||||
|
// Ejemplo: SAT_SYNC_EXCLUDED_TENANTS=DEMO2501019X2,OTRO_RFC
|
||||||
|
const EXCLUDED_TENANTS = new Set(
|
||||||
|
(process.env.SAT_SYNC_EXCLUDED_TENANTS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => s.trim().toUpperCase())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function isTenantExcludedFromSync(tenantId: string): Promise<boolean> {
|
||||||
|
if (EXCLUDED_TENANTS.has(tenantId)) return true;
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { rfc: true } });
|
||||||
|
return tenant?.rfc ? EXCLUDED_TENANTS.has(tenant.rfc.toUpperCase()) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recorta una fecha de inicio personalizada para que el rango [dateFrom, fechaFin]
|
||||||
|
* no exceda ~5 años 11 meses. El SAT rechaza solicitudes "mayor a 6 años de
|
||||||
|
* antigüedad"; este margen evita el error 301 en los chunks más antiguos.
|
||||||
|
*/
|
||||||
|
function clampDateFrom(fechaFin: Date, customDateFrom?: Date): Date {
|
||||||
|
const limite = new Date(
|
||||||
|
fechaFin.getFullYear(),
|
||||||
|
fechaFin.getMonth() - SAT_MAX_RANGE_MONTHS,
|
||||||
|
fechaFin.getDate(),
|
||||||
|
fechaFin.getHours(),
|
||||||
|
fechaFin.getMinutes(),
|
||||||
|
fechaFin.getSeconds(),
|
||||||
|
fechaFin.getMilliseconds(),
|
||||||
|
);
|
||||||
|
if (!customDateFrom || customDateFrom < limite) {
|
||||||
|
console.log(`[SAT] Rango recortado a ${limite.toISOString()} para no exceder 5 años 11 meses`);
|
||||||
|
return limite;
|
||||||
|
}
|
||||||
|
return customDateFrom;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fecha final segura para consultas al SAT.
|
* Fecha final segura para consultas al SAT.
|
||||||
@@ -100,6 +147,40 @@ interface SyncContext {
|
|||||||
getPool: () => Promise<Pool>;
|
getPool: () => Promise<Pool>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta una operación SAT reintentando con otro proxy si falla por error de red
|
||||||
|
* (timeout, ECONNRESET, 502, etc.). Actualiza el Service y proxyInfo del contexto
|
||||||
|
* para que las siguientes operaciones del mismo job usen el proxy que funcionó.
|
||||||
|
*/
|
||||||
|
async function withSatRetry<T>(
|
||||||
|
ctx: SyncContext,
|
||||||
|
operation: (service: Service) => Promise<T>,
|
||||||
|
maxProxyRetries = 2,
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: any;
|
||||||
|
for (let attempt = 0; attempt <= maxProxyRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation(ctx.service);
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error;
|
||||||
|
if (!(error instanceof SatNetworkError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const current = ctx.proxyInfo;
|
||||||
|
const fallback = proxyManager.getDifferentProxy(current);
|
||||||
|
if (!fallback) {
|
||||||
|
console.warn(`[SAT] No hay proxy alternativo disponible para failover`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
console.warn(`[SAT] Proxy failover intento ${attempt + 1}: ${current?.host}:${current?.port} → ${fallback.host}:${fallback.port}`);
|
||||||
|
const { service, proxyInfo } = createSatService(ctx.fielData, fallback);
|
||||||
|
ctx.service = service;
|
||||||
|
ctx.proxyInfo = proxyInfo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registra un error de bloqueo/devolución del SAT junto con el proxy usado.
|
* Registra un error de bloqueo/devolución del SAT junto con el proxy usado.
|
||||||
* Permite reportes diarios de errores por IP.
|
* Permite reportes diarios de errores por IP.
|
||||||
@@ -660,48 +741,50 @@ async function requestAndDownload(
|
|||||||
});
|
});
|
||||||
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
|
||||||
|
|
||||||
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
|
// Reutilización segura de requestIds entre jobs recientes:
|
||||||
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
|
// - Solo jobs del mismo tenant/contribuyente, tipo daily/incremental, estado completed.
|
||||||
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
|
// - Solo si el requestId fue creado en las últimas 48h (evita expiración >72h del SAT).
|
||||||
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
|
// - Solo si el kindKey (rango+tipo+requestType) coincide exactamente.
|
||||||
//
|
// Esto ahorra quota del SAT y evita crear solicitudes duplicadas para el mismo rango.
|
||||||
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
|
const REQUEST_ID_REUSE_MAX_AGE_HOURS = 48;
|
||||||
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
|
if (!existingMap[kindKey]) {
|
||||||
// if (!existingMap[kindKey]) {
|
const reuseCutoff = new Date(Date.now() - REQUEST_ID_REUSE_MAX_AGE_HOURS * 60 * 60 * 1000);
|
||||||
// const previousJob = await prisma.satSyncJob.findFirst({
|
const previousJob = await prisma.satSyncJob.findFirst({
|
||||||
// where: {
|
where: {
|
||||||
// tenantId: jobRow?.tenantId,
|
tenantId: jobRow?.tenantId,
|
||||||
// contribuyenteId: jobRow?.contribuyenteId ?? null,
|
contribuyenteId: jobRow?.contribuyenteId ?? null,
|
||||||
// id: { not: jobId },
|
id: { not: jobId },
|
||||||
// dateFrom: jobRow?.dateFrom,
|
type: { in: ['daily', 'incremental'] },
|
||||||
// dateTo: jobRow?.dateTo,
|
status: 'completed',
|
||||||
// },
|
createdAt: { gte: reuseCutoff },
|
||||||
// orderBy: { createdAt: 'desc' },
|
},
|
||||||
// select: { satRequestIds: true },
|
orderBy: { createdAt: 'desc' },
|
||||||
// });
|
select: { satRequestIds: true },
|
||||||
// if (previousJob?.satRequestIds) {
|
});
|
||||||
// const prevMap = previousJob.satRequestIds as Record<string, string>;
|
if (previousJob?.satRequestIds) {
|
||||||
// if (prevMap[kindKey]) {
|
const prevMap = previousJob.satRequestIds as Record<string, string>;
|
||||||
// console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
if (prevMap[kindKey]) {
|
||||||
// // Copiar al job actual para futuros usos
|
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
|
||||||
// await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
|
||||||
// existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|
||||||
let requestId: string | null = existingMap[kindKey] || null;
|
let requestId: string | null = existingMap[kindKey] || null;
|
||||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||||
|
|
||||||
if (requestId) {
|
if (requestId) {
|
||||||
console.log(`[SAT] Reusando requestId previo (${label}): ${requestId}`);
|
const currentRequestId = requestId; // alias para estrechamiento de tipo
|
||||||
|
console.log(`[SAT] Reusando requestId previo (${label}): ${currentRequestId}`);
|
||||||
try {
|
try {
|
||||||
verifyResult = await verifySatRequest(ctx.service, requestId);
|
verifyResult = await withSatRetry(ctx, service => verifySatRequest(service, currentRequestId));
|
||||||
console.log(`[SAT] Estado del request reusado (${label}): ${verifyResult.status}`);
|
console.log(`[SAT] Estado del request reusado (${label}): ${verifyResult.status}`);
|
||||||
|
|
||||||
// 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)) {
|
if (isAgotadas(verifyResult.message) || verifyResult.codeRequest === '5002') {
|
||||||
|
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest || '5002', stageIdForTimeout(label), `${label}: solicitud reusada agotada de por vida`);
|
||||||
console.log(`[SAT] Solicitud reusada agotada de por vida (${label}); se cancela y se omite, no se recrea.`);
|
console.log(`[SAT] Solicitud reusada agotada de por vida (${label}); se cancela y se omite, no se recrea.`);
|
||||||
return { packageContents: [], totalCfdis: 0 };
|
return { packageContents: [], totalCfdis: 0 };
|
||||||
}
|
}
|
||||||
@@ -722,10 +805,12 @@ async function requestAndDownload(
|
|||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
console.log(`[SAT] Solicitando ${label} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
|
console.log(`[SAT] Solicitando ${label} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
|
||||||
|
|
||||||
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType);
|
const queryResult = await withSatRetry(ctx, service => querySat(service, fechaInicio, fechaFin, tipoCfdi, requestType));
|
||||||
|
|
||||||
if (!queryResult.success) {
|
if (!queryResult.success) {
|
||||||
if (queryResult.statusCode === '5004' || queryResult.statusCode === 'exhausted' || isAgotadas(queryResult.message)) {
|
if (queryResult.statusCode === '5004' || queryResult.statusCode === '5002' || isAgotadas(queryResult.message)) {
|
||||||
|
const satCode = queryResult.statusCode === '5002' || isAgotadas(queryResult.message) ? '5002' : '5004';
|
||||||
|
await recordSatProxyError(jobId, ctx, satCode, stageIdForTimeout(label), `${label}: ${queryResult.message}`);
|
||||||
console.log(`[SAT] Sin CFDIs, quota agotada o solicitudes agotadas (${label}): ${queryResult.message}`);
|
console.log(`[SAT] Sin CFDIs, quota agotada o solicitudes agotadas (${label}): ${queryResult.message}`);
|
||||||
return { packageContents: [], totalCfdis: 0 };
|
return { packageContents: [], totalCfdis: 0 };
|
||||||
}
|
}
|
||||||
@@ -755,15 +840,17 @@ async function requestAndDownload(
|
|||||||
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
const delayMs = POLL_INTERVALS_MS[attempts];
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
verifyResult = await verifySatRequest(ctx.service, requestId);
|
verifyResult = await withSatRetry(ctx, service => verifySatRequest(service, requestId));
|
||||||
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
|
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts}, espera ${delayMs / 60000}min)`);
|
||||||
|
|
||||||
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)) {
|
if (isAgotadas(verifyResult.message) || verifyResult.codeRequest === '5002') {
|
||||||
|
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest || '5002', stageIdForTimeout(label), `${label}: solicitudes agotadas de por vida`);
|
||||||
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`);
|
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`);
|
||||||
return { packageContents: [], totalCfdis: 0 };
|
return { packageContents: [], totalCfdis: 0 };
|
||||||
}
|
}
|
||||||
@@ -777,12 +864,18 @@ async function requestAndDownload(
|
|||||||
throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
|
throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registrar EmptyResult (5004) / Exhausted (5002) cuando el verify termina en
|
||||||
|
// ready con 0 paquetes. Esto permite distinguir "no hay CFDIs" de quota agotada.
|
||||||
|
if (verifyResult.totalCfdis === 0 && verifyResult.packageIds.length === 0 && verifyResult.codeRequest) {
|
||||||
|
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest, stageIdForTimeout(label), `${label}: ${verifyResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
const packageContents: string[] = [];
|
const packageContents: string[] = [];
|
||||||
for (let i = 0; i < verifyResult.packageIds.length; i++) {
|
for (let i = 0; i < verifyResult.packageIds.length; i++) {
|
||||||
const packageId = verifyResult.packageIds[i];
|
const packageId = verifyResult.packageIds[i];
|
||||||
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
|
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
|
||||||
|
|
||||||
const downloadResult = await downloadSatPackage(ctx.service, packageId);
|
const downloadResult = await withSatRetry(ctx, service => downloadSatPackage(service, packageId));
|
||||||
if (!downloadResult.success) {
|
if (!downloadResult.success) {
|
||||||
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
|
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -992,9 +1085,9 @@ async function processInitialSync(
|
|||||||
// Si el usuario pasó un rango explícito lo respetamos (aunque podría fallar
|
// Si el usuario pasó un rango explícito lo respetamos (aunque podría fallar
|
||||||
// si pone "hoy").
|
// si pone "hoy").
|
||||||
const fechaFin = customDateTo || getYesterdayEnd();
|
const fechaFin = customDateTo || getYesterdayEnd();
|
||||||
// Exactamente 6 años atrás desde la fecha final (mismo día del mes), no inicio de mes.
|
// Inicio histórico: 5 años y 11 meses atrás. El SAT rechaza rangos "mayor a 6 años",
|
||||||
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
|
// así que dejamos 1 mes de margen de seguridad respecto al límite legal.
|
||||||
const inicioHistorico = customDateFrom || new Date(fechaFin.getFullYear() - YEARS_TO_SYNC, fechaFin.getMonth(), fechaFin.getDate());
|
const inicioHistorico = clampDateFrom(fechaFin, customDateFrom);
|
||||||
|
|
||||||
// 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);
|
||||||
@@ -1025,6 +1118,7 @@ async function processInitialSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
|
// Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses)
|
||||||
|
const xmlErrors: { stage: string; bloque: number; start: string; end: string; message: string }[] = [];
|
||||||
for (let i = 0; i < xmlChunks.length; i++) {
|
for (let i = 0; i < xmlChunks.length; i++) {
|
||||||
const { start, end } = xmlChunks[i];
|
const { start, end } = xmlChunks[i];
|
||||||
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
|
console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
|
||||||
@@ -1037,6 +1131,7 @@ async function processInitialSync(
|
|||||||
totalUpdated += emitidos.updated;
|
totalUpdated += emitidos.updated;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message);
|
||||||
|
xmlErrors.push({ stage: 'xml-emitidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
|
||||||
}
|
}
|
||||||
await reportProgress();
|
await reportProgress();
|
||||||
|
|
||||||
@@ -1048,6 +1143,7 @@ async function processInitialSync(
|
|||||||
totalUpdated += recibidos.updated;
|
totalUpdated += recibidos.updated;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message);
|
||||||
|
xmlErrors.push({ stage: 'xml-recibidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
|
||||||
}
|
}
|
||||||
await reportProgress();
|
await reportProgress();
|
||||||
|
|
||||||
@@ -1055,6 +1151,7 @@ async function processInitialSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Paso 3: Descargar metadata (bloques anuales)
|
// Paso 3: Descargar metadata (bloques anuales)
|
||||||
|
const metadataErrors: { stage: string; bloque: number; start: string; end: string; message: string }[] = [];
|
||||||
for (let i = 0; i < metaChunks.length; i++) {
|
for (let i = 0; i < metaChunks.length; i++) {
|
||||||
const { start, end } = metaChunks[i];
|
const { start, end } = metaChunks[i];
|
||||||
console.log(`[SAT] Metadata bloque ${i + 1}/${metaChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
|
console.log(`[SAT] Metadata bloque ${i + 1}/${metaChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`);
|
||||||
@@ -1065,6 +1162,7 @@ async function processInitialSync(
|
|||||||
totalUpdated += updated;
|
totalUpdated += updated;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message);
|
||||||
|
metadataErrors.push({ stage: 'metadata-emitidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
|
||||||
}
|
}
|
||||||
await reportProgress();
|
await reportProgress();
|
||||||
|
|
||||||
@@ -1074,6 +1172,7 @@ async function processInitialSync(
|
|||||||
totalUpdated += updated;
|
totalUpdated += updated;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
|
console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message);
|
||||||
|
metadataErrors.push({ stage: 'metadata-recibidos', bloque: i + 1, start: start.toISOString(), end: end.toISOString(), message: error.message });
|
||||||
}
|
}
|
||||||
await reportProgress();
|
await reportProgress();
|
||||||
|
|
||||||
@@ -1086,7 +1185,16 @@ async function processInitialSync(
|
|||||||
cfdisInserted: totalInserted,
|
cfdisInserted: totalInserted,
|
||||||
cfdisUpdated: totalUpdated,
|
cfdisUpdated: totalUpdated,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
|
errorMessage: xmlErrors.length > 0 || metadataErrors.length > 0
|
||||||
|
? JSON.stringify({ xmlErrors, metadataErrors }, null, 2)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Si fallo alguna etapa de XMLs, marcamos el job como failed para que sea
|
||||||
|
// facil identificarlo y repetir la sincronizacion mas tarde.
|
||||||
|
if (xmlErrors.length > 0) {
|
||||||
|
throw new Error(`Fallo en etapa de XMLs: ${xmlErrors.map(e => `${e.stage} bloque ${e.bloque}`).join(', ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1100,9 +1208,10 @@ async function processInitialSync(
|
|||||||
async function processCustomRangeSync(
|
async function processCustomRangeSync(
|
||||||
ctx: SyncContext,
|
ctx: SyncContext,
|
||||||
jobId: string,
|
jobId: string,
|
||||||
dateFrom: Date,
|
dateFromInput: Date,
|
||||||
dateTo: Date
|
dateTo: Date
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const dateFrom = clampDateFrom(dateTo, dateFromInput);
|
||||||
const diffMs = dateTo.getTime() - dateFrom.getTime();
|
const diffMs = dateTo.getTime() - dateFrom.getTime();
|
||||||
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
|
const diffMonths = diffMs / (1000 * 60 * 60 * 24 * 30);
|
||||||
const MAX_MONTHS_DIRECT = 6;
|
const MAX_MONTHS_DIRECT = 6;
|
||||||
@@ -1175,41 +1284,37 @@ async function processCustomRangeSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sincronización incremental: ventana fija de las últimas 8 horas.
|
* Sincronización incremental: rango ayer → hoy (hora CDMX).
|
||||||
* Diseñada para correr 3 veces al día (11:00, 15:00, 19:00) en clientes Enterprise.
|
* Corre 3 veces al día (11:00, 15:00 y 19:00) en clientes Enterprise.
|
||||||
* La ventana de 8h cubre el gap máximo (03:00 → 11:00) entre el daily y el primer
|
* El SAT solo acepta rangos de días calendario distintos, así que la ventana
|
||||||
* incremental; los disparos siguientes solapan, pero la unicidad del UUID deduplica.
|
* mínima válida es ayer 00:00 → hoy 00:00. Cada ejecución recupera las
|
||||||
* Fuera de ese rango, el daily de 03:00 se encarga.
|
* facturas de hoy que el SAT ya haya procesado desde la última corrida.
|
||||||
|
*
|
||||||
|
* Solo descargamos XMLs (cfdi); la metadata histórica la cubre el daily
|
||||||
|
* (domingos) y el recovery cron. Incluir metadata en incremental hace que
|
||||||
|
* el job exceda el umbral del watchdog porque los paquetes de metadata de
|
||||||
|
* 1 día tardan demasiado en generarse.
|
||||||
|
* Los disparos solapan, pero la unicidad del UUID deduplica.
|
||||||
*/
|
*/
|
||||||
const INCREMENTAL_WINDOW_HOURS = 8;
|
|
||||||
|
|
||||||
async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> {
|
async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||||
// Retrocedemos 2h respecto a ahora para evitar que el SAT vea una fecha final
|
const fechaFin = startOfMexicoDay(0); // hoy 00:00 CDMX
|
||||||
// futura / demasiado reciente (rechazo "Fecha final invalida").
|
const desde = startOfMexicoDay(-1); // ayer 00:00 CDMX
|
||||||
const ahora = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
|
||||||
const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
let totalDownloaded = 0;
|
let totalDownloaded = 0;
|
||||||
let totalInserted = 0;
|
let totalInserted = 0;
|
||||||
let totalUpdated = 0;
|
let totalUpdated = 0;
|
||||||
|
|
||||||
console.log(`[SAT] Incremental: ${desde.toISOString()} → ${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`);
|
console.log(`[SAT] Incremental: ${desde.toISOString()} → ${fechaFin.toISOString()} (ayer → hoy CDMX)`);
|
||||||
|
|
||||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||||
const result = await processDateRange(ctx, jobId, desde, ahora, tipo);
|
const result = await processDateRange(ctx, jobId, desde, fechaFin, 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
|
||||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo);
|
|
||||||
totalInserted += inserted;
|
|
||||||
totalUpdated += updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
cfdisFound: totalFound,
|
cfdisFound: totalFound,
|
||||||
cfdisDownloaded: totalDownloaded,
|
cfdisDownloaded: totalDownloaded,
|
||||||
@@ -1483,15 +1588,17 @@ async function requestAndDownloadWithId(
|
|||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
||||||
while (attempts < maxAttempts) {
|
while (attempts < maxAttempts) {
|
||||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
const delayMs = POLL_INTERVALS_MS[attempts];
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||||
attempts++;
|
attempts++;
|
||||||
|
|
||||||
verifyResult = await verifySatRequest(ctx.service, requestId);
|
verifyResult = await withSatRetry(ctx, service => verifySatRequest(service, requestId));
|
||||||
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
|
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts}, espera ${delayMs / 60000}min)`);
|
||||||
|
|
||||||
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)) {
|
if (isAgotadas(verifyResult.message) || verifyResult.codeRequest === '5002') {
|
||||||
|
await recordSatProxyError(jobId, ctx, verifyResult.codeRequest || '5002', stageIdForTimeout(label), `${label}: solicitudes agotadas de por vida`);
|
||||||
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite.`);
|
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite.`);
|
||||||
return { inserted: 0, updated: 0 };
|
return { inserted: 0, updated: 0 };
|
||||||
}
|
}
|
||||||
@@ -1507,7 +1614,7 @@ async function requestAndDownloadWithId(
|
|||||||
const packageId = verifyResult.packageIds[i];
|
const packageId = verifyResult.packageIds[i];
|
||||||
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
|
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
|
||||||
|
|
||||||
const downloadResult = await downloadSatPackage(ctx.service, packageId);
|
const downloadResult = await withSatRetry(ctx, service => downloadSatPackage(service, packageId));
|
||||||
if (!downloadResult.success) {
|
if (!downloadResult.success) {
|
||||||
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
|
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
|
||||||
continue;
|
continue;
|
||||||
@@ -1534,6 +1641,10 @@ export async function startSync(
|
|||||||
dateTo?: Date,
|
dateTo?: Date,
|
||||||
contribuyenteId?: string
|
contribuyenteId?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
if (await isTenantExcludedFromSync(tenantId)) {
|
||||||
|
throw new Error('Este tenant está excluido de sincronizaciones SAT');
|
||||||
|
}
|
||||||
|
|
||||||
// Try per-contribuyente FIEL first (despachos), then legacy (Horux360)
|
// Try per-contribuyente FIEL first (despachos), then legacy (Horux360)
|
||||||
let decryptedFiel = null;
|
let decryptedFiel = null;
|
||||||
if (contribuyenteId) {
|
if (contribuyenteId) {
|
||||||
@@ -1585,16 +1696,18 @@ export async function startSync(
|
|||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// `isCustomRange` solo aplica a 'initial' con fechas explícitas del UI.
|
// `isCustomRange` solo aplica a 'initial' con fechas explícitas del UI.
|
||||||
// Bootstrap puro = initial sin fechas → usa default de 6 años atrás.
|
// Bootstrap puro = initial sin fechas → usa el límite seguro de 5 años 11 meses.
|
||||||
const isCustomRange = type === 'initial' && (!!dateFrom || !!dateTo);
|
const isCustomRange = type === 'initial' && (!!dateFrom || !!dateTo);
|
||||||
|
const effectiveDateTo = dateTo || now;
|
||||||
|
const effectiveDateFrom = clampDateFrom(effectiveDateTo, dateFrom);
|
||||||
const job = await prisma.satSyncJob.create({
|
const job = await prisma.satSyncJob.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
contribuyenteId: contribuyenteId || null,
|
contribuyenteId: contribuyenteId || null,
|
||||||
type,
|
type,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
|
dateFrom: effectiveDateFrom,
|
||||||
dateTo: dateTo || now,
|
dateTo: effectiveDateTo,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
isCustomRange,
|
isCustomRange,
|
||||||
proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null,
|
proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null,
|
||||||
@@ -1616,11 +1729,11 @@ export async function startSync(
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (type === 'initial') {
|
if (type === 'initial') {
|
||||||
await processInitialSync(ctx, job.id, dateFrom, dateTo);
|
await processInitialSync(ctx, job.id, effectiveDateFrom, dateTo);
|
||||||
} else if (type === 'incremental') {
|
} else if (type === 'incremental') {
|
||||||
await processIncrementalSync(ctx, job.id);
|
await processIncrementalSync(ctx, job.id);
|
||||||
} else if (dateFrom && dateTo) {
|
} else if (dateFrom && dateTo) {
|
||||||
await processCustomRangeSync(ctx, job.id, dateFrom, dateTo);
|
await processCustomRangeSync(ctx, job.id, effectiveDateFrom, dateTo);
|
||||||
} else {
|
} else {
|
||||||
await processDailySync(ctx, job.id);
|
await processDailySync(ctx, job.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ module.exports = {
|
|||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
PORT: 4000,
|
PORT: 4000,
|
||||||
|
SAT_SYNC_EXCLUDED_TENANTS: 'DEMO2501019X2',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user