fix(csf): retry con backoff, delays entre tenants, timeouts aumentados
This commit is contained in:
@@ -351,6 +351,8 @@ async function runCsfJob(): Promise<void> {
|
|||||||
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
|
||||||
failed++;
|
failed++;
|
||||||
}
|
}
|
||||||
|
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
|
||||||
|
await new Promise(r => setTimeout(r, 30_000));
|
||||||
}
|
}
|
||||||
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
|
|||||||
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
||||||
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
||||||
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
||||||
|
*
|
||||||
|
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
|
||||||
|
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
|
||||||
*/
|
*/
|
||||||
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
||||||
const fiel = await getDecryptedFiel(tenantId);
|
const fiel = await getDecryptedFiel(tenantId);
|
||||||
@@ -55,72 +58,78 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
|
|||||||
});
|
});
|
||||||
if (!tenant) throw new Error('Tenant no encontrado');
|
if (!tenant) throw new Error('Tenant no encontrado');
|
||||||
|
|
||||||
const tempId = randomUUID();
|
const MAX_RETRIES = 3;
|
||||||
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
|
||||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
|
||||||
const cerPath = join(tempDir, 'cert.cer');
|
|
||||||
const keyPath = join(tempDir, 'key.key');
|
|
||||||
|
|
||||||
try {
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
const tempId = randomUUID();
|
||||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||||
|
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||||
|
const cerPath = join(tempDir, 'cert.cer');
|
||||||
|
const keyPath = join(tempDir, 'key.key');
|
||||||
|
|
||||||
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
|
|
||||||
// caso donde el click sintético no dispara el handler del SAT. Si algún
|
|
||||||
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
|
|
||||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
|
||||||
const browser = await chromium.launch({
|
|
||||||
headless,
|
|
||||||
args: ['--disable-blink-features=AutomationControlled'],
|
|
||||||
ignoreDefaultArgs: ['--enable-automation'],
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||||
);
|
|
||||||
|
|
||||||
const resultPromise = (async () => {
|
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
const browser = await chromium.launch({
|
||||||
const pdfBuffer = await extractCsfPdf(session);
|
headless,
|
||||||
const csf = await parseCsfPdf(pdfBuffer);
|
args: ['--disable-blink-features=AutomationControlled'],
|
||||||
|
ignoreDefaultArgs: ['--enable-automation'],
|
||||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
});
|
||||||
const { rows } = await pool.query(
|
try {
|
||||||
`INSERT INTO constancias_situacion_fiscal
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
|
||||||
datos, fecha_consulta, created_at`,
|
|
||||||
[
|
|
||||||
csf.rfc,
|
|
||||||
csf.idCIF,
|
|
||||||
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
|
||||||
csf.estatusPadron,
|
|
||||||
csf.lugarFechaEmision,
|
|
||||||
JSON.stringify(csf),
|
|
||||||
pdfBuffer,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-fill domicilio del tenant + regímenes activos desde el CSF.
|
const resultPromise = (async () => {
|
||||||
// Se hace después del INSERT para que si algo falla en la sincronización
|
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password, fiel.rfc);
|
||||||
// la CSF ya quedó guardada y el usuario puede verla.
|
const pdfBuffer = await extractCsfPdf(session);
|
||||||
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
const csf = await parseCsfPdf(pdfBuffer);
|
||||||
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
|
||||||
});
|
|
||||||
|
|
||||||
return rowToConstancia(rows[0]);
|
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||||
})();
|
const { rows } = await pool.query(
|
||||||
|
`INSERT INTO constancias_situacion_fiscal
|
||||||
|
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
||||||
|
datos, fecha_consulta, created_at`,
|
||||||
|
[
|
||||||
|
csf.rfc,
|
||||||
|
csf.idCIF,
|
||||||
|
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
||||||
|
csf.estatusPadron,
|
||||||
|
csf.lugarFechaEmision,
|
||||||
|
JSON.stringify(csf),
|
||||||
|
pdfBuffer,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return await Promise.race([resultPromise, timeoutPromise]);
|
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
||||||
|
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return rowToConstancia(rows[0]);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return await Promise.race([resultPromise, timeoutPromise]);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
const willRetry = attempt < MAX_RETRIES - 1;
|
||||||
|
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
|
||||||
|
if (!willRetry) throw err;
|
||||||
|
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||||
|
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||||
|
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
|
||||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
|
||||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw new Error('No debería llegar aquí');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,20 +30,20 @@ export async function loginSatCsf(
|
|||||||
const publicPage = await context.newPage();
|
const publicPage = await context.newPage();
|
||||||
publicPage.setDefaultTimeout(60_000);
|
publicPage.setDefaultTimeout(60_000);
|
||||||
|
|
||||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
|
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
||||||
await publicPage.waitForTimeout(2000);
|
await publicPage.waitForTimeout(3000);
|
||||||
|
|
||||||
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
||||||
const obtenerLocator = publicPage.locator(
|
const obtenerLocator = publicPage.locator(
|
||||||
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
||||||
).first();
|
).first();
|
||||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
|
await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
||||||
await obtenerLocator.scrollIntoViewIfNeeded();
|
await obtenerLocator.scrollIntoViewIfNeeded();
|
||||||
await obtenerLocator.click();
|
await obtenerLocator.click();
|
||||||
await publicPage.waitForTimeout(1500);
|
await publicPage.waitForTimeout(1500);
|
||||||
|
|
||||||
// Click "SERVICIO" → popup
|
// Click "SERVICIO" → popup
|
||||||
const popupPromise = context.waitForEvent('page', { timeout: 60_000 });
|
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
||||||
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
||||||
const loginPage = await popupPromise;
|
const loginPage = await popupPromise;
|
||||||
await loginPage.waitForLoadState('domcontentloaded');
|
await loginPage.waitForLoadState('domcontentloaded');
|
||||||
@@ -56,7 +56,7 @@ export async function loginSatCsf(
|
|||||||
const efirmaBtn = loginPage
|
const efirmaBtn = loginPage
|
||||||
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
||||||
.first();
|
.first();
|
||||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
|
||||||
await efirmaBtn.scrollIntoViewIfNeeded();
|
await efirmaBtn.scrollIntoViewIfNeeded();
|
||||||
await efirmaBtn.click();
|
await efirmaBtn.click();
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export async function loginSatCsf(
|
|||||||
return rfc !== null && rfc.value.length >= 12;
|
return rfc !== null && rfc.value.length >= 12;
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
);
|
);
|
||||||
rfcPopulated = true;
|
rfcPopulated = true;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -121,7 +121,7 @@ export async function loginSatCsf(
|
|||||||
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
// Esperar a que salga del dominio de login y aterrice en el portal SAT
|
||||||
await loginPage.waitForURL(
|
await loginPage.waitForURL(
|
||||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||||
{ timeout: 60_000 },
|
{ timeout: 120_000 },
|
||||||
);
|
);
|
||||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||||
await loginPage.waitForTimeout(2000);
|
await loginPage.waitForTimeout(2000);
|
||||||
|
|||||||
Reference in New Issue
Block a user