docs: actualiza CAMBIOS-2026-05-04.md y commitea fixes pendientes

- Monitor de sincronización SAT (sat-sync-monitor.job + alerta por correo).
- Scraper de CSF más robusto (iframes, blobs, popups, validación PDF).
- Reactivación de contribuyentes desactivados y limpieza al desactivar.
- Timeout de constancia aumentado a 5 min.
- Variables de entorno SAT en .env.example y env.ts.
This commit is contained in:
Horux Dev
2026-06-25 02:09:58 +00:00
parent a1727321c3
commit 1130a1833c
14 changed files with 1108 additions and 95 deletions

View File

@@ -11,7 +11,7 @@ import { loginSatCsf } from './sat/sat-csf-login.js';
import { extractCsfPdf } from './sat/sat-csf-scraper.js';
import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js';
const PROCESS_TIMEOUT = 180_000;
const PROCESS_TIMEOUT = 300_000;
export interface ConstanciaRow {
id: number;

View File

@@ -135,10 +135,61 @@ export async function getContribuyenteById(pool: Pool, id: string, tenantId?: st
return mergeContribuyenteWithTenant(row, tenantData);
}
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
export async function createContribuyente(
pool: Pool,
data: CreateContribuyenteData,
): Promise<{ row: ContribuyenteRow; reactivated: boolean }> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// 1. Si el RFC ya existe, reactivar la entidad desactivada en lugar de
// violar el UNIQUE de contribuyentes.rfc. Si está activa, lanzar
// error 23505 para que el controller devuelva 409.
const { rows: existing } = await client.query<{ entidad_id: string; active: boolean }>(`
SELECT c.entidad_id, e.active
FROM contribuyentes c
JOIN entidades_gestionadas e ON e.id = c.entidad_id
WHERE UPPER(c.rfc) = UPPER($1)
`, [data.rfc]);
if (existing.length > 0) {
const { entidad_id, active } = existing[0];
if (active) {
await client.query('ROLLBACK');
const err: any = new Error('Ya existe un contribuyente activo con este RFC');
err.code = '23505';
throw err;
}
await client.query(`
UPDATE entidades_gestionadas
SET active = true,
nombre = $1,
identificador = $2,
supervisor_user_id = $3,
updated_at = now()
WHERE id = $4
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null, entidad_id]);
await client.query(`
UPDATE contribuyentes
SET regimen_fiscal = $1,
codigo_postal = $2,
domicilio = $3
WHERE entidad_id = $4
`, [data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null, entidad_id]);
await client.query('COMMIT');
await backfillCfdiContribuyente(pool, entidad_id, data.rfc.toUpperCase()).catch(
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
);
return { row: (await getContribuyenteById(pool, entidad_id))!, reactivated: true };
}
// 2. Caso normal: crear nuevo contribuyente
const { rows: [entidad] } = await client.query(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3)
@@ -157,7 +208,7 @@ export async function createContribuyente(pool: Pool, data: CreateContribuyenteD
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
);
return (await getContribuyenteById(pool, entidad.id))!;
return { row: (await getContribuyenteById(pool, entidad.id))!, reactivated: false };
} catch (err) {
await client.query('ROLLBACK');
throw err;
@@ -220,11 +271,39 @@ export async function updateContribuyente(pool: Pool, id: string, data: Partial<
}
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
const { rowCount } = await pool.query(
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
[id]
);
return (rowCount ?? 0) > 0;
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rowCount } = await client.query(
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
[id]
);
const ok = (rowCount ?? 0) > 0;
if (ok) {
// Limpiar asignaciones para que no aparezca en carteras ni accesos de cliente
await client.query('DELETE FROM cartera_entidades WHERE entidad_id = $1', [id]).catch((err) => {
console.error('[Contribuyente] Error limpiando cartera_entidades:', err);
});
await client.query('DELETE FROM cliente_accesos WHERE entidad_id = $1', [id]).catch((err) => {
console.error('[Contribuyente] Error limpiando cliente_accesos:', err);
});
// Desactivar FIEL para que no siga sincronizándose mientras está inactivo
await client.query('UPDATE fiel_contribuyente SET is_active = false WHERE contribuyente_id = $1', [id]).catch((err) => {
console.error('[Contribuyente] Error desactivando FIEL:', err);
});
}
await client.query('COMMIT');
return ok;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
/**

View File

@@ -44,6 +44,12 @@ export const emailService = {
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
},
sendSatSyncAlert: async (to: string, data: import('./templates/sat-sync-alert.js').SatSyncAlertData) => {
const { satSyncAlertEmail } = await import('./templates/sat-sync-alert.js');
const total = data.summary.failed + data.summary.stale + data.summary.stuckRunning + data.summary.pendingOld + data.summary.missingInitial;
await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data));
},
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));

View File

@@ -0,0 +1,194 @@
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
export interface SatSyncAlertData {
generatedAt: string;
recipient: string;
summary: {
failed: number;
stale: number;
stuckRunning: number;
pendingOld: number;
missingInitial: number;
};
failed: Array<{
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
errorMessage?: string | null;
completedAt?: Date | string | null;
}>;
stale: Array<{
id: string;
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
kind: 'pending-stale' | 'running-stale';
ageHours: number;
}>;
stuckRunning: Array<{
id: string;
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
progressPercent: number;
startedAt?: Date | string | null;
hoursRunning: number;
}>;
pendingOld: Array<{
id: string;
tenantName: string;
tenantRfc: string;
contribuyenteName?: string | null;
contribuyenteRfc?: string | null;
type: string;
createdAt?: Date | string | null;
nextRetryAt?: Date | string | null;
hoursPending: number;
}>;
missingInitial: Array<{
tenantName: string;
tenantRfc: string;
contribuyenteName: string;
contribuyenteRfc: string;
}>;
}
function fmtDate(value?: Date | string | null): string {
if (!value) return 'N/A';
const d = typeof value === 'string' ? new Date(value) : value;
return d.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' });
}
function tableHeader(cells: string[]): string {
return `<tr>
${cells.map(c => `<th align="left" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">${c}</th>`).join('')}
</tr>`;
}
function tableRow(cells: string[]): string {
return `<tr>
${cells.map(c => `<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:13px;vertical-align:top;">${c}</td>`).join('')}
</tr>`;
}
function section(title: string, color: string, rowsHtml: string, headers: string[]): string {
return `
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${color};margin:28px 0 12px;font-size:16px;">${title}</h3>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<thead>${tableHeader(headers)}</thead>
<tbody>${rowsHtml}</tbody>
</table>
`;
}
export function satSyncAlertEmail(data: SatSyncAlertData): string {
const { summary } = data;
const summaryRows = [
{ label: 'Jobs fallidos recientes', value: summary.failed, color: summary.failed > 0 ? '#dc2626' : C.textPrimary },
{ label: 'Jobs stale detectados', value: summary.stale, color: summary.stale > 0 ? '#dc2626' : C.textPrimary },
{ label: 'Running atorados sin progreso', value: summary.stuckRunning, color: summary.stuckRunning > 0 ? '#f59e0b' : C.textPrimary },
{ label: 'Pending sin atender', value: summary.pendingOld, color: summary.pendingOld > 0 ? '#f59e0b' : C.textPrimary },
{ label: 'Contribuyentes con FIEL sin sync inicial', value: summary.missingInitial, color: summary.missingInitial > 0 ? '#dc2626' : C.textPrimary },
]
.map(r => `<tr><td style="padding:6px 0;color:${C.textMuted};">${r.label}</td><td style="padding:6px 0;color:${r.color};font-weight:600;text-align:right;">${r.value}</td></tr>`)
.join('');
const failedHtml = data.failed.length > 0
? section(
`Jobs fallidos (${data.failed.length})`,
'#dc2626',
data.failed.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
`<span style="color:#dc2626;">${j.errorMessage || 'Sin mensaje'}</span>`,
fmtDate(j.completedAt),
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Error', 'Fecha fallo']
)
: '';
const staleHtml = data.stale.length > 0
? section(
`Jobs stale detectados por el watchdog (${data.stale.length})`,
'#dc2626',
data.stale.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
j.kind === 'running-stale' ? 'Running abandonado' : 'Pending abandonado',
`${j.ageHours}h`,
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Problema', 'Antigüedad']
)
: '';
const stuckHtml = data.stuckRunning.length > 0
? section(
`Running atorados sin avance (${data.stuckRunning.length})`,
'#f59e0b',
data.stuckRunning.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
`${j.progressPercent}%`,
`${j.hoursRunning}h`,
fmtDate(j.startedAt),
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Progreso', 'Tiempo', 'Inicio']
)
: '';
const pendingHtml = data.pendingOld.length > 0
? section(
`Pending sin atender (${data.pendingOld.length})`,
'#f59e0b',
data.pendingOld.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
j.contribuyenteName ? `<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc || ''}</span>` : '—',
j.type,
`${j.hoursPending}h`,
j.nextRetryAt ? fmtDate(j.nextRetryAt) : 'Sin reintento',
])).join(''),
['Tenant', 'Contribuyente', 'Tipo', 'Tiempo pendiente', 'Próximo reintento']
)
: '';
const missingHtml = data.missingInitial.length > 0
? section(
`Contribuyentes con FIEL sin sync inicial (${data.missingInitial.length})`,
'#dc2626',
data.missingInitial.map(j => tableRow([
`<strong>${j.tenantName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.tenantRfc}</span>`,
`<strong>${j.contribuyenteName}</strong><br/><span style="color:${C.textMuted};font-size:11px;">${j.contribuyenteRfc}</span>`,
])).join(''),
['Tenant', 'Contribuyente']
)
: '';
return baseTemplate(`
${heading('🚨 Alerta de sincronización SAT')}
<p style="color:${C.textPrimary};margin:0 0 16px;">
El monitoreo de sincronizaciones SAT detectó anomalías que requieren revisión interna.
</p>
${infoBox(`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${summaryRows}</table>`)}
${failedHtml}
${staleHtml}
${stuckHtml}
${pendingHtml}
${missingHtml}
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
Configura umbrales con SAT_STUCK_RUNNING_HOURS y SAT_FAILED_LOOKBACK_HOURS.
</p>
`);
}

View File

@@ -1,40 +1,113 @@
import type { Page, Locator, Frame, Response } from 'playwright';
import type { Page, Locator, Frame, Response, BrowserContext } from 'playwright';
import type { CsfLoginSession } from './sat-csf-login.js';
async function tryFetchPdfFromUrl(page: Page, url: string): Promise<Buffer | null> {
async function tryFetchPdfFromUrl(frame: Frame, url: string): Promise<Buffer | null> {
if (!url || url === 'about:blank') return null;
// Blob / data URI → fetchear dentro del navegador para respetar cookies/sesión
if (url.startsWith('blob:') || url.startsWith('data:')) {
const arr = await page.evaluate(async (u) => {
const r = await fetch(u);
const buf = await r.arrayBuffer();
return Array.from(new Uint8Array(buf));
}, url);
return Buffer.from(arr);
try {
const page = frame.page();
const arr = await page.evaluate(async (u) => {
const r = await fetch(u);
const buf = await r.arrayBuffer();
return Array.from(new Uint8Array(buf));
}, url);
const buf = Buffer.from(arr);
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
} catch {
return null;
}
}
// URL http(s) relativa o absoluta → fetchear desde el frame para mantener sesión
if (url.startsWith('http')) {
const response = await page.context().request.get(url);
if (!response.ok()) return null;
return Buffer.from(await response.body());
try {
const response = await frame.page().context().request.get(url);
if (!response.ok()) return null;
const ct = response.headers()['content-type'] ?? '';
if (!ct.includes('application/pdf') && !url.toLowerCase().includes('.pdf')) return null;
const buf = Buffer.from(await response.body());
return buf.subarray(0, 5).toString().startsWith('%PDF-') ? buf : null;
} catch {
return null;
}
}
return null;
}
/**
* Busca "Generar Constancia" en cualquiera de los frames del appPage (vive
* típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...).
* Intenta 3 rutas: download event, popup con viewer, response interception.
*/
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
const { context, appPage } = session;
async function findPdfInFrames(appPage: Page, deadlineMs: number): Promise<Buffer | null> {
const deadline = Date.now() + deadlineMs;
let interceptedPdf: Buffer | null = null;
const responseListener = async (response: Response) => {
const ct = response.headers()['content-type'] ?? '';
if (ct.includes('application/pdf')) {
try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ }
while (Date.now() < deadline) {
const frames = appPage.frames();
for (const frame of frames) {
try {
const frameUrl = frame.url();
// 1. Frame cuya URL sea directamente un PDF
if (
frameUrl.toLowerCase().includes('.pdf') ||
frameUrl.includes('application/pdf')
) {
const body = await frame.content().catch(() => null);
if (!body) continue;
// content() de un PDF no es util; intentar fetch por URL
const pdf = await tryFetchPdfFromUrl(frame, frameUrl);
if (pdf) return pdf;
}
// 2. <embed type="application/pdf">
const embed = frame.locator('embed[type="application/pdf"]').first();
if ((await embed.count()) > 0) {
const src = await embed.getAttribute('src');
if (src) {
const pdf = await tryFetchPdfFromUrl(frame, src);
if (pdf) return pdf;
}
}
// 3. <iframe> cuyo src apunte al visor/constancia (IdcSiat, .pdf, etc.)
const iframes = await frame.locator('iframe').all();
for (const iframe of iframes) {
const src = await iframe.getAttribute('src');
if (src) {
const lower = src.toLowerCase();
if (
lower.includes('.pdf') ||
lower.includes('idcsiat') ||
lower.includes('reimpresion') ||
lower.includes('consultatramite')
) {
const pdf = await tryFetchPdfFromUrl(frame, src);
if (pdf) return pdf;
}
}
}
// 4. <a download> o link con data/blob generado por jsPDF
const downloadLinks = await frame.locator('a[download], a[href*="data:"], a[href*="blob:"]').all();
for (const link of downloadLinks) {
const href = await link.getAttribute('href');
if (href) {
const pdf = await tryFetchPdfFromUrl(frame, href);
if (pdf) return pdf;
}
}
} catch {
// Frame puede estar navegando; ignorar y continuar
}
}
};
context.on('response', responseListener);
await appPage.waitForTimeout(1000);
}
return null;
}
async function findGenerarButton(appPage: Page, timeoutMs: number): Promise<Locator | null> {
const GENERAR_SELECTORS = [
'button:has-text("Generar Constancia")',
'button:has-text("Generar constancia")',
@@ -44,77 +117,109 @@ export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
'a:has-text("Generar constancia")',
].join(', ');
let generarLocator: Locator | null = null;
let foundFrame: Frame | null = null;
const deadline = Date.now() + 90_000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const frame of appPage.frames()) {
const loc = frame.locator(GENERAR_SELECTORS).first();
const count = await loc.count().catch(() => 0);
if (count > 0 && await loc.isVisible().catch(() => false)) {
generarLocator = loc;
foundFrame = frame;
break;
if (count > 0 && (await loc.isVisible().catch(() => false))) {
return loc;
}
}
if (generarLocator) break;
await appPage.waitForTimeout(1000);
}
return null;
}
if (!generarLocator || !foundFrame) {
context.off('response', responseListener);
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
}
/**
* Busca "Generar Constancia" en cualquiera de los frames del appPage,
* hace click, y extrae el PDF desde el visor/iframe que genera el SAT.
*
* El portal SAT actual (2025-2026) genera la CSF dentro de un iframe JSF
* legacy (rfcampc.siat.sat.gob.mx/PTSC/.../ConsultaTramite.jsf). El PDF
* no siempre se descarga como evento de download ni abre popup; a veces
* se renderiza en un <embed> o en un iframe cuyo src devuelve el PDF.
*/
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
const { context, appPage } = session;
await generarLocator.scrollIntoViewIfNeeded();
await appPage.waitForTimeout(500);
const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null);
const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null);
await generarLocator.click();
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
let interceptedPdf: Buffer | null = null;
const responseListener = async (response: Response) => {
const ct = response.headers()['content-type'] ?? '';
if (ct.includes('application/pdf')) {
try {
interceptedPdf = Buffer.from(await response.body());
} catch {
/* ok */
}
}
};
context.on('response', responseListener);
try {
// Path 1: download event
const generarLocator = await findGenerarButton(appPage, 90_000);
if (!generarLocator) {
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
}
await generarLocator.scrollIntoViewIfNeeded();
await appPage.waitForTimeout(500);
// Algunos botones del SAT usan JSF/ajax; un click simple a veces no basta.
// Hacemos click normal y, como fallback, dispatchEvent si no hay reacción.
await generarLocator.click();
await appPage.waitForTimeout(2000);
// Intentar extraer el PDF del iframe/visor
let pdf = await findPdfInFrames(appPage, 60_000);
if (pdf) return pdf;
// Si aún no hay PDF, algunos flujos abren popup clásico
const popupPromise = context.waitForEvent('page', { timeout: 10_000 }).catch(() => null);
const downloadPromise = appPage.waitForEvent('download', { timeout: 10_000 }).catch(() => null);
// Reintentar click por si el primero no disparó el handler
const stillVisible = await generarLocator.isVisible().catch(() => false);
if (stillVisible) {
await generarLocator.dispatchEvent('click');
}
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
if (download) {
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
const pdf = Buffer.concat(chunks);
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) {
throw new Error('El archivo descargado no es un PDF válido');
}
return pdf;
const downloaded = Buffer.concat(chunks);
if (downloaded.subarray(0, 5).toString().startsWith('%PDF-')) return downloaded;
}
// Path 2: viewer popup
if (popup) {
await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
await popup.waitForTimeout(2000);
let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null);
if (!pdf) {
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
if (embedSrc) {
const absolute = new URL(embedSrc, popup.url()).toString();
pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null);
}
pdf = await findPdfInFrames(popup, 20_000);
if (pdf) return pdf;
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
if (embedSrc) {
const absolute = new URL(embedSrc, popup.url()).toString();
pdf = await tryFetchPdfFromUrl(popup.mainFrame(), absolute).catch(() => null);
if (pdf) return pdf;
}
if (!pdf && interceptedPdf) pdf = interceptedPdf;
if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF');
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido');
return pdf;
if (interceptedPdf) return interceptedPdf;
throw new Error('El visor abrió pero no se pudo extraer el PDF');
}
// Path 3: inline response (no popup, no download)
await appPage.waitForTimeout(3000);
if (interceptedPdf) {
const pdf = interceptedPdf as Buffer;
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido');
return pdf;
}
throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF');
// Último intento: esperar un poco más a que el iframe termine de cargar
await appPage.waitForTimeout(5000);
pdf = await findPdfInFrames(appPage, 20_000);
if (pdf) return pdf;
if (interceptedPdf) return interceptedPdf;
throw new Error('Click en "Generar Constancia" no produjo un PDF descargable ni visible');
} finally {
context.off('response', responseListener);
}

View File

@@ -820,9 +820,12 @@ async function processMetadataRange(
}
/**
* Determina el tamaño de bloque óptimo consultando metadata del rango completo.
* Determina el tamaño de bloque óptimo consultando metadata en chunks de 1 año.
* <= 15,000 CFDIs → bloques de 6 meses
* > 15,000 CFDIs → bloques de 2 meses
* > 15,000 CFDIs → bloques de 3 meses
*
* El SAT no genera paquetes de metadata para rangos muy grandes (p. ej. 6 años),
* así que el sondeo se divide en bloques anuales.
*/
async function determineChunkMonths(
ctx: SyncContext,
@@ -851,17 +854,20 @@ async function determineChunkMonths(
}
const THRESHOLD = 15_000;
const probeChunks = generateChunks(fechaInicio, fechaFin, 12);
let totalCfdis = 0;
for (const tipo of ['emitidos', 'recibidos'] as const) {
try {
const { totalCfdis: count } = await requestAndDownload(
ctx, jobId, fechaInicio, fechaFin, tipo, 'metadata'
);
totalCfdis += count;
console.log(`[SAT] Sondeo metadata ${tipo}: ${count} CFDIs en rango completo`);
} catch (error: any) {
console.log(`[SAT] No se pudo sondear metadata ${tipo}: ${error.message}`);
for (const { start, end } of probeChunks) {
try {
const { totalCfdis: count } = await requestAndDownload(
ctx, jobId, start, end, tipo, 'metadata'
);
totalCfdis += count;
console.log(`[SAT] Sondeo metadata ${tipo} ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}: ${count} CFDIs`);
} catch (error: any) {
console.log(`[SAT] No se pudo sondear metadata ${tipo} ${start.toISOString().slice(0, 10)}${end.toISOString().slice(0, 10)}: ${error.message}`);
}
}
}
@@ -907,7 +913,7 @@ async function processInitialSync(
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
const xmlChunks = generateChunks(inicioHistorico, fechaFin, chunkMonths);
const metaChunks = generateChunks(inicioHistorico, fechaFin, 36); // bloques de 3 años
const metaChunks = generateChunks(inicioHistorico, fechaFin, 12); // bloques de 1 año
console.log(`[SAT] Sincronización: ${xmlChunks.length} bloques XML (${chunkMonths}m) + ${metaChunks.length} bloques metadata (36m)`);