feat(sat): add main sync orchestrator service (Phase 5)
- Add sat.service.ts as the main orchestrator that coordinates: - FIEL credential retrieval and token management - SAT download request workflow - Package processing and CFDI storage - Progress tracking and job management - Support for initial sync (10 years history) and daily sync - Automatic token refresh during long-running syncs - Month-by-month processing to avoid SAT limits - Raw SQL queries for multi-tenant schema isolation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
618
apps/api/src/services/sat/sat.service.ts
Normal file
618
apps/api/src/services/sat/sat.service.ts
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
import { prisma } from '../../config/database.js';
|
||||||
|
import { getDecryptedFiel } from '../fiel.service.js';
|
||||||
|
import { authenticate, isTokenValid } from './sat-auth.service.js';
|
||||||
|
import {
|
||||||
|
requestDownload,
|
||||||
|
verifyRequest,
|
||||||
|
downloadPackage,
|
||||||
|
isRequestComplete,
|
||||||
|
isRequestFailed,
|
||||||
|
isRequestInProgress,
|
||||||
|
SAT_REQUEST_STATES,
|
||||||
|
} from './sat-download.service.js';
|
||||||
|
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
|
||||||
|
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
|
||||||
|
import type { Credential } from '@nodecfdi/credentials/node';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 30000; // 30 segundos
|
||||||
|
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
|
||||||
|
const YEARS_TO_SYNC = 10;
|
||||||
|
|
||||||
|
interface SyncContext {
|
||||||
|
credential: Credential;
|
||||||
|
token: string;
|
||||||
|
tokenExpiresAt: Date;
|
||||||
|
rfc: string;
|
||||||
|
tenantId: string;
|
||||||
|
schemaName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene o renueva el token de autenticación
|
||||||
|
*/
|
||||||
|
async function ensureValidToken(ctx: SyncContext): Promise<void> {
|
||||||
|
if (!isTokenValid({ token: ctx.token, expiresAt: ctx.tokenExpiresAt })) {
|
||||||
|
console.log('[SAT] Renovando token...');
|
||||||
|
const newToken = await authenticate(ctx.credential);
|
||||||
|
ctx.token = newToken.token;
|
||||||
|
ctx.tokenExpiresAt = newToken.expiresAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza el progreso de un job
|
||||||
|
*/
|
||||||
|
async function updateJobProgress(
|
||||||
|
jobId: string,
|
||||||
|
updates: Partial<{
|
||||||
|
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||||
|
satRequestId: string;
|
||||||
|
satPackageIds: string[];
|
||||||
|
cfdisFound: number;
|
||||||
|
cfdisDownloaded: number;
|
||||||
|
cfdisInserted: number;
|
||||||
|
cfdisUpdated: number;
|
||||||
|
progressPercent: number;
|
||||||
|
errorMessage: string;
|
||||||
|
startedAt: Date;
|
||||||
|
completedAt: Date;
|
||||||
|
retryCount: number;
|
||||||
|
nextRetryAt: Date;
|
||||||
|
}>
|
||||||
|
): Promise<void> {
|
||||||
|
await prisma.satSyncJob.update({
|
||||||
|
where: { id: jobId },
|
||||||
|
data: updates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda los CFDIs en la base de datos del tenant
|
||||||
|
*/
|
||||||
|
async function saveCfdis(
|
||||||
|
schemaName: string,
|
||||||
|
cfdis: CfdiParsed[],
|
||||||
|
jobId: string
|
||||||
|
): Promise<{ inserted: number; updated: number }> {
|
||||||
|
let inserted = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
for (const cfdi of cfdis) {
|
||||||
|
try {
|
||||||
|
// Usar raw query para el esquema del tenant
|
||||||
|
const existing = await prisma.$queryRawUnsafe<{ id: string }[]>(
|
||||||
|
`SELECT id FROM "${schemaName}".cfdis WHERE uuid_fiscal = $1`,
|
||||||
|
cfdi.uuidFiscal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Actualizar CFDI existente
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`UPDATE "${schemaName}".cfdis SET
|
||||||
|
tipo = $2,
|
||||||
|
serie = $3,
|
||||||
|
folio = $4,
|
||||||
|
fecha_emision = $5,
|
||||||
|
fecha_timbrado = $6,
|
||||||
|
rfc_emisor = $7,
|
||||||
|
nombre_emisor = $8,
|
||||||
|
rfc_receptor = $9,
|
||||||
|
nombre_receptor = $10,
|
||||||
|
subtotal = $11,
|
||||||
|
descuento = $12,
|
||||||
|
iva = $13,
|
||||||
|
isr_retenido = $14,
|
||||||
|
iva_retenido = $15,
|
||||||
|
total = $16,
|
||||||
|
moneda = $17,
|
||||||
|
tipo_cambio = $18,
|
||||||
|
metodo_pago = $19,
|
||||||
|
forma_pago = $20,
|
||||||
|
uso_cfdi = $21,
|
||||||
|
estado = $22,
|
||||||
|
xml_original = $23,
|
||||||
|
last_sat_sync = NOW(),
|
||||||
|
sat_sync_job_id = $24,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE uuid_fiscal = $1`,
|
||||||
|
cfdi.uuidFiscal,
|
||||||
|
cfdi.tipo,
|
||||||
|
cfdi.serie,
|
||||||
|
cfdi.folio,
|
||||||
|
cfdi.fechaEmision,
|
||||||
|
cfdi.fechaTimbrado,
|
||||||
|
cfdi.rfcEmisor,
|
||||||
|
cfdi.nombreEmisor,
|
||||||
|
cfdi.rfcReceptor,
|
||||||
|
cfdi.nombreReceptor,
|
||||||
|
cfdi.subtotal,
|
||||||
|
cfdi.descuento,
|
||||||
|
cfdi.iva,
|
||||||
|
cfdi.isrRetenido,
|
||||||
|
cfdi.ivaRetenido,
|
||||||
|
cfdi.total,
|
||||||
|
cfdi.moneda,
|
||||||
|
cfdi.tipoCambio,
|
||||||
|
cfdi.metodoPago,
|
||||||
|
cfdi.formaPago,
|
||||||
|
cfdi.usoCfdi,
|
||||||
|
cfdi.estado,
|
||||||
|
cfdi.xmlOriginal,
|
||||||
|
jobId
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
// Insertar nuevo CFDI
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`INSERT INTO "${schemaName}".cfdis (
|
||||||
|
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||||
|
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||||
|
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||||
|
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado,
|
||||||
|
xml_original, source, sat_sync_job_id, last_sat_sync, created_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
|
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
|
||||||
|
$23, 'sat', $24, NOW(), NOW()
|
||||||
|
)`,
|
||||||
|
cfdi.uuidFiscal,
|
||||||
|
cfdi.tipo,
|
||||||
|
cfdi.serie,
|
||||||
|
cfdi.folio,
|
||||||
|
cfdi.fechaEmision,
|
||||||
|
cfdi.fechaTimbrado,
|
||||||
|
cfdi.rfcEmisor,
|
||||||
|
cfdi.nombreEmisor,
|
||||||
|
cfdi.rfcReceptor,
|
||||||
|
cfdi.nombreReceptor,
|
||||||
|
cfdi.subtotal,
|
||||||
|
cfdi.descuento,
|
||||||
|
cfdi.iva,
|
||||||
|
cfdi.isrRetenido,
|
||||||
|
cfdi.ivaRetenido,
|
||||||
|
cfdi.total,
|
||||||
|
cfdi.moneda,
|
||||||
|
cfdi.tipoCambio,
|
||||||
|
cfdi.metodoPago,
|
||||||
|
cfdi.formaPago,
|
||||||
|
cfdi.usoCfdi,
|
||||||
|
cfdi.estado,
|
||||||
|
cfdi.xmlOriginal,
|
||||||
|
jobId
|
||||||
|
);
|
||||||
|
inserted++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SAT] Error guardando CFDI ${cfdi.uuidFiscal}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procesa una solicitud de descarga para un rango de fechas
|
||||||
|
*/
|
||||||
|
async function processDateRange(
|
||||||
|
ctx: SyncContext,
|
||||||
|
jobId: string,
|
||||||
|
fechaInicio: Date,
|
||||||
|
fechaFin: Date,
|
||||||
|
tipoCfdi: CfdiSyncType
|
||||||
|
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||||
|
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
|
||||||
|
|
||||||
|
await ensureValidToken(ctx);
|
||||||
|
|
||||||
|
// 1. Solicitar descarga
|
||||||
|
const requestResponse = await requestDownload({
|
||||||
|
credential: ctx.credential,
|
||||||
|
token: ctx.token,
|
||||||
|
rfc: ctx.rfc,
|
||||||
|
fechaInicio,
|
||||||
|
fechaFin,
|
||||||
|
tipoSolicitud: 'CFDI',
|
||||||
|
tipoCfdi,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (requestResponse.codEstatus !== '5000') {
|
||||||
|
if (requestResponse.codEstatus === '5004') {
|
||||||
|
console.log('[SAT] No se encontraron CFDIs en el rango');
|
||||||
|
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
throw new Error(`Error SAT: ${requestResponse.codEstatus} - ${requestResponse.mensaje}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idSolicitud = requestResponse.idSolicitud;
|
||||||
|
console.log(`[SAT] Solicitud creada: ${idSolicitud}`);
|
||||||
|
|
||||||
|
await updateJobProgress(jobId, { satRequestId: idSolicitud });
|
||||||
|
|
||||||
|
// 2. Esperar y verificar solicitud
|
||||||
|
let verifyResponse;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
while (attempts < MAX_POLL_ATTEMPTS) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
await ensureValidToken(ctx);
|
||||||
|
verifyResponse = await verifyRequest(ctx.credential, ctx.token, ctx.rfc, idSolicitud);
|
||||||
|
|
||||||
|
console.log(`[SAT] Estado solicitud: ${verifyResponse.estadoSolicitud} (intento ${attempts})`);
|
||||||
|
|
||||||
|
if (isRequestComplete(verifyResponse.estadoSolicitud)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRequestFailed(verifyResponse.estadoSolicitud)) {
|
||||||
|
throw new Error(`Solicitud fallida: ${verifyResponse.mensaje}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifyResponse || !isRequestComplete(verifyResponse.estadoSolicitud)) {
|
||||||
|
throw new Error('Timeout esperando respuesta del SAT');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Descargar paquetes
|
||||||
|
const packageIds = verifyResponse.paquetes;
|
||||||
|
await updateJobProgress(jobId, {
|
||||||
|
satPackageIds: packageIds,
|
||||||
|
cfdisFound: verifyResponse.numeroCfdis,
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalInserted = 0;
|
||||||
|
let totalUpdated = 0;
|
||||||
|
let totalDownloaded = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < packageIds.length; i++) {
|
||||||
|
const packageId = packageIds[i];
|
||||||
|
console.log(`[SAT] Descargando paquete ${i + 1}/${packageIds.length}: ${packageId}`);
|
||||||
|
|
||||||
|
await ensureValidToken(ctx);
|
||||||
|
const packageResponse = await downloadPackage(ctx.credential, ctx.token, ctx.rfc, packageId);
|
||||||
|
|
||||||
|
// 4. Procesar paquete
|
||||||
|
const cfdis = processPackage(packageResponse.paquete);
|
||||||
|
totalDownloaded += cfdis.length;
|
||||||
|
|
||||||
|
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
|
||||||
|
|
||||||
|
const { inserted, updated } = await saveCfdis(ctx.schemaName, cfdis, jobId);
|
||||||
|
totalInserted += inserted;
|
||||||
|
totalUpdated += updated;
|
||||||
|
|
||||||
|
// Actualizar progreso
|
||||||
|
const progress = Math.round(((i + 1) / packageIds.length) * 100);
|
||||||
|
await updateJobProgress(jobId, {
|
||||||
|
cfdisDownloaded: totalDownloaded,
|
||||||
|
cfdisInserted: totalInserted,
|
||||||
|
cfdisUpdated: totalUpdated,
|
||||||
|
progressPercent: progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: verifyResponse.numeroCfdis,
|
||||||
|
downloaded: totalDownloaded,
|
||||||
|
inserted: totalInserted,
|
||||||
|
updated: totalUpdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta sincronización inicial (últimos 10 años)
|
||||||
|
*/
|
||||||
|
async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||||
|
const ahora = new Date();
|
||||||
|
const inicioHistorico = new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
||||||
|
|
||||||
|
let totalFound = 0;
|
||||||
|
let totalDownloaded = 0;
|
||||||
|
let totalInserted = 0;
|
||||||
|
let totalUpdated = 0;
|
||||||
|
|
||||||
|
// Procesar por meses para evitar límites del SAT
|
||||||
|
let currentDate = new Date(inicioHistorico);
|
||||||
|
|
||||||
|
while (currentDate < ahora) {
|
||||||
|
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
|
||||||
|
const rangeEnd = monthEnd > ahora ? ahora : monthEnd;
|
||||||
|
|
||||||
|
// Procesar emitidos
|
||||||
|
try {
|
||||||
|
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
|
||||||
|
totalFound += emitidos.found;
|
||||||
|
totalDownloaded += emitidos.downloaded;
|
||||||
|
totalInserted += emitidos.inserted;
|
||||||
|
totalUpdated += emitidos.updated;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar recibidos
|
||||||
|
try {
|
||||||
|
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
|
||||||
|
totalFound += recibidos.found;
|
||||||
|
totalDownloaded += recibidos.downloaded;
|
||||||
|
totalInserted += recibidos.inserted;
|
||||||
|
totalUpdated += recibidos.updated;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Siguiente mes
|
||||||
|
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
||||||
|
|
||||||
|
// Pequeña pausa entre meses para no saturar el SAT
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateJobProgress(jobId, {
|
||||||
|
cfdisFound: totalFound,
|
||||||
|
cfdisDownloaded: totalDownloaded,
|
||||||
|
cfdisInserted: totalInserted,
|
||||||
|
cfdisUpdated: totalUpdated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ejecuta sincronización diaria (mes actual)
|
||||||
|
*/
|
||||||
|
async function processDailySync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||||
|
const ahora = new Date();
|
||||||
|
const inicioMes = new Date(ahora.getFullYear(), ahora.getMonth(), 1);
|
||||||
|
|
||||||
|
let totalFound = 0;
|
||||||
|
let totalDownloaded = 0;
|
||||||
|
let totalInserted = 0;
|
||||||
|
let totalUpdated = 0;
|
||||||
|
|
||||||
|
// Procesar emitidos del mes
|
||||||
|
try {
|
||||||
|
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
|
||||||
|
totalFound += emitidos.found;
|
||||||
|
totalDownloaded += emitidos.downloaded;
|
||||||
|
totalInserted += emitidos.inserted;
|
||||||
|
totalUpdated += emitidos.updated;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT] Error procesando emitidos:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Procesar recibidos del mes
|
||||||
|
try {
|
||||||
|
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
|
||||||
|
totalFound += recibidos.found;
|
||||||
|
totalDownloaded += recibidos.downloaded;
|
||||||
|
totalInserted += recibidos.inserted;
|
||||||
|
totalUpdated += recibidos.updated;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT] Error procesando recibidos:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateJobProgress(jobId, {
|
||||||
|
cfdisFound: totalFound,
|
||||||
|
cfdisDownloaded: totalDownloaded,
|
||||||
|
cfdisInserted: totalInserted,
|
||||||
|
cfdisUpdated: totalUpdated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicia la sincronización con el SAT
|
||||||
|
*/
|
||||||
|
export async function startSync(
|
||||||
|
tenantId: string,
|
||||||
|
type: SatSyncType = 'daily',
|
||||||
|
dateFrom?: Date,
|
||||||
|
dateTo?: Date
|
||||||
|
): Promise<string> {
|
||||||
|
// Obtener credenciales FIEL
|
||||||
|
const fielData = await getDecryptedFiel(tenantId);
|
||||||
|
if (!fielData) {
|
||||||
|
throw new Error('No hay FIEL configurada o está vencida');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener datos del tenant
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { schemaName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
throw new Error('Tenant no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que no haya sync activo
|
||||||
|
const activeSync = await prisma.satSyncJob.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
status: { in: ['pending', 'running'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeSync) {
|
||||||
|
throw new Error('Ya hay una sincronización en curso');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear job
|
||||||
|
const now = new Date();
|
||||||
|
const job = await prisma.satSyncJob.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
type,
|
||||||
|
status: 'running',
|
||||||
|
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
|
||||||
|
dateTo: dateTo || now,
|
||||||
|
startedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Autenticar con SAT
|
||||||
|
const tokenData = await authenticate(fielData.credential);
|
||||||
|
|
||||||
|
const ctx: SyncContext = {
|
||||||
|
credential: fielData.credential,
|
||||||
|
token: tokenData.token,
|
||||||
|
tokenExpiresAt: tokenData.expiresAt,
|
||||||
|
rfc: fielData.rfc,
|
||||||
|
tenantId,
|
||||||
|
schemaName: tenant.schemaName,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ejecutar sincronización en background
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (type === 'initial') {
|
||||||
|
await processInitialSync(ctx, job.id);
|
||||||
|
} else {
|
||||||
|
await processDailySync(ctx, job.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateJobProgress(job.id, {
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: new Date(),
|
||||||
|
progressPercent: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[SAT] Sincronización ${job.id} completada`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
|
||||||
|
await updateJobProgress(job.id, {
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: error.message,
|
||||||
|
completedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return job.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado actual de sincronización de un tenant
|
||||||
|
*/
|
||||||
|
export async function getSyncStatus(tenantId: string): Promise<{
|
||||||
|
hasActiveSync: boolean;
|
||||||
|
currentJob?: SatSyncJob;
|
||||||
|
lastCompletedJob?: SatSyncJob;
|
||||||
|
totalCfdisSynced: number;
|
||||||
|
}> {
|
||||||
|
const activeJob = await prisma.satSyncJob.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
status: { in: ['pending', 'running'] },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastCompleted = await prisma.satSyncJob.findFirst({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
orderBy: { completedAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totals = await prisma.satSyncJob.aggregate({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
cfdisInserted: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapJob = (job: any): SatSyncJob => ({
|
||||||
|
id: job.id,
|
||||||
|
tenantId: job.tenantId,
|
||||||
|
type: job.type,
|
||||||
|
status: job.status,
|
||||||
|
dateFrom: job.dateFrom.toISOString(),
|
||||||
|
dateTo: job.dateTo.toISOString(),
|
||||||
|
cfdiType: job.cfdiType ?? undefined,
|
||||||
|
satRequestId: job.satRequestId ?? undefined,
|
||||||
|
satPackageIds: job.satPackageIds,
|
||||||
|
cfdisFound: job.cfdisFound,
|
||||||
|
cfdisDownloaded: job.cfdisDownloaded,
|
||||||
|
cfdisInserted: job.cfdisInserted,
|
||||||
|
cfdisUpdated: job.cfdisUpdated,
|
||||||
|
progressPercent: job.progressPercent,
|
||||||
|
errorMessage: job.errorMessage ?? undefined,
|
||||||
|
startedAt: job.startedAt?.toISOString(),
|
||||||
|
completedAt: job.completedAt?.toISOString(),
|
||||||
|
createdAt: job.createdAt.toISOString(),
|
||||||
|
retryCount: job.retryCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasActiveSync: !!activeJob,
|
||||||
|
currentJob: activeJob ? mapJob(activeJob) : undefined,
|
||||||
|
lastCompletedJob: lastCompleted ? mapJob(lastCompleted) : undefined,
|
||||||
|
totalCfdisSynced: totals._sum.cfdisInserted || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el historial de sincronizaciones
|
||||||
|
*/
|
||||||
|
export async function getSyncHistory(
|
||||||
|
tenantId: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<{ jobs: SatSyncJob[]; total: number }> {
|
||||||
|
const [jobs, total] = await Promise.all([
|
||||||
|
prisma.satSyncJob.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
prisma.satSyncJob.count({ where: { tenantId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs: jobs.map(job => ({
|
||||||
|
id: job.id,
|
||||||
|
tenantId: job.tenantId,
|
||||||
|
type: job.type,
|
||||||
|
status: job.status,
|
||||||
|
dateFrom: job.dateFrom.toISOString(),
|
||||||
|
dateTo: job.dateTo.toISOString(),
|
||||||
|
cfdiType: job.cfdiType ?? undefined,
|
||||||
|
satRequestId: job.satRequestId ?? undefined,
|
||||||
|
satPackageIds: job.satPackageIds,
|
||||||
|
cfdisFound: job.cfdisFound,
|
||||||
|
cfdisDownloaded: job.cfdisDownloaded,
|
||||||
|
cfdisInserted: job.cfdisInserted,
|
||||||
|
cfdisUpdated: job.cfdisUpdated,
|
||||||
|
progressPercent: job.progressPercent,
|
||||||
|
errorMessage: job.errorMessage ?? undefined,
|
||||||
|
startedAt: job.startedAt?.toISOString(),
|
||||||
|
completedAt: job.completedAt?.toISOString(),
|
||||||
|
createdAt: job.createdAt.toISOString(),
|
||||||
|
retryCount: job.retryCount,
|
||||||
|
})),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reintenta un job fallido
|
||||||
|
*/
|
||||||
|
export async function retryJob(jobId: string): Promise<string> {
|
||||||
|
const job = await prisma.satSyncJob.findUnique({
|
||||||
|
where: { id: jobId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
throw new Error('Job no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.status !== 'failed') {
|
||||||
|
throw new Error('Solo se pueden reintentar jobs fallidos');
|
||||||
|
}
|
||||||
|
|
||||||
|
return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user