From 0a65c60570c386a96de44fa1a02c41ac40407a8c Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Sun, 25 Jan 2026 00:53:54 +0000 Subject: [PATCH] feat(sat): add scheduled cron job for daily sync (Phase 6) - Add sat-sync.job.ts with scheduled daily sync at 3:00 AM - Automatic detection of tenants with active FIEL - Initial sync (10 years) for new tenants, daily for existing - Concurrent processing with configurable batch size - Integration with app startup for production environment - Install node-cron dependency Co-Authored-By: Claude Opus 4.5 --- apps/api/package.json | 2 + apps/api/src/index.ts | 10 +- apps/api/src/jobs/sat-sync.job.ts | 162 ++++++++++++++++++++++++++++++ pnpm-lock.yaml | 17 ++++ 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/jobs/sat-sync.job.ts diff --git a/apps/api/package.json b/apps/api/package.json index 15ea917..9a7fd55 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ "fast-xml-parser": "^5.3.3", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", + "node-cron": "^4.2.1", "node-forge": "^1.3.3", "zod": "^3.23.0" }, @@ -37,6 +38,7 @@ "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.0.0", + "@types/node-cron": "^3.0.11", "@types/node-forge": "^1.3.14", "prisma": "^5.22.0", "tsx": "^4.19.0", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 432c560..2dad4ec 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,9 +1,15 @@ import { app } from './app.js'; import { env } from './config/env.js'; +import { startSatSyncJob } from './jobs/sat-sync.job.js'; const PORT = parseInt(env.PORT, 10); app.listen(PORT, '0.0.0.0', () => { - console.log(`馃殌 API Server running on http://0.0.0.0:${PORT}`); - console.log(`馃搳 Environment: ${env.NODE_ENV}`); + console.log(`API Server running on http://0.0.0.0:${PORT}`); + console.log(`Environment: ${env.NODE_ENV}`); + + // Iniciar job de sincronizaci贸n SAT + if (env.NODE_ENV === 'production') { + startSatSyncJob(); + } }); diff --git a/apps/api/src/jobs/sat-sync.job.ts b/apps/api/src/jobs/sat-sync.job.ts new file mode 100644 index 0000000..c442e6e --- /dev/null +++ b/apps/api/src/jobs/sat-sync.job.ts @@ -0,0 +1,162 @@ +import cron from 'node-cron'; +import { prisma } from '../config/database.js'; +import { startSync, getSyncStatus } from '../services/sat/sat.service.js'; +import { hasFielConfigured } from '../services/fiel.service.js'; + +const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los d铆as +const CONCURRENT_SYNCS = 3; // M谩ximo de sincronizaciones simult谩neas + +let isRunning = false; + +/** + * Obtiene los tenants que tienen FIEL configurada y activa + */ +async function getTenantsWithFiel(): Promise { + const tenants = await prisma.tenant.findMany({ + where: { active: true }, + select: { id: true }, + }); + + const tenantsWithFiel: string[] = []; + + for (const tenant of tenants) { + const hasFiel = await hasFielConfigured(tenant.id); + if (hasFiel) { + tenantsWithFiel.push(tenant.id); + } + } + + return tenantsWithFiel; +} + +/** + * Verifica si un tenant necesita sincronizaci贸n inicial + */ +async function needsInitialSync(tenantId: string): Promise { + const completedSync = await prisma.satSyncJob.findFirst({ + where: { + tenantId, + type: 'initial', + status: 'completed', + }, + }); + + return !completedSync; +} + +/** + * Ejecuta sincronizaci贸n para un tenant + */ +async function syncTenant(tenantId: string): Promise { + try { + // Verificar si hay sync activo + const status = await getSyncStatus(tenantId); + if (status.hasActiveSync) { + console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`); + return; + } + + // Determinar tipo de sync + const needsInitial = await needsInitialSync(tenantId); + const syncType = needsInitial ? 'initial' : 'daily'; + + console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`); + const jobId = await startSync(tenantId, syncType); + console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`); + } catch (error: any) { + console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message); + } +} + +/** + * Ejecuta el job de sincronizaci贸n para todos los tenants + */ +async function runSyncJob(): Promise { + if (isRunning) { + console.log('[SAT Cron] Job ya en ejecuci贸n, omitiendo'); + return; + } + + isRunning = true; + console.log('[SAT Cron] Iniciando job de sincronizaci贸n diaria'); + + try { + const tenantIds = await getTenantsWithFiel(); + console.log(`[SAT Cron] ${tenantIds.length} tenants con FIEL configurada`); + + if (tenantIds.length === 0) { + console.log('[SAT Cron] No hay tenants para sincronizar'); + return; + } + + // Procesar en lotes para no saturar + for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) { + const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS); + await Promise.all(batch.map(syncTenant)); + + // Peque帽a pausa entre lotes + if (i + CONCURRENT_SYNCS < tenantIds.length) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + + console.log('[SAT Cron] Job de sincronizaci贸n completado'); + } catch (error: any) { + console.error('[SAT Cron] Error en job:', error.message); + } finally { + isRunning = false; + } +} + +let scheduledTask: ReturnType | null = null; + +/** + * Inicia el job programado + */ +export function startSatSyncJob(): void { + if (scheduledTask) { + console.log('[SAT Cron] Job ya est谩 programado'); + return; + } + + // Validar expresi贸n cron + if (!cron.validate(SYNC_CRON_SCHEDULE)) { + console.error('[SAT Cron] Expresi贸n cron inv谩lida:', SYNC_CRON_SCHEDULE); + return; + } + + scheduledTask = cron.schedule(SYNC_CRON_SCHEDULE, runSyncJob, { + timezone: 'America/Mexico_City', + }); + + console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`); +} + +/** + * Detiene el job programado + */ +export function stopSatSyncJob(): void { + if (scheduledTask) { + scheduledTask.stop(); + scheduledTask = null; + console.log('[SAT Cron] Job detenido'); + } +} + +/** + * Ejecuta el job manualmente (para testing o ejecuci贸n forzada) + */ +export async function runSatSyncJobManually(): Promise { + await runSyncJob(); +} + +/** + * Obtiene informaci贸n del pr贸ximo job programado + */ +export function getJobInfo(): { scheduled: boolean; expression: string; timezone: string } { + return { + scheduled: scheduledTask !== null, + expression: SYNC_CRON_SCHEDULE, + timezone: 'America/Mexico_City', + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07a8383..0f6e785 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 node-forge: specifier: ^1.3.3 version: 1.3.3 @@ -81,6 +84,9 @@ importers: '@types/node': specifier: ^22.0.0 version: 22.19.7 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 '@types/node-forge': specifier: ^1.3.14 version: 1.3.14 @@ -1031,6 +1037,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node-forge@1.3.14': resolution: {integrity: sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==} @@ -1814,6 +1823,10 @@ packages: sass: optional: true + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + node-forge@1.3.3: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} @@ -3080,6 +3093,8 @@ snapshots: '@types/ms@2.1.0': {} + '@types/node-cron@3.0.11': {} + '@types/node-forge@1.3.14': dependencies: '@types/node': 22.19.7 @@ -3901,6 +3916,8 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-cron@4.2.1: {} + node-forge@1.3.3: {} node-releases@2.0.27: {}