feat: mejoras SAT, auth, web y migración
SAT: - Cron diario 6-10 AM con grupos de tenants (~20% por hora). - Retries fijos de daily sync a 9 AM y 4 PM CDMX. - Filtrado de contribuyentes con FIEL vigente en cron. - Timeout de 5 min en cliente HTTP del SAT. - Manejo defensivo de 404, EmptyResult (5004) y solicitudes agotadas. - Fechas formateadas en zona horaria America/Mexico_City. - Patch a @nodecfdi/sat-ws-descarga-masiva para evitar getResponse crash. - Sweep de jobs stale con thresholds ajustados. Auth/Web: - Primer pago de suscripción define periodo activo en webhook. - Rate limit de login: 25 intentos / 15 min. - Recuperación de contraseña: 24h de validez. - Soporte viewingTenantId en contribuyentes. - Timeout y estado de carga al crear organización en Facturapi. - Filtros por cliente y cartera en Mis Asignados. - Orden alfabético en selector de contribuyente. DB: - Migración 057: unique index de declaraciones incluye impuestos.
This commit is contained in:
@@ -38,10 +38,14 @@ const createSchema = z.object({
|
|||||||
|
|
||||||
const updateSchema = createSchema.partial();
|
const updateSchema = createSchema.partial();
|
||||||
|
|
||||||
|
function effectiveTenantId(req: Request): string {
|
||||||
|
return req.viewingTenantId || req.user!.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
|
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, effectiveTenantId(req));
|
||||||
|
|
||||||
// Batch lookup de nombres de supervisores
|
// Batch lookup de nombres de supervisores
|
||||||
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
||||||
@@ -65,7 +69,7 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
|||||||
|
|
||||||
export async function getById(req: Request, res: Response, next: NextFunction) {
|
export async function getById(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
|
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), effectiveTenantId(req));
|
||||||
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
|
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||||
return res.json(row);
|
return res.json(row);
|
||||||
} catch (err) { return next(err); }
|
} catch (err) { return next(err); }
|
||||||
|
|||||||
@@ -253,12 +253,19 @@ async function handlePaymentNotification(paymentId: string) {
|
|||||||
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
||||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||||
const esPrimerPago = subscription.status === 'pending';
|
const esPrimerPago = subscription.status === 'pending';
|
||||||
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
|
const updateData: { status: string; currentPeriodStart?: Date; currentPeriodEnd?: Date } = { status: 'authorized' };
|
||||||
|
|
||||||
|
if (esPrimerPago) {
|
||||||
|
// El primer pago aprobado define el inicio del período activo.
|
||||||
|
// Algunos flujos (cambio de plan, creación manual) dejan currentPeriodEnd
|
||||||
|
// en null, así que lo establecemos aquí para evitar que la suscripción
|
||||||
|
// aparezca vencida aunque esté authorized.
|
||||||
|
const periodStart = payment.dateApproved ? new Date(payment.dateApproved) : new Date();
|
||||||
|
updateData.currentPeriodStart = periodStart;
|
||||||
|
updateData.currentPeriodEnd = computeNextPeriodEnd(periodStart, subscription.frequency);
|
||||||
|
console.log(`[WEBHOOK] Subscription ${subscription.id} primer pago aprobado: período ${updateData.currentPeriodStart.toISOString()} → ${updateData.currentPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||||
|
} else if (subscription.currentPeriodEnd) {
|
||||||
// Extender currentPeriodEnd para renovaciones recurrentes.
|
// Extender currentPeriodEnd para renovaciones recurrentes.
|
||||||
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
|
|
||||||
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
|
|
||||||
if (!esPrimerPago && subscription.currentPeriodEnd) {
|
|
||||||
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
||||||
updateData.currentPeriodEnd = nextPeriodEnd;
|
updateData.currentPeriodEnd = nextPeriodEnd;
|
||||||
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
|
import { startSync, getSyncStatus, retryTimedOutJobs, continuePendingDailyRequests } from '../services/sat/sat.service.js';
|
||||||
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
||||||
import { hasFielConfigured } from '../services/fiel.service.js';
|
import { hasFielConfigured } from '../services/fiel.service.js';
|
||||||
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
|
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
|
||||||
@@ -11,18 +11,21 @@ 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 3 * * *'; // 3:00 AM todos los días
|
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 RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM 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 CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||||
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 (03:00) cubre
|
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 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)
|
||||||
|
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let isIncrementalRunning = false;
|
let isIncrementalRunning = false;
|
||||||
let isRecoveryRunning = false;
|
let isRecoveryRunning = false;
|
||||||
|
let isDailyRetryRunning = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
||||||
@@ -46,7 +49,7 @@ async function hasAnyFielConfigured(tenantId: string, databaseName?: string | nu
|
|||||||
try {
|
try {
|
||||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
|
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true AND valid_until > NOW() LIMIT 1`
|
||||||
);
|
);
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -94,6 +97,41 @@ async function needsInitialSync(tenantId: string, contribuyenteId?: string): Pro
|
|||||||
return !completedSync;
|
return !completedSync;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve los entidad_id de contribuyentes con FIEL vigente.
|
||||||
|
* Si el tenant tiene FIEL legacy vigente a nivel tenant, devuelve todos
|
||||||
|
* (startSync hace fallback por RFC). `total` permite distinguir "tenant sin
|
||||||
|
* contribuyentes" (path legacy) de "ninguno con FIEL vigente" (se omite).
|
||||||
|
*/
|
||||||
|
async function getContribuyentesParaSync(
|
||||||
|
tenantId: string,
|
||||||
|
databaseName: string,
|
||||||
|
logPrefix: string
|
||||||
|
): Promise<{ ids: string[]; total: number }> {
|
||||||
|
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||||
|
const { rows: allRows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||||
|
const allIds: string[] = allRows.map((r: any) => r.entidad_id);
|
||||||
|
if (allIds.length === 0) return { ids: [], total: 0 };
|
||||||
|
|
||||||
|
const hasLegacyFiel = await hasFielConfigured(tenantId);
|
||||||
|
if (hasLegacyFiel) return { ids: allIds, total: allIds.length };
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT c.entidad_id FROM contribuyentes c
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM fiel_contribuyente f
|
||||||
|
WHERE f.contribuyente_id = c.entidad_id
|
||||||
|
AND f.is_active = true AND f.valid_until > NOW()
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
const ids: string[] = rows.map((r: any) => r.entidad_id);
|
||||||
|
const skipped = allIds.length - ids.length;
|
||||||
|
if (skipped > 0) {
|
||||||
|
console.log(`${logPrefix} Tenant ${tenantId}: ${skipped} contribuyente(s) sin FIEL vigente, omitidos`);
|
||||||
|
}
|
||||||
|
return { ids, total: allIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ejecuta sincronización para un tenant y sus contribuyentes
|
* Ejecuta sincronización para un tenant y sus contribuyentes
|
||||||
*/
|
*/
|
||||||
@@ -107,9 +145,12 @@ async function syncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
let contribuyenteIds: string[] = [];
|
let contribuyenteIds: string[] = [];
|
||||||
if (tenant?.databaseName) {
|
if (tenant?.databaseName) {
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron]');
|
||||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
if (total > 0 && ids.length === 0) {
|
||||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
console.log(`[SAT Cron] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contribuyenteIds = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
||||||
@@ -153,6 +194,27 @@ 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_WINDOW_START = 6; // primera ventana CDMX
|
||||||
|
|
||||||
|
/** Hash estable del tenantId → grupo 0..DAILY_GROUPS-1 (reparte ~20% por ventana) */
|
||||||
|
function tenantGroup(tenantId: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < tenantId.length; i++) h = (h * 31 + tenantId.charCodeAt(i)) >>> 0;
|
||||||
|
return h % DAILY_GROUPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hora actual en zona America/Mexico_City (0-23) */
|
||||||
|
function cdmxHour(): number {
|
||||||
|
return Number(
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'America/Mexico_City',
|
||||||
|
hour: 'numeric',
|
||||||
|
hour12: false,
|
||||||
|
}).format(new Date())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function runSyncJob(): Promise<void> {
|
async function runSyncJob(): Promise<void> {
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
|
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
|
||||||
@@ -171,13 +233,27 @@ async function runSyncJob(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hour = cdmxHour();
|
||||||
|
const groupIndex = hour - DAILY_WINDOW_START; // 6→0 … 10→4
|
||||||
|
if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
|
||||||
|
console.log(`[SAT Cron] Hora CDMX ${hour} fuera de ventana 6-10 AM, omitiendo`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupTenants = tenantIds.filter(id => tenantGroup(id) === groupIndex);
|
||||||
|
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — grupo ${groupIndex + 1}/${DAILY_GROUPS}: ${groupTenants.length}/${tenantIds.length} tenants`);
|
||||||
|
|
||||||
|
if (groupTenants.length === 0) {
|
||||||
|
console.log('[SAT Cron] No hay tenants en este grupo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Procesar en lotes para no saturar
|
// Procesar en lotes para no saturar
|
||||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
for (let i = 0; i < groupTenants.length; i += CONCURRENT_SYNCS) {
|
||||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
const batch = groupTenants.slice(i, i + CONCURRENT_SYNCS);
|
||||||
await Promise.all(batch.map(syncTenant));
|
await Promise.all(batch.map(syncTenant));
|
||||||
|
|
||||||
// Pequeña pausa entre lotes
|
// Pequeña pausa entre lotes
|
||||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
if (i + CONCURRENT_SYNCS < groupTenants.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,9 +308,12 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
|||||||
|
|
||||||
let contribuyenteIds: string[] = [];
|
let contribuyenteIds: string[] = [];
|
||||||
if (tenant?.databaseName) {
|
if (tenant?.databaseName) {
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron Inc]');
|
||||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
if (total > 0 && ids.length === 0) {
|
||||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
console.log(`[SAT Cron Inc] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contribuyenteIds = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
||||||
@@ -529,9 +608,30 @@ export async function runRecoverySyncJob(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runDailyRetryJob(): Promise<void> {
|
||||||
|
if (isDailyRetryRunning) {
|
||||||
|
console.log('[SAT Daily Retry] Ya en ejecución, omitiendo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDailyRetryRunning = true;
|
||||||
|
console.log('[SAT Daily Retry] Iniciando retry programado de daily syncs');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await continuePendingDailyRequests();
|
||||||
|
console.log('[SAT Daily Retry] Retry programado completado');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Daily Retry] Error:', error.message);
|
||||||
|
} finally {
|
||||||
|
isDailyRetryRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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 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;
|
||||||
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
||||||
@@ -585,6 +685,28 @@ export function startSatSyncJob(): void {
|
|||||||
timezone: 'America/Mexico_City',
|
timezone: 'America/Mexico_City',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
await runDailyRetryJob();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[SAT Daily Retry 4PM] Error:', error.message);
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timezone: 'America/Mexico_City',
|
||||||
|
});
|
||||||
|
|
||||||
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
||||||
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
||||||
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
||||||
@@ -691,6 +813,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(`[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)`);
|
||||||
@@ -714,6 +837,14 @@ export function stopSatSyncJob(): void {
|
|||||||
recoveryTask.stop();
|
recoveryTask.stop();
|
||||||
recoveryTask = null;
|
recoveryTask = null;
|
||||||
}
|
}
|
||||||
|
if (retry9amTask) {
|
||||||
|
retry9amTask.stop();
|
||||||
|
retry9amTask = null;
|
||||||
|
}
|
||||||
|
if (retry4pmTask) {
|
||||||
|
retry4pmTask.stop();
|
||||||
|
retry4pmTask = null;
|
||||||
|
}
|
||||||
if (opinionTask) {
|
if (opinionTask) {
|
||||||
opinionTask.stop();
|
opinionTask.stop();
|
||||||
opinionTask = null;
|
opinionTask = null;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Fix: la constraint unique de declaraciones normales solo consideraba
|
||||||
|
-- (año, mes, contribuyente_id). Esto impedía subir una declaración normal de
|
||||||
|
-- ISRTP si ya existía una normal de ISN para el mismo mes y contribuyente.
|
||||||
|
-- Ahora la unicidad se valida por (año, mes, contribuyente_id, impuestos),
|
||||||
|
-- permitiendo una declaración normal distinta por cada conjunto de impuestos.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS uniq_declaracion_normal_mes_contrib;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes_contrib_impuestos
|
||||||
|
ON declaraciones_provisionales(año, mes, contribuyente_id, impuestos)
|
||||||
|
WHERE tipo = 'normal';
|
||||||
|
|
||||||
|
INSERT INTO tenant_migrations (scope, version, name)
|
||||||
|
VALUES ('vertical-contable', 57, '057_declaraciones_unique_por_impuestos')
|
||||||
|
ON CONFLICT (scope, version) DO NOTHING;
|
||||||
@@ -6,10 +6,10 @@ import { strictLimit } from '../middlewares/rate-limit.middleware.js';
|
|||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
// Rate limiting: 10 login attempts per 15 minutes per IP
|
// Rate limiting: 25 login attempts per 15 minutes per IP
|
||||||
const loginLimiter = rateLimit({
|
const loginLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10,
|
max: 25,
|
||||||
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
|
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ export async function logout(token: string): Promise<void> {
|
|||||||
// Password reset
|
// Password reset
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora
|
const PASSWORD_RESET_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 horas
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
|
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
|
||||||
|
|||||||
@@ -17,6 +17,23 @@ export interface FielData {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout explícito para el cliente HTTP del SAT (ms).
|
||||||
|
*
|
||||||
|
* IMPORTANTE: la librería @nodecfdi/sat-ws-descarga-masiva@2.0.0 tiene un bug
|
||||||
|
* en HttpsWebClient: si no se pasa un timeout explícito y ocurre un timeout
|
||||||
|
* de red, rechaza con un `Error` nativo en vez de `WebClientException`.
|
||||||
|
* Eso rompe el manejo de errores posterior y produce
|
||||||
|
* `webError.getResponse is not a function`.
|
||||||
|
*
|
||||||
|
* Al pasar un timeout explícito, `_timeout` queda definido y la librería
|
||||||
|
* envuelve el timeout como `WebClientException`, permitiendo reintentos sanos.
|
||||||
|
*
|
||||||
|
* El endpoint de verificación del SAT suele tardar >30s en responder; 5 minutos
|
||||||
|
* da margen sin dejar la conexión colgada indefinidamente.
|
||||||
|
*/
|
||||||
|
const SAT_WEB_CLIENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutos
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
|
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
|
||||||
*/
|
*/
|
||||||
@@ -29,8 +46,13 @@ export function createSatService(fielData: FielData): Service {
|
|||||||
throw new Error('La FIEL no es válida o está vencida');
|
throw new Error('La FIEL no es válida o está vencida');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear cliente HTTP
|
// Crear cliente HTTP con timeout explícito para evitar el bug de la librería
|
||||||
const webClient = new HttpsWebClient();
|
// cuando ocurre un timeout de red.
|
||||||
|
const webClient = new (HttpsWebClient as any)(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
SAT_WEB_CLIENT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
// Crear request builder con la FIEL
|
// Crear request builder con la FIEL
|
||||||
const requestBuilder = new FielRequestBuilder(fiel);
|
const requestBuilder = new FielRequestBuilder(fiel);
|
||||||
@@ -73,10 +95,13 @@ export async function querySat(
|
|||||||
): Promise<QueryResult> {
|
): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
||||||
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
|
// a medianoche en zona horaria de México, dos fechas dentro del mismo día
|
||||||
// Ajustamos fechaFin al día siguiente para evitar el error.
|
// calendario mexicano resultan iguales. Ajustamos fechaFin al día siguiente
|
||||||
|
// en hora México para evitar el error.
|
||||||
let adjustedFechaFin = fechaFin;
|
let adjustedFechaFin = fechaFin;
|
||||||
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
|
if (isSameMexicoDay(fechaInicio, fechaFin)) {
|
||||||
|
// Sumar 24h en ms es suficiente porque formatDateForSat solo usa la fecha
|
||||||
|
// calendaria de México, no la hora.
|
||||||
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +135,30 @@ export async function querySat(
|
|||||||
statusCode: result.getStatus().getCode().toString(),
|
statusCode: result.getStatus().getCode().toString(),
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[SAT Query Error]', error);
|
// Errores tipo "EmptyResult (5004)" o "Se han agotado las solicitudes de por vida"
|
||||||
|
// a veces vienen como excepción en vez de resultado aceptado. Los traducimos para
|
||||||
|
// que el llamador los trate como "sin datos / no hay nada más que hacer" en lugar
|
||||||
|
// de error fatal.
|
||||||
|
const raw = error?.message || String(error);
|
||||||
|
const emptyMatch = raw.match(/EmptyResult\s*\(?\s*(5004)\s*\)?/i) || raw.includes('5004');
|
||||||
|
if (emptyMatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'No se encontró la información',
|
||||||
|
statusCode: '5004',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exhaustedMatch = raw.includes('Se han agotado las solicitudes de por vida');
|
||||||
|
if (exhaustedMatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Se han agotado las solicitudes de por vida para este rango',
|
||||||
|
statusCode: 'exhausted',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[SAT Query Error]', error?.message, error?.stack || error);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || 'Error al realizar consulta',
|
message: error.message || 'Error al realizar consulta',
|
||||||
@@ -174,6 +222,7 @@ export async function verifySatRequest(
|
|||||||
if (entryId === 'Finished') status = 'ready';
|
if (entryId === 'Finished') status = 'ready';
|
||||||
else if (entryId === 'InProgress') status = 'processing';
|
else if (entryId === 'InProgress') status = 'processing';
|
||||||
else if (entryId === 'Accepted') status = 'pending';
|
else if (entryId === 'Accepted') status = 'pending';
|
||||||
|
else if (entryId === 'Unknown' && result.getStatus().getCode().toString() === '404') status = 'failed';
|
||||||
else status = 'pending';
|
else status = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +232,38 @@ export async function verifySatRequest(
|
|||||||
const statusMsg = result.getStatus().getMessage();
|
const statusMsg = result.getStatus().getMessage();
|
||||||
const reqValue = statusRequest.getValue();
|
const reqValue = statusRequest.getValue();
|
||||||
const reqEntry = statusRequest.getEntryId();
|
const reqEntry = statusRequest.getEntryId();
|
||||||
|
|
||||||
|
// EmptyResult (5004) o Exhausted (5002, "solicitudes de por vida"): el SAT
|
||||||
|
// aceptó la solicitud pero no generó paquetes (rango sin info) o ya agotamos
|
||||||
|
// las solicitudes de ese rango. Tratarlos como "ready" con 0 paquetes para
|
||||||
|
// NO fallar la etapa ni quemar reintentos — es un resultado benigno.
|
||||||
|
// Se comparan value/entry/mensaje de forma defensiva porque getValue() puede
|
||||||
|
// venir como number o string según la versión de la librería.
|
||||||
|
const codeValueStr = codeRequestValue != null ? String(codeRequestValue) : '';
|
||||||
|
const codeEntryStr = codeRequestEntry != null ? String(codeRequestEntry) : '';
|
||||||
|
const codeMsgStr = codeRequestMessage != null ? String(codeRequestMessage) : '';
|
||||||
|
const isEmptyResult =
|
||||||
|
codeValueStr === '5004' ||
|
||||||
|
codeEntryStr === '5004' ||
|
||||||
|
/EmptyResult/i.test(codeEntryStr) ||
|
||||||
|
/\b5004\b/.test(codeMsgStr);
|
||||||
|
const isExhausted =
|
||||||
|
codeValueStr === '5002' ||
|
||||||
|
/Exhausted/i.test(codeEntryStr) ||
|
||||||
|
/solicitudes de por vida/i.test(codeMsgStr);
|
||||||
|
if (isEmptyResult || isExhausted) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
status: 'ready',
|
||||||
|
packageIds: [],
|
||||||
|
totalCfdis: 0,
|
||||||
|
message: isExhausted
|
||||||
|
? 'Se han agotado las solicitudes de por vida para este rango'
|
||||||
|
: 'No se encontró información para el rango solicitado',
|
||||||
|
statusCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
let message = statusMsg;
|
let message = statusMsg;
|
||||||
if (status === 'rejected' || status === 'failed') {
|
if (status === 'rejected' || status === 'failed') {
|
||||||
const codeReqStr = codeRequestValue
|
const codeReqStr = codeRequestValue
|
||||||
@@ -200,7 +281,7 @@ export async function verifySatRequest(
|
|||||||
statusCode,
|
statusCode,
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[SAT Verify Error]', error.message || error);
|
console.error('[SAT Verify Error]', error?.message, error?.stack || error);
|
||||||
// Errores de la librería (ej. webError.getResponse is not a function)
|
// Errores de la librería (ej. webError.getResponse is not a function)
|
||||||
// no son fallos del SAT — devolver 'pending' para reintentar polling
|
// no son fallos del SAT — devolver 'pending' para reintentar polling
|
||||||
return {
|
return {
|
||||||
@@ -250,8 +331,34 @@ export async function downloadSatPackage(
|
|||||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
|
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
|
||||||
* El SAT requiere hora 00:00:00; cualquier otra hora causa
|
* El SAT requiere hora 00:00:00; cualquier otra hora causa
|
||||||
* "Fecha final invalida" / "Fecha inicial invalida".
|
* "Fecha final invalida" / "Fecha inicial invalida".
|
||||||
|
*
|
||||||
|
* IMPORTANTE: las fechas deben interpretarse en la zona horaria de México
|
||||||
|
* (America/Mexico_City) porque el SAT opera en esa zona. El servidor corre
|
||||||
|
* en UTC, así que usamos Intl.DateTimeFormat para obtener los componentes
|
||||||
|
* locales a México.
|
||||||
*/
|
*/
|
||||||
function formatDateForSat(date: Date): string {
|
function formatDateForSat(date: Date): string {
|
||||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
const fmt = new Intl.DateTimeFormat('es-MX', {
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
timeZone: 'America/Mexico_City',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const parts = fmt.formatToParts(date);
|
||||||
|
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')} 00:00:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve true si dos fechas (interpretadas en zona horaria de México)
|
||||||
|
* caen en el mismo día calendario.
|
||||||
|
*/
|
||||||
|
function isSameMexicoDay(a: Date, b: Date): boolean {
|
||||||
|
const fmt = new Intl.DateTimeFormat('es-MX', {
|
||||||
|
timeZone: 'America/Mexico_City',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
return fmt.format(a) === fmt.format(b);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export interface SweepResult {
|
|||||||
|
|
||||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||||
initial: 24,
|
initial: 24,
|
||||||
daily: 4,
|
daily: 8,
|
||||||
incremental: 2,
|
incremental: 2,
|
||||||
custom: 24,
|
custom: 24,
|
||||||
};
|
};
|
||||||
@@ -38,8 +38,8 @@ const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
|||||||
* (volver a correrlo no reabre los ya-marcados-failed).
|
* (volver a correrlo no reabre los ya-marcados-failed).
|
||||||
*
|
*
|
||||||
* - `apply=false` (default): dry-run, no toca BD.
|
* - `apply=false` (default): dry-run, no toca BD.
|
||||||
* - `pendingHours`: threshold pending (default 12h).
|
* - `pendingHours`: threshold pending (default 24h).
|
||||||
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
|
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 8h).
|
||||||
* - `runningHoursByType`: override por tipo de sync.
|
* - `runningHoursByType`: override por tipo de sync.
|
||||||
*/
|
*/
|
||||||
export async function sweepStaleSatJobs(params: {
|
export async function sweepStaleSatJobs(params: {
|
||||||
@@ -48,7 +48,7 @@ export async function sweepStaleSatJobs(params: {
|
|||||||
runningHours?: number;
|
runningHours?: number;
|
||||||
runningHoursByType?: Record<string, number>;
|
runningHoursByType?: Record<string, number>;
|
||||||
} = { apply: false }): Promise<SweepResult> {
|
} = { apply: false }): Promise<SweepResult> {
|
||||||
const pendingHours = params.pendingHours ?? 12;
|
const pendingHours = params.pendingHours ?? 24;
|
||||||
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export default function CsdConfigPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [creatingOrg, setCreatingOrg] = useState(false);
|
||||||
const [cerFile, setCerFile] = useState<string>('');
|
const [cerFile, setCerFile] = useState<string>('');
|
||||||
const [keyFile, setKeyFile] = useState<string>('');
|
const [keyFile, setKeyFile] = useState<string>('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -178,16 +179,28 @@ export default function CsdConfigPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateOrg = async () => {
|
const handleCreateOrg = async () => {
|
||||||
|
if (creatingOrg) return;
|
||||||
|
setCreatingOrg(true);
|
||||||
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
|
const cfg = { timeout: 60000 };
|
||||||
if (selectedContribuyenteId) {
|
if (selectedContribuyenteId) {
|
||||||
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`);
|
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`, undefined, cfg);
|
||||||
} else {
|
} else {
|
||||||
await apiClient.post('/facturacion/org');
|
await apiClient.post('/facturacion/org', undefined, cfg);
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
|
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
|
||||||
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
|
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al crear organización' });
|
const isTimeout = err?.code === 'ECONNABORTED';
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: isTimeout
|
||||||
|
? 'La creación está tardando más de lo esperado. Refresca la página en unos segundos; si no aparece, intenta de nuevo.'
|
||||||
|
: (err.response?.data?.message || 'Error al crear organización'),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreatingOrg(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -268,7 +281,9 @@ export default function CsdConfigPage() {
|
|||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No hay organización configurada para este tenant.
|
No hay organización configurada para este tenant.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={handleCreateOrg}>Crear Organización</Button>
|
<Button onClick={handleCreateOrg} disabled={creatingOrg}>
|
||||||
|
{creatingOrg ? 'Creando… (puede tardar unos segundos)' : 'Crear Organización'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Card, CardContent } from '@horux/shared-ui';
|
import { Card, CardContent } from '@horux/shared-ui';
|
||||||
@@ -10,7 +11,7 @@ import { apiClient } from '@/lib/api/client';
|
|||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||||
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
||||||
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2, Search, FolderOpen, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
interface Asignado {
|
interface Asignado {
|
||||||
contribuyenteId: string;
|
contribuyenteId: string;
|
||||||
@@ -29,6 +30,9 @@ const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar', 'cont
|
|||||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||||
|
|
||||||
export default function MisAsignadosPage() {
|
export default function MisAsignadosPage() {
|
||||||
|
const [filtroCliente, setFiltroCliente] = useState('');
|
||||||
|
const [filtroCartera, setFiltroCartera] = useState('');
|
||||||
|
|
||||||
const role = useAuthStore(s => s.user?.role);
|
const role = useAuthStore(s => s.user?.role);
|
||||||
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
||||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||||
@@ -64,6 +68,19 @@ export default function MisAsignadosPage() {
|
|||||||
|
|
||||||
const items = data ?? [];
|
const items = data ?? [];
|
||||||
|
|
||||||
|
const carterasUnicas = Array.from(new Set(items.map(it => it.carteraNombre || 'Sin cartera'))).sort((a, b) =>
|
||||||
|
a.localeCompare(b, 'es', { sensitivity: 'base' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const itemsFiltrados = items.filter((it) => {
|
||||||
|
const coincideCliente = [it.nombre, it.rfc].some(v =>
|
||||||
|
v.toLowerCase().includes(filtroCliente.trim().toLowerCase())
|
||||||
|
);
|
||||||
|
const coincideCartera =
|
||||||
|
filtroCartera === '' || (filtroCartera === '__sin_cartera__' ? !it.carteraNombre : it.carteraNombre === filtroCartera);
|
||||||
|
return coincideCliente && coincideCartera;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
|
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
|
||||||
@@ -84,6 +101,39 @@ export default function MisAsignadosPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b bg-muted/30">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filtroCliente}
|
||||||
|
onChange={(e) => setFiltroCliente(e.target.value)}
|
||||||
|
placeholder="Buscar por cliente o RFC..."
|
||||||
|
className="w-full rounded-md border border-input bg-background pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative sm:w-64">
|
||||||
|
<FolderOpen className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<select
|
||||||
|
value={filtroCartera}
|
||||||
|
onChange={(e) => setFiltroCartera(e.target.value)}
|
||||||
|
className="w-full appearance-none rounded-md border border-input bg-background pl-9 pr-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Todas las carteras</option>
|
||||||
|
<option value="__sin_cartera__">Sin cartera</option>
|
||||||
|
{carterasUnicas.filter(c => c !== 'Sin cartera').map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{itemsFiltrados.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||||
|
No hay resultados para los filtros seleccionados.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="border-b bg-muted/50">
|
<thead className="border-b bg-muted/50">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -102,7 +152,7 @@ export default function MisAsignadosPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map(it => {
|
{itemsFiltrados.map(it => {
|
||||||
const total =
|
const total =
|
||||||
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
|
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
|
||||||
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
|
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
|
||||||
@@ -193,6 +243,7 @@ export default function MisAsignadosPage() {
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ export function ContribuyenteSelector() {
|
|||||||
|
|
||||||
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
|
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
|
||||||
|
|
||||||
|
// Orden alfabético por nombre (locale español, sin distinguir acentos/mayúsculas)
|
||||||
|
const contribuyentesOrdenados = [...contribuyentes].sort((a, b) =>
|
||||||
|
a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base', numeric: true })
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="contribuyente-selector relative">
|
<div className="contribuyente-selector relative">
|
||||||
<button
|
<button
|
||||||
@@ -91,7 +96,7 @@ export function ContribuyenteSelector() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lista de contribuyentes */}
|
{/* Lista de contribuyentes */}
|
||||||
{contribuyentes.map((c) => (
|
{contribuyentesOrdenados.map((c) => (
|
||||||
<button
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}
|
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}
|
||||||
|
|||||||
@@ -22,5 +22,10 @@
|
|||||||
"packageManager": "pnpm@9.0.0",
|
"packageManager": "pnpm@9.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"patchedDependencies": {
|
||||||
|
"@nodecfdi/sat-ws-descarga-masiva@2.0.0": "patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
Normal file
19
patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
diff --git a/build/index.js b/build/index.js
|
||||||
|
index bf7a6aafce966c4ab44ba3abb240578cc68779d6..4678df8734b098f20c7e14d8593bd4328fe759d8 100644
|
||||||
|
--- a/build/index.js
|
||||||
|
+++ b/build/index.js
|
||||||
|
@@ -266,7 +266,13 @@ var ServiceConsumer = class _ServiceConsumer {
|
||||||
|
} catch (error) {
|
||||||
|
const webError = error;
|
||||||
|
exception = webError;
|
||||||
|
- response = webError.getResponse();
|
||||||
|
+ if (typeof webError.getResponse !== "function") {
|
||||||
|
+ console.error("[SAT Library] Error no-WebClientException capturado en ServiceConsumer.execute:", error);
|
||||||
|
+ const fallbackResponse = new CResponse(0, String(error && error.message ? error.message : error), {});
|
||||||
|
+ response = fallbackResponse;
|
||||||
|
+ } else {
|
||||||
|
+ response = webError.getResponse();
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
this.checkErrors(request, response, exception);
|
||||||
|
return response.getBody();
|
||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -4,6 +4,11 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
|
||||||
|
hash: n2q5glw3wdhkcidljfdzrkmxnq
|
||||||
|
path: patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
@@ -34,7 +39,7 @@ importers:
|
|||||||
version: 3.2.0(luxon@3.7.2)
|
version: 3.2.0(luxon@3.7.2)
|
||||||
'@nodecfdi/sat-ws-descarga-masiva':
|
'@nodecfdi/sat-ws-descarga-masiva':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
version: 2.0.0(patch_hash=n2q5glw3wdhkcidljfdzrkmxnq)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
@@ -3108,7 +3113,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
luxon: 3.7.2
|
luxon: 3.7.2
|
||||||
|
|
||||||
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
|
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(patch_hash=n2q5glw3wdhkcidljfdzrkmxnq)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodecfdi/cfdi-core': 1.0.1
|
'@nodecfdi/cfdi-core': 1.0.1
|
||||||
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
|
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user