feat(sat): reporte diario de errores SAT por proxy
- Agrega tabla sat_proxy_errors con proxy_usado, error_code, stage, message. - sat-client.service.ts expone proxyInfo usado en cada conexión SAT. - sat.service.ts registra errores de bloqueo/devolución del SAT en sat_proxy_errors y guarda proxyUsed en sat_sync_jobs. - Nuevo job sat-proxy-report.job.ts: cron 8 AM CDMX, envía email a ADMIN_EMAIL con errores de las últimas 24h agrupados por proxy/error_code. - Template de email sat-proxy-report.ts y método sendSatProxyReport. - Registra el cron en src/index.ts. - Actualiza docs/SAT-SYNC-IMPLEMENTATION.md.
This commit is contained in:
31
apps/api/.env.save
Normal file
31
apps/api/.env.save
Normal file
@@ -0,0 +1,31 @@
|
||||
NODE_ENV=production
|
||||
PORT=4000
|
||||
DATABASE_URL=postgresql://postgres:ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb@localhost:5432/horux360
|
||||
JWT_SECRET=16901123ea97c95571cde21c46a7c1a63199694630fa2225702aa9ed7f3bac0d143e53fb636039fd69ccc88db202cc40fc551861d59037d3eb4c2c1ea9f7cf6f
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
CORS_ORIGIN=https://horuxfin.com
|
||||
FRONTEND_URL=https://horuxfin.com
|
||||
FIEL_ENCRYPTION_KEY=ad4e2c43f73ecab311223646e0acde1e74d27832c0b2ff365cc26220b89cd3f9e6c0237a268855f3dcd79d59d77dd11e52fe1bfc9da7b565536155948bef3786
|
||||
FIEL_STORAGE_PATH=/var/horux/fiel
|
||||
ADMIN_EMAIL=carlos@horuxfin.com
|
||||
MP_USE_SANDBOX=false
|
||||
MP_ACCES_TOKEN=APP_USR-5319386258998241-031520-79992e7fbe7c7fdb56da4b971fed7aad-1966893850
|
||||
MP_ACCESS_TOKEN_SANDBOX=TEST-5319386258998241-031520-49b9b0601330fd0e3ee95e41c2816b6a-1966893850
|
||||
MP_WEBHOOK_SECRET=dd12d6eb6ea9b41b3b06b85ae4d68884a68353662a56ca64c5430c190b7a1e2c
|
||||
MP_NOTIFICATION_URL=https://horuxfin.com/api/webhooks/mercadopago
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=soporte@horuxfin.com
|
||||
SMTP_PASS=qyvb tbzi cwxl sxde
|
||||
SMTP_FROM=Horux360 <noreply@horuxfin.com>
|
||||
FACTURAPI_USER_KEY=sk_user_wdqvggGbg8RHN7mqLhogM2y7K1PXGT9PSDLguaLsM9
|
||||
CLOUDFLARE_TUNNEL_DOMAIN=tunnel.horux.mx
|
||||
CONNECTOR_ENCRYPTION_KEY=f28dc3b723e675714c1a461fcbcb6d9b4589d89d67263d868faddb28f0053d47c14ee7415340209cf56241388be24fe7c3e2f7d19be86814a957a0d108a71703
|
||||
METABASE_URL=https://metabase.consultoria-as.com
|
||||
METABASE_USERNAME=ialcarazsalazar@consultoria-as.com
|
||||
METABASE_PASSWORD=Aasi940812
|
||||
METABASE_PG_HOST=192.168.10.90
|
||||
METABASE_PG_PORT=5432
|
||||
METABASE_PG_USER=postgres
|
||||
METABASE_PG_PASSWORD=ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Agrega proxy_used a sat_sync_jobs para diagnosticar bloqueos por IP
|
||||
ALTER TABLE "sat_sync_jobs" ADD COLUMN "proxy_used" VARCHAR(255);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Tabla de errores SAT por proxy para reportes diarios
|
||||
CREATE TABLE "sat_proxy_errors" (
|
||||
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"job_id" TEXT NOT NULL REFERENCES "sat_sync_jobs"("id") ON DELETE CASCADE,
|
||||
"proxy_used" VARCHAR(255),
|
||||
"error_code" VARCHAR(50),
|
||||
"stage" VARCHAR(255),
|
||||
"message" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX "sat_proxy_errors_created_at_idx" ON "sat_proxy_errors"("created_at");
|
||||
CREATE INDEX "sat_proxy_errors_proxy_used_created_at_idx" ON "sat_proxy_errors"("proxy_used", "created_at");
|
||||
@@ -677,8 +677,12 @@ model SatSyncJob {
|
||||
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
|
||||
// bootstrap puro. Daily/incremental ignoran este campo.
|
||||
isCustomRange Boolean @default(false) @map("is_custom_range")
|
||||
// Proxy usado en el último request SAT que falló (host:port). Ayuda a diagnosticar
|
||||
// bloqueos por IP y a generar reportes diarios de errores por proxy.
|
||||
proxyUsed String? @map("proxy_used") @db.VarChar(255)
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
proxyErrors SatProxyError[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@ -686,6 +690,24 @@ model SatSyncJob {
|
||||
@@map("sat_sync_jobs")
|
||||
}
|
||||
|
||||
// Errores de bloqueo/devolución del SAT por proxy.
|
||||
// Permite reportes diarios de cuántos 404/500X/etc. ocurrieron en cada IP.
|
||||
model SatProxyError {
|
||||
id String @id @default(uuid())
|
||||
jobId String @map("job_id")
|
||||
proxyUsed String? @map("proxy_used") @db.VarChar(255)
|
||||
errorCode String? @map("error_code") @db.VarChar(50)
|
||||
stage String? @map("stage") @db.VarChar(255)
|
||||
message String? @map("message")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
job SatSyncJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([proxyUsed, createdAt])
|
||||
@@map("sat_proxy_errors")
|
||||
}
|
||||
|
||||
enum SatSyncType {
|
||||
initial
|
||||
daily
|
||||
|
||||
105
apps/api/scripts/backfill-conceptos.ts
Normal file
105
apps/api/scripts/backfill-conceptos.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Backfill: Extrae conceptos de XMLs existentes en cfdis y los inserta en cfdi_conceptos.
|
||||
* Uso: npx tsx scripts/backfill-conceptos.ts <database_name> [batchSize]
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { parseXml } from '../src/services/sat/sat-parser.service.js';
|
||||
|
||||
async function main() {
|
||||
const databaseName = process.argv[2];
|
||||
const batchSize = parseInt(process.argv[3] || '100', 10);
|
||||
|
||||
if (!databaseName) {
|
||||
console.error('Uso: npx tsx scripts/backfill-conceptos.ts <database_name> [batchSize]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb',
|
||||
database: databaseName,
|
||||
});
|
||||
|
||||
let totalProcessed = 0;
|
||||
let totalConceptos = 0;
|
||||
let errors = 0;
|
||||
|
||||
while (true) {
|
||||
const { rows: cfdis } = await pool.query(`
|
||||
SELECT c.id, c.uuid, c.xml_original
|
||||
FROM cfdis c
|
||||
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
|
||||
WHERE c.xml_original IS NOT NULL AND cc.id IS NULL
|
||||
LIMIT $1
|
||||
`, [batchSize]);
|
||||
|
||||
if (cfdis.length === 0) break;
|
||||
|
||||
for (const row of cfdis) {
|
||||
try {
|
||||
const cfdi = parseXml(row.xml_original, 'emitidos');
|
||||
if (!cfdi || !cfdi.conceptos || cfdi.conceptos.length === 0) {
|
||||
// Algunos XMLs pueden no tener conceptos (ej. tipo P, N, T)
|
||||
// Pero aún así los marcamos como "procesados" insertando un concepto dummy?
|
||||
// No, mejor solo saltamos.
|
||||
totalProcessed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tc = cfdi.tipoCambio || 1;
|
||||
const m = (v: number) => v * tc;
|
||||
|
||||
for (const c of cfdi.conceptos) {
|
||||
await pool.query(`
|
||||
INSERT INTO cfdi_conceptos (
|
||||
cfdi_id, clave_prod_serv, no_identificacion, descripcion, cantidad,
|
||||
clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn,
|
||||
descuento, descuento_mxn,
|
||||
isr_retencion, isr_retencion_mxn,
|
||||
iva_traslado, iva_traslado_mxn,
|
||||
iva_retencion, iva_retencion_mxn,
|
||||
ieps_traslado, ieps_traslado_mxn,
|
||||
ieps_retencion, ieps_retencion_mxn
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,
|
||||
$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23
|
||||
)
|
||||
`, [
|
||||
row.id,
|
||||
c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
|
||||
c.claveUnidad, c.unidad,
|
||||
c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
|
||||
c.descuento, m(c.descuento),
|
||||
c.isrRetencion, m(c.isrRetencion),
|
||||
c.ivaTraslado, m(c.ivaTraslado),
|
||||
c.ivaRetencion, m(c.ivaRetencion),
|
||||
c.iepsTraslado, m(c.iepsTraslado),
|
||||
c.iepsRetencion, m(c.iepsRetencion),
|
||||
]);
|
||||
}
|
||||
|
||||
totalConceptos += cfdi.conceptos.length;
|
||||
totalProcessed++;
|
||||
} catch (err: any) {
|
||||
errors++;
|
||||
console.error(`[ERROR] UUID ${row.uuid}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PROGRESS] Procesados: ${totalProcessed}, Conceptos: ${totalConceptos}, Errores: ${errors}`);
|
||||
}
|
||||
|
||||
console.log(`\n[COMPLETE] Total CFDIs procesados: ${totalProcessed}`);
|
||||
console.log(`[COMPLETE] Total conceptos insertados: ${totalConceptos}`);
|
||||
console.log(`[COMPLETE] Errores: ${errors}`);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
133
apps/api/scripts/backfill-regimen-fiscal.ts
Normal file
133
apps/api/scripts/backfill-regimen-fiscal.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'dotenv/config';
|
||||
import { Client } from 'pg';
|
||||
|
||||
// Backfill NO destructivo de contribuyentes.regimen_fiscal desde la CSF ya
|
||||
// almacenada en constancias_situacion_fiscal (tabla del tenant). Replica la
|
||||
// lógica de matching de consultarConstanciaContribuyente para que el resultado
|
||||
// sea idéntico al de una descarga fresca. Solo toca filas con regimen_fiscal
|
||||
// NULL o '' (COALESCE semantics). APPLY=1 para escribir; default = dry-run.
|
||||
|
||||
const APPLY = process.env.APPLY === '1';
|
||||
const PG = {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: process.env.PGPASSWORD,
|
||||
};
|
||||
const CONTROL_DB = 'horux360';
|
||||
|
||||
// Réplica exacta del `norm` de consultarConstanciaContribuyente:
|
||||
// NFD + strip de diacríticos (U+0300–U+036F) + lowercase + trim.
|
||||
const DIACRITICS_RE = new RegExp('[̀-ͯ]', 'g');
|
||||
const norm = (s: string) =>
|
||||
s.normalize('NFD').replace(DIACRITICS_RE, '').toLowerCase().trim();
|
||||
|
||||
interface CatalogoReg { clave: string; descripcion: string; }
|
||||
|
||||
function clavesFromCsfRegimenes(
|
||||
regimenes: Array<{ nombre: string; fechaFin?: string | null }> | undefined | null,
|
||||
catalogo: CatalogoReg[],
|
||||
): string[] {
|
||||
if (!regimenes?.length) return [];
|
||||
const found: string[] = [];
|
||||
for (const reg of regimenes) {
|
||||
if (reg.fechaFin) continue; // solo activos
|
||||
const regNorm = norm(reg.nombre);
|
||||
let best: { clave: string; score: number } | null = null;
|
||||
for (const r of catalogo) {
|
||||
const catNorm = norm(r.descripcion);
|
||||
if (regNorm === catNorm || regNorm.includes(catNorm) || catNorm.includes(regNorm)) {
|
||||
const score = catNorm.length;
|
||||
if (!best || score > best.score) best = { clave: r.clave, score };
|
||||
}
|
||||
}
|
||||
if (best) found.push(best.clave);
|
||||
}
|
||||
return [...new Set(found)];
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const control = new Client({ ...PG, database: CONTROL_DB });
|
||||
await control.connect();
|
||||
|
||||
const { rows: catalogo } = await control.query<CatalogoReg>(
|
||||
`SELECT clave, descripcion FROM regimenes WHERE activo = true`,
|
||||
);
|
||||
|
||||
const { rows: tenants } = await control.query<{ id: string; database_name: string; rfc: string | null }>(
|
||||
`SELECT id, database_name, rfc FROM tenants
|
||||
WHERE active = true AND database_name IS NOT NULL AND database_name NOT LIKE '%\\_deleted\\_%'
|
||||
ORDER BY database_name`,
|
||||
);
|
||||
|
||||
let totalContrib = 0;
|
||||
let totalUpdated = 0;
|
||||
const report: Array<{ db: string; rfc: string; claves: string; applied: boolean }> = [];
|
||||
|
||||
for (const t of tenants) {
|
||||
const tdb = new Client({ ...PG, database: t.database_name });
|
||||
try {
|
||||
await tdb.connect();
|
||||
} catch (e: any) {
|
||||
console.log(`SKIP ${t.database_name}: no se pudo conectar (${e.message})`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { rows: hasTbl } = await tdb.query(
|
||||
`SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='constancias_situacion_fiscal' LIMIT 1`,
|
||||
);
|
||||
if (hasTbl.length === 0) continue;
|
||||
|
||||
const { rows: contribs } = await tdb.query<{ entidad_id: string; rfc: string }>(
|
||||
`SELECT entidad_id, rfc FROM contribuyentes
|
||||
WHERE regimen_fiscal IS NULL OR regimen_fiscal = ''`,
|
||||
);
|
||||
if (contribs.length === 0) continue;
|
||||
|
||||
for (const c of contribs) {
|
||||
totalContrib++;
|
||||
const { rows: csfRows } = await tdb.query<{ regimenes: any }>(
|
||||
`SELECT datos->'regimenes' AS regimenes FROM constancias_situacion_fiscal
|
||||
WHERE UPPER(rfc) = UPPER($1) ORDER BY fecha_consulta DESC LIMIT 1`,
|
||||
[c.rfc],
|
||||
);
|
||||
const regimenes = csfRows[0]?.regimenes;
|
||||
const claves = clavesFromCsfRegimenes(regimenes, catalogo);
|
||||
if (claves.length === 0) {
|
||||
report.push({ db: t.database_name, rfc: c.rfc, claves: '(sin match en CSF)', applied: false });
|
||||
continue;
|
||||
}
|
||||
const csv = claves.join(',');
|
||||
let applied = false;
|
||||
if (APPLY) {
|
||||
const { rowCount } = await tdb.query(
|
||||
`UPDATE contribuyentes SET regimen_fiscal = $1
|
||||
WHERE entidad_id = $2 AND (regimen_fiscal IS NULL OR regimen_fiscal = '')`,
|
||||
[csv, c.entidad_id],
|
||||
);
|
||||
applied = (rowCount ?? 0) > 0;
|
||||
if (applied) totalUpdated++;
|
||||
}
|
||||
report.push({ db: t.database_name, rfc: c.rfc, claves: csv, applied });
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.log(`ERR ${t.database_name}: ${e.message}`);
|
||||
} finally {
|
||||
await tdb.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await control.end();
|
||||
|
||||
console.log(`\n=== ${APPLY ? 'APPLY' : 'DRY-RUN'} regimen_fiscal backfill ===`);
|
||||
console.log(`catálogo regimenes activos: ${catalogo.length}`);
|
||||
console.log(`contribuyentes con regimen_fiscal vacío: ${totalContrib}`);
|
||||
for (const r of report) {
|
||||
const tag = APPLY ? (r.applied ? 'UPDATED' : 'skip') : 'would-update';
|
||||
console.log(` [${tag}] ${r.db} ${r.rfc} -> ${r.claves}`);
|
||||
}
|
||||
if (APPLY) console.log(`\nTOTAL ACTUALIZADOS: ${totalUpdated}`);
|
||||
else console.log(`\n(dry-run: ninguna escritura. Corre con APPLY=1 para aplicar.)`);
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
16
apps/api/scripts/check-clave-prod-serv.ts
Normal file
16
apps/api/scripts/check-clave-prod-serv.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { prisma } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const count = await prisma.catClaveProdServ.count();
|
||||
console.log('catClaveProdServ count:', count);
|
||||
if (count > 0) {
|
||||
const sample = await prisma.catClaveProdServ.findMany({ take: 5 });
|
||||
console.log('Sample:', sample);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
20
apps/api/scripts/check_auza_cfdis.ts
Normal file
20
apps/api/scripts/check_auza_cfdis.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { tenantDb } from '../src/config/database';
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool('81116985-03cd-4843-97ba-05e8be9917c6', 'horux_auza640701ti9');
|
||||
|
||||
const countResult = await pool.query('SELECT COUNT(*) FROM cfdis');
|
||||
console.log('Total CFDIs:', countResult.rows[0].count);
|
||||
|
||||
const vigentesResult = await pool.query("SELECT COUNT(*) FROM cfdis WHERE status = 'Vigente'");
|
||||
console.log('Vigentes:', vigentesResult.rows[0].count);
|
||||
|
||||
const canceladosResult = await pool.query("SELECT COUNT(*) FROM cfdis WHERE status = 'Cancelado'");
|
||||
console.log('Cancelados:', canceladosResult.rows[0].count);
|
||||
|
||||
const recentResult = await pool.query("SELECT MAX(actualizado_en) as last_update FROM cfdis");
|
||||
console.log('Last update:', recentResult.rows[0].last_update);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
11
apps/api/scripts/check_auza_schema.ts
Normal file
11
apps/api/scripts/check_auza_schema.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { tenantDb } from '../src/config/database';
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool('81116985-03cd-4843-97ba-05e8be9917c6', 'horux_auza640701ti9');
|
||||
|
||||
const result = await pool.query("SELECT column_name FROM information_schema.columns WHERE table_name = 'cfdis' ORDER BY ordinal_position");
|
||||
console.log(result.rows.map((r: any) => r.column_name).join(', '));
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
23
apps/api/scripts/check_entidades.ts
Normal file
23
apps/api/scripts/check_entidades.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { tenantDb } from '../src/config/database';
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool('c52c2f5d-b1ae-45c6-8cc8-b11c9611618a', 'horux_hts240708lja');
|
||||
|
||||
const cols = await pool.query("SELECT column_name FROM information_schema.columns WHERE table_name = 'entidades_gestionadas' ORDER BY ordinal_position");
|
||||
console.log('Columnas:', cols.rows.map((r: any) => r.column_name).join(', '));
|
||||
|
||||
const result = await pool.query('SELECT COUNT(*) FROM entidades_gestionadas');
|
||||
console.log('Total entidades:', result.rows[0].count);
|
||||
|
||||
const active = await pool.query("SELECT COUNT(*) FROM entidades_gestionadas WHERE active = true");
|
||||
console.log('Activas:', active.rows[0].count);
|
||||
|
||||
const contrib = await pool.query("SELECT COUNT(*) FROM entidades_gestionadas WHERE tipo = 'CONTRIBUYENTE'");
|
||||
console.log('Contribuyentes:', contrib.rows[0].count);
|
||||
|
||||
const activeContrib = await pool.query("SELECT COUNT(*) FROM entidades_gestionadas WHERE tipo = 'CONTRIBUYENTE' AND active = true");
|
||||
console.log('Contribuyentes activos:', activeContrib.rows[0].count);
|
||||
|
||||
await pool.end();
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
9
apps/api/scripts/check_fiel.ts
Normal file
9
apps/api/scripts/check_fiel.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { tenantDb } from '../src/config/database';
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool('81116985-03cd-4843-97ba-05e8be9917c6', 'horux_auza640701ti9');
|
||||
const result = await pool.query('SELECT rfc, is_active, valid_from, valid_until FROM fiel_contribuyente');
|
||||
console.log(result.rows);
|
||||
await pool.end();
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
47
apps/api/scripts/check_request.ts
Normal file
47
apps/api/scripts/check_request.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { tenantDb } from '../src/config/database';
|
||||
import { verifySatRequest } from '../src/services/sat/sat-client.service';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: '81116985-03cd-4843-97ba-05e8be9917c6' }
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.log('Tenant not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
const fielResult = await pool.query(
|
||||
'SELECT * FROM fiel_contribuyente WHERE rfc = $1',
|
||||
['AUZA640701TI9']
|
||||
);
|
||||
|
||||
if (fielResult.rows.length === 0) {
|
||||
console.log('No FIEL found');
|
||||
return;
|
||||
}
|
||||
|
||||
const fiel = fielResult.rows[0];
|
||||
|
||||
const requestId = '9f78f8e7-4361-43db-b43a-af43932a4b45';
|
||||
console.log('Checking request:', requestId);
|
||||
|
||||
const status = await verifySatRequest({
|
||||
rfc: fiel.rfc,
|
||||
cert: fiel.cer_data,
|
||||
key: fiel.key_data,
|
||||
keyPass: fiel.key_password_enc
|
||||
}, requestId);
|
||||
|
||||
console.log('Status:', JSON.stringify(status, null, 2));
|
||||
|
||||
await pool.end();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
9
apps/api/scripts/check_schema.ts
Normal file
9
apps/api/scripts/check_schema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { tenantDb } from '../src/config/database';
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool('81116985-03cd-4843-97ba-05e8be9917c6', 'horux_auza640701ti9');
|
||||
const result = await pool.query("SELECT column_name FROM information_schema.columns WHERE table_name = 'fiel_contribuyente'");
|
||||
console.log(result.rows);
|
||||
await pool.end();
|
||||
}
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
84
apps/api/scripts/check_sync_jobs.ts
Normal file
84
apps/api/scripts/check_sync_jobs.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { prisma } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
// Buscar tenant HTS240708LJA
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: 'HTS240708LJA' },
|
||||
select: { id: true, nombre: true, rfc: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.log('Tenant HTS240708LJA no encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Tenant: ${tenant.nombre} (${tenant.id})\n`);
|
||||
|
||||
// Jobs a nivel tenant
|
||||
const tenantJobs = await prisma.satSyncJob.findMany({
|
||||
where: { tenantId: tenant.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
contribuyenteId: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
cfdisDownloaded: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('=== Jobs a nivel tenant ===');
|
||||
if (tenantJobs.length === 0) {
|
||||
console.log('Sin jobs');
|
||||
} else {
|
||||
tenantJobs.forEach((j) => {
|
||||
console.log(` ${j.id} | type=${j.type} | status=${j.status} | contrib=${j.contribuyenteId || 'N/A'} | descargados=${j.cfdisDownloaded} | completed=${j.completedAt?.toISOString() || 'N/A'}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Buscar contribuyentes del tenant
|
||||
const db = await import('../src/config/database.js');
|
||||
const pool = await db.tenantDb.getPool(tenant.id, `horux_${tenant.rfc.toLowerCase()}`);
|
||||
const { rows } = await pool.query('SELECT entidad_id, rfc, razon_social FROM contribuyentes');
|
||||
|
||||
console.log('\n=== Contribuyentes ===');
|
||||
for (const c of rows) {
|
||||
console.log(` ${c.entidad_id} | ${c.rfc} | ${c.nombre}`);
|
||||
}
|
||||
|
||||
// Jobs por contribuyente
|
||||
for (const c of rows) {
|
||||
const jobs = await prisma.satSyncJob.findMany({
|
||||
where: { tenantId: tenant.id, contribuyenteId: c.entidad_id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
cfdisDownloaded: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\n=== Jobs para ${c.rfc} ===`);
|
||||
if (jobs.length === 0) {
|
||||
console.log(' Sin jobs');
|
||||
} else {
|
||||
jobs.forEach((j) => {
|
||||
console.log(` ${j.id} | type=${j.type} | status=${j.status} | descargados=${j.cfdisDownloaded} | completed=${j.completedAt?.toISOString() || 'N/A'}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
20
apps/api/scripts/csf-husberto-manual.ts
Normal file
20
apps/api/scripts/csf-husberto-manual.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { consultarConstancia } from '../src/services/constancia.service.js';
|
||||
|
||||
const TENANT_ID = 'd75bf020-2008-4881-ab96-77467bf9e1fd';
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('[CSF Manual] Iniciando descarga para Husberto...');
|
||||
const result = await consultarConstancia(TENANT_ID);
|
||||
console.log('[CSF Manual] ÉXITO — RFC:', result.rfc);
|
||||
console.log('[CSF Manual] Estatus:', result.estatusPadron);
|
||||
console.log('[CSF Manual] Fecha emisión:', result.fechaEmision);
|
||||
process.exit(0);
|
||||
} catch (err: any) {
|
||||
console.error('[CSF Manual] ERROR:', err.message);
|
||||
if (err.stack) console.error(err.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
161
apps/api/scripts/debug-csf-download.ts
Normal file
161
apps/api/scripts/debug-csf-download.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { tenantDb, prisma } from '../src/config/database.js';
|
||||
import { getDecryptedFielContribuyente } from '../src/services/contribuyente-fiel.service.js';
|
||||
|
||||
const CONTRIBUYENTE_ID = 'cc41d462-4270-4949-aa9b-f0a175784659';
|
||||
const DEBUG_DIR = join(tmpdir(), `horux-csf-download-debug-${Date.now()}`);
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[DEBUG] ${new Date().toISOString()} — ${msg}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(DEBUG_DIR, { recursive: true });
|
||||
log(`Directorio: ${DEBUG_DIR}`);
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: 'DESPACHO_MPG95QP7_XZVFF' },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const fiel = await getDecryptedFielContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
if (!fiel) throw new Error('No hay FIEL');
|
||||
|
||||
const tempId = randomUUID();
|
||||
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');
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal';
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
acceptDownloads: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
});
|
||||
|
||||
const publicPage = await context.newPage();
|
||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
||||
await publicPage.waitForTimeout(3000);
|
||||
|
||||
await 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').first().click();
|
||||
await publicPage.waitForTimeout(1500);
|
||||
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
||||
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
||||
const loginPage = await popupPromise;
|
||||
await loginPage.waitForLoadState('domcontentloaded');
|
||||
|
||||
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]'
|
||||
).first();
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await efirmaBtn.click();
|
||||
|
||||
const fileInputs = loginPage.locator('input[type="file"]');
|
||||
try {
|
||||
await fileInputs.first().waitFor({ state: 'attached', timeout: 10_000 });
|
||||
} catch {
|
||||
await efirmaBtn.dispatchEvent('click');
|
||||
await fileInputs.first().waitFor({ state: 'attached', timeout: 30_000 });
|
||||
}
|
||||
|
||||
await fileInputs.nth(0).setInputFiles(cerPath);
|
||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||
|
||||
await loginPage.waitForFunction(
|
||||
() => {
|
||||
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
|
||||
return rfc !== null && rfc.value.length >= 12;
|
||||
},
|
||||
null,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
|
||||
await loginPage.locator('input[type="password"]').first().fill(fiel.password);
|
||||
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
|
||||
|
||||
await loginPage.waitForURL(
|
||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
log(`URL post-login: ${loginPage.url()}`);
|
||||
|
||||
// Buscar y click en Generar Constancia
|
||||
const GENERAR_SELECTORS = [
|
||||
'button:has-text("Generar Constancia")',
|
||||
'button:has-text("Generar constancia")',
|
||||
'input[type="button"][value*="Generar" i]',
|
||||
'input[type="submit"][value*="Generar" i]',
|
||||
'a:has-text("Generar Constancia")',
|
||||
'a:has-text("Generar constancia")',
|
||||
].join(', ');
|
||||
|
||||
let generarLocator: any = null;
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const frame of loginPage.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;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (generarLocator) break;
|
||||
await loginPage.waitForTimeout(1000);
|
||||
}
|
||||
if (!generarLocator) throw new Error('No se encontró botón');
|
||||
|
||||
log('Click en Generar Constancia...');
|
||||
await generarLocator.click();
|
||||
|
||||
// Observar qué pasa en los próximos 30s
|
||||
for (let i = 1; i <= 30; i++) {
|
||||
await loginPage.waitForTimeout(1000);
|
||||
const html = await loginPage.content();
|
||||
writeFileSync(join(DEBUG_DIR, `after-click-${i}s.html`), html);
|
||||
|
||||
// Buscar elementos de descarga
|
||||
const links = await loginPage.locator('a[download], a[href*="data:"], a[href*="blob:"]').all();
|
||||
const embeds = await loginPage.locator('embed[type="application/pdf"], iframe').all();
|
||||
log(`Segundo ${i}: ${links.length} links descarga, ${embeds.length} embeds/frames PDF`);
|
||||
|
||||
// Intentar extraer jsPDF del window
|
||||
const jsPdfState = await loginPage.evaluate(() => {
|
||||
return {
|
||||
hasJsPDF: typeof (window as any).jsPDF !== 'undefined',
|
||||
docCount: (window as any).__pdfDocs?.length || 0,
|
||||
};
|
||||
}).catch(() => ({ hasJsPDF: false, docCount: 0 }));
|
||||
if (jsPdfState.hasJsPDF) log(` jsPDF detectado`);
|
||||
}
|
||||
|
||||
log('Debug finalizado');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
173
apps/api/scripts/debug-csf-response.ts
Normal file
173
apps/api/scripts/debug-csf-response.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { tenantDb, prisma } from '../src/config/database.js';
|
||||
import { getDecryptedFielContribuyente } from '../src/services/contribuyente-fiel.service.js';
|
||||
|
||||
const CONTRIBUYENTE_ID = 'cc41d462-4270-4949-aa9b-f0a175784659';
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[DEBUG] ${new Date().toISOString()} — ${msg}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: 'DESPACHO_MPG95QP7_XZVFF' },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const fiel = await getDecryptedFielContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
if (!fiel) throw new Error('No hay FIEL');
|
||||
|
||||
const tempId = randomUUID();
|
||||
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');
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
acceptDownloads: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
});
|
||||
|
||||
// Interceptar responses
|
||||
const pdfUrls: string[] = [];
|
||||
context.on('response', async (response) => {
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
const url = response.url();
|
||||
if (ct.includes('application/pdf') || url.includes('.pdf') || url.includes('IdcSiat')) {
|
||||
log(`Response: ${url} | CT: ${ct} | status: ${response.status()}`);
|
||||
if (ct.includes('application/pdf')) pdfUrls.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
const publicPage = await context.newPage();
|
||||
await publicPage.goto('https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal', { waitUntil: 'networkidle', timeout: 120_000 });
|
||||
await publicPage.waitForTimeout(3000);
|
||||
await 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').first().click();
|
||||
await publicPage.waitForTimeout(1500);
|
||||
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
||||
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
||||
const loginPage = await popupPromise;
|
||||
await loginPage.waitForLoadState('domcontentloaded');
|
||||
|
||||
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]'
|
||||
).first();
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await efirmaBtn.click();
|
||||
|
||||
const fileInputs = loginPage.locator('input[type="file"]');
|
||||
try { await fileInputs.first().waitFor({ state: 'attached', timeout: 10_000 }); }
|
||||
catch { await efirmaBtn.dispatchEvent('click'); await fileInputs.first().waitFor({ state: 'attached', timeout: 30_000 }); }
|
||||
|
||||
await fileInputs.nth(0).setInputFiles(cerPath);
|
||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||
|
||||
await loginPage.waitForFunction(() => {
|
||||
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
|
||||
return rfc !== null && rfc.value.length >= 12;
|
||||
}, null, { timeout: 120_000 });
|
||||
|
||||
await loginPage.locator('input[type="password"]').first().fill(fiel.password);
|
||||
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
|
||||
|
||||
await loginPage.waitForURL(url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'), { timeout: 180_000 });
|
||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage.waitForTimeout(3000);
|
||||
|
||||
log(`URL post-login: ${loginPage.url()}`);
|
||||
|
||||
// Buscar botón en frames
|
||||
const GENERAR_SELECTORS = [
|
||||
'button:has-text("Generar Constancia")',
|
||||
'button:has-text("Generar constancia")',
|
||||
'input[type="button"][value*="Generar" i]',
|
||||
'input[type="submit"][value*="Generar" i]',
|
||||
'a:has-text("Generar Constancia")',
|
||||
'a:has-text("Generar constancia")',
|
||||
].join(', ');
|
||||
|
||||
let generarLocator: any = null;
|
||||
let foundFrame: any = null;
|
||||
for (const frame of loginPage.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 (!generarLocator) throw new Error('No botón');
|
||||
|
||||
log('Click en Generar Constancia...');
|
||||
await generarLocator.click();
|
||||
|
||||
log('Esperando 20s por responses PDF...');
|
||||
await loginPage.waitForTimeout(20_000);
|
||||
|
||||
log(`URLs PDF interceptadas: ${pdfUrls.length}`);
|
||||
for (const u of pdfUrls) log(` - ${u}`);
|
||||
|
||||
// Explorar frames después del click
|
||||
log('Frames después del click:');
|
||||
for (let i = 0; i < loginPage.frames().length; i++) {
|
||||
const f = loginPage.frames()[i];
|
||||
log(` Frame ${i}: ${f.url()}`);
|
||||
try {
|
||||
const embeds = await f.locator('embed[type="application/pdf"], iframe').all();
|
||||
for (const e of embeds) {
|
||||
const src = await e.getAttribute('src');
|
||||
const tag = await e.evaluate((el) => el.tagName);
|
||||
log(` ${tag} src=${src}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
log(` Error leyendo frame: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Intentar obtener PDF desde iframe si es URL
|
||||
for (const f of loginPage.frames()) {
|
||||
try {
|
||||
const embed = f.locator('embed[type="application/pdf"]').first();
|
||||
if (await embed.count() > 0) {
|
||||
const src = await embed.getAttribute('src');
|
||||
log(`Embed PDF src: ${src}`);
|
||||
if (src && src.startsWith('http')) {
|
||||
const resp = await f.page().context().request.get(src);
|
||||
log(`Fetch status: ${resp.status()}, content-type: ${resp.headers()['content-type']}`);
|
||||
if (resp.ok() && resp.headers()['content-type']?.includes('pdf')) {
|
||||
const body = await resp.body();
|
||||
log(`PDF size: ${body.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
log(`Error frame: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
245
apps/api/scripts/debug-csf.ts
Normal file
245
apps/api/scripts/debug-csf.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFileSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { tenantDb, prisma } from '../src/config/database.js';
|
||||
import { getDecryptedFielContribuyente } from '../src/services/contribuyente-fiel.service.js';
|
||||
|
||||
const CONTRIBUYENTE_ID = 'cc41d462-4270-4949-aa9b-f0a175784659';
|
||||
const DEBUG_DIR = join(tmpdir(), `horux-csf-debug-${Date.now()}`);
|
||||
|
||||
let step = 0;
|
||||
async function snapshot(page: any, label: string) {
|
||||
step++;
|
||||
const prefix = `${String(step).padStart(2, '0')}-${label}`;
|
||||
try {
|
||||
await page.screenshot({ path: join(DEBUG_DIR, `${prefix}.png`), fullPage: true });
|
||||
const html = await page.content().catch(() => '');
|
||||
writeFileSync(join(DEBUG_DIR, `${prefix}.html`), html);
|
||||
console.log(`[DEBUG] ${prefix} — screenshot + html guardados`);
|
||||
} catch (err: any) {
|
||||
console.error(`[DEBUG] ${prefix} — error al guardar snapshot:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function log(msg: string) {
|
||||
console.log(`[DEBUG] ${new Date().toISOString()} — ${msg}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(DEBUG_DIR, { recursive: true });
|
||||
log(`Directorio debug: ${DEBUG_DIR}`);
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: 'DESPACHO_MPG95QP7_XZVFF' },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const fiel = await getDecryptedFielContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
if (!fiel) throw new Error('No hay FIEL');
|
||||
|
||||
log(`FIEL obtenida para RFC ${fiel.rfc}`);
|
||||
|
||||
const tempId = randomUUID();
|
||||
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');
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal';
|
||||
|
||||
log('Lanzando Chromium...');
|
||||
const start = Date.now();
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-blink-features=AutomationControlled'],
|
||||
ignoreDefaultArgs: ['--enable-automation'],
|
||||
});
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
acceptDownloads: true,
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
});
|
||||
|
||||
const publicPage = await context.newPage();
|
||||
publicPage.setDefaultTimeout(120_000);
|
||||
|
||||
log(`Navegando a ${PUBLIC_URL}`);
|
||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
|
||||
await publicPage.waitForTimeout(3000);
|
||||
await snapshot(publicPage, 'public-page');
|
||||
|
||||
log('Buscando acordeón "Obtén la constancia"...');
|
||||
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',
|
||||
).first();
|
||||
|
||||
try {
|
||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
||||
log('Acordeón encontrado, haciendo click...');
|
||||
await obtenerLocator.scrollIntoViewIfNeeded();
|
||||
await obtenerLocator.click();
|
||||
await publicPage.waitForTimeout(1500);
|
||||
await snapshot(publicPage, 'after-accordion-click');
|
||||
} catch (err: any) {
|
||||
log(`ERROR acordeón: ${err.message}`);
|
||||
await snapshot(publicPage, 'accordion-error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
log('Buscando botón SERVICIO...');
|
||||
const servicioLocator = publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first();
|
||||
try {
|
||||
await servicioLocator.waitFor({ state: 'visible', timeout: 120_000 });
|
||||
log('Botón SERVICIO encontrado, haciendo click...');
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
|
||||
await servicioLocator.click();
|
||||
log('Esperando popup de login...');
|
||||
var loginPage = await popupPromise;
|
||||
log(`Popup abierto: ${loginPage.url()}`);
|
||||
await loginPage.waitForLoadState('domcontentloaded');
|
||||
await snapshot(loginPage, 'login-popup');
|
||||
} catch (err: any) {
|
||||
log(`ERROR SERVICIO/popup: ${err.message}`);
|
||||
await snapshot(publicPage, 'servicio-error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
loginPage!.setDefaultTimeout(120_000);
|
||||
|
||||
log('Buscando botón e.firma...');
|
||||
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]'
|
||||
).first();
|
||||
try {
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 120_000 });
|
||||
log('Botón e.firma encontrado, haciendo click...');
|
||||
await efirmaBtn.scrollIntoViewIfNeeded();
|
||||
await efirmaBtn.click();
|
||||
await snapshot(loginPage!, 'after-efirma-click');
|
||||
} catch (err: any) {
|
||||
log(`ERROR e.firma: ${err.message}`);
|
||||
await snapshot(loginPage!, 'efirma-error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
log('Esperando inputs de archivo...');
|
||||
const fileInputs = loginPage!.locator('input[type="file"]');
|
||||
try {
|
||||
await fileInputs.first().waitFor({ state: 'attached', timeout: 60_000 });
|
||||
} catch {
|
||||
log('Click no disparó handler, reintentando con dispatchEvent...');
|
||||
await efirmaBtn.dispatchEvent('click');
|
||||
await fileInputs.first().waitFor({ state: 'attached', timeout: 60_000 });
|
||||
await snapshot(loginPage!, 'after-dispatch-click');
|
||||
}
|
||||
|
||||
log('Subiendo .cer y .key...');
|
||||
await fileInputs.nth(0).setInputFiles(cerPath);
|
||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||
await snapshot(loginPage!, 'after-file-upload');
|
||||
|
||||
log('Esperando auto-populado de RFC...');
|
||||
try {
|
||||
await loginPage!.waitForFunction(
|
||||
() => {
|
||||
const rfc = document.getElementById('rfc') as HTMLInputElement | null;
|
||||
return rfc !== null && rfc.value.length >= 12;
|
||||
},
|
||||
null,
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
log('RFC auto-populado');
|
||||
} catch (err: any) {
|
||||
log(`ERROR RFC auto-populate: ${err.message}`);
|
||||
await snapshot(loginPage!, 'rfc-autopopulate-error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
log('Llenando password y enviando...');
|
||||
await loginPage!.locator('input[type="password"]').first().fill(fiel.password);
|
||||
await snapshot(loginPage!, 'before-submit');
|
||||
await loginPage!.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true });
|
||||
|
||||
log('Esperando redirección a portal SAT...');
|
||||
await loginPage!.waitForURL(
|
||||
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
await loginPage!.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage!.waitForTimeout(2000);
|
||||
await snapshot(loginPage!, 'after-login-redirect');
|
||||
log(`URL post-login: ${loginPage!.url()}`);
|
||||
|
||||
const bodyText = await loginPage!.locator('body').innerText().catch(() => '');
|
||||
if (/contrase[nñ]a\\s+incorrecta|usuario.*no.*v[aá]lido|firma\\s+inv[aá]lida/i.test(bodyText)) {
|
||||
throw new Error('FIEL inválida o contraseña incorrecta');
|
||||
}
|
||||
|
||||
log('Buscando botón Generar Constancia...');
|
||||
const GENERAR_SELECTORS = [
|
||||
'button:has-text("Generar Constancia")',
|
||||
'button:has-text("Generar constancia")',
|
||||
'input[type="button"][value*="Generar" i]',
|
||||
'input[type="submit"][value*="Generar" i]',
|
||||
'a:has-text("Generar Constancia")',
|
||||
'a:has-text("Generar constancia")',
|
||||
].join(', ');
|
||||
|
||||
let generarLocator: any = null;
|
||||
let foundFrame: any = null;
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const frame of loginPage!.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 (generarLocator) break;
|
||||
await loginPage!.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
if (!generarLocator) {
|
||||
log('No se encontró botón Generar Constancia');
|
||||
await snapshot(loginPage!, 'generar-not-found');
|
||||
throw new Error('Botón Generar Constancia no encontrado');
|
||||
}
|
||||
|
||||
log('Botón Generar Constancia encontrado, haciendo click...');
|
||||
await generarLocator.scrollIntoViewIfNeeded();
|
||||
await generarLocator.click();
|
||||
await loginPage!.waitForTimeout(5000);
|
||||
await snapshot(loginPage!, 'after-generar-click');
|
||||
|
||||
log('Proceso completado sin timeout');
|
||||
} catch (err: any) {
|
||||
log(`ERROR: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
log(`Cerrando browser. Tiempo total: ${(Date.now() - start) / 1000}s`);
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
log('FIN OK');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((e) => {
|
||||
log(`FIN ERROR: ${e.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
150
apps/api/scripts/fix-null-cfdi-fields.ts
Normal file
150
apps/api/scripts/fix-null-cfdi-fields.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Script para reparar CFDIs tipo I/E que tienen XML pero campos nulos
|
||||
* (subtotal, pac, tipo_cambio, impuestos, etc.)
|
||||
*
|
||||
* El problema: algunos CFDIs fueron insertados por saveMetadata y luego
|
||||
* saveCfdis no actualizó todos los campos correctamente.
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { parseXml } from '../src/services/sat/sat-parser.service';
|
||||
|
||||
const DB_CONFIG = {
|
||||
host: 'localhost',
|
||||
user: 'postgres',
|
||||
password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb',
|
||||
};
|
||||
|
||||
const TENANTS = [
|
||||
{ db: 'horux_auza640701ti9', rfc: 'AUZA640701TI9' },
|
||||
{ db: 'horux_hts240708lja', rfc: 'HTS240708LJA' },
|
||||
{ db: 'horux_momc8311199va', rfc: 'MOMC8311199VA' },
|
||||
{ db: 'horux_roem691011ez4', rfc: 'ROEM691011EZ4' },
|
||||
{ db: 'horux_toah680201ra2', rfc: 'TOAH680201RA2' },
|
||||
{ db: 'horux_tora0007099r6', rfc: 'TORA0007099R6' },
|
||||
{ db: 'horux_despacho_mpg95qp7_xzvff', rfc: 'MPG95QP7_XZVFF' },
|
||||
];
|
||||
|
||||
async function fixTenant(dbName: string, rfc: string): Promise<{ fixed: number; errors: number }> {
|
||||
const pool = new Pool({ ...DB_CONFIG, database: dbName });
|
||||
let fixed = 0;
|
||||
let errors = 0;
|
||||
|
||||
try {
|
||||
// CFDIs tipo I/E con XML pero sin subtotal (indica que saveCfdis no actualizó bien)
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, uuid, xml_original, type
|
||||
FROM cfdis
|
||||
WHERE tipo_comprobante IN ('I', 'E')
|
||||
AND xml_original IS NOT NULL AND xml_original <> ''
|
||||
AND subtotal IS NULL
|
||||
`);
|
||||
|
||||
console.log(`[${rfc}] ${rows.length} CFDIs a reparar`);
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const downloadType = row.type === 'EMITIDO' ? 'emitidos' : 'recibidos';
|
||||
const parsed = parseXml(row.xml_original, downloadType);
|
||||
if (!parsed) {
|
||||
console.warn(`[${rfc}] No se pudo parsear UUID ${row.uuid}`);
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const tc = parsed.tipoCambio || 1;
|
||||
const m = (v: number) => v * tc;
|
||||
|
||||
await pool.query(`
|
||||
UPDATE cfdis SET
|
||||
subtotal = $1,
|
||||
subtotal_mxn = $2,
|
||||
descuento = $3,
|
||||
descuento_mxn = $4,
|
||||
total = $5,
|
||||
total_mxn = $6,
|
||||
moneda = $7,
|
||||
tipo_cambio = $8,
|
||||
metodo_pago = $9,
|
||||
forma_pago = $10,
|
||||
uso_cfdi = $11,
|
||||
pac = $12,
|
||||
fecha_cert_sat = $13,
|
||||
uuid_relacionado = $14,
|
||||
isr_retencion = $15,
|
||||
isr_retencion_mxn = $16,
|
||||
iva_traslado = $17,
|
||||
iva_traslado_mxn = $18,
|
||||
iva_retencion = $19,
|
||||
iva_retencion_mxn = $20,
|
||||
ieps_traslado = $21,
|
||||
ieps_traslado_mxn = $22,
|
||||
ieps_retencion = $23,
|
||||
ieps_retencion_mxn = $24,
|
||||
impuestos_locales_trasladado = $25,
|
||||
impuestos_locales_trasladado_mxn = $26,
|
||||
impuestos_locales_retenidos = $27,
|
||||
impuestos_locales_retenidos_mxn = $28,
|
||||
regimen_fiscal_emisor = $29,
|
||||
regimen_fiscal_receptor = $30,
|
||||
codigo_postal_receptor = $31,
|
||||
serie = $32,
|
||||
folio = $33,
|
||||
cfdi_tipo_relacion = $34,
|
||||
cfdis_relacionados = $35,
|
||||
actualizado_en = NOW()
|
||||
WHERE id = $36
|
||||
`, [
|
||||
parsed.subtotal, m(parsed.subtotal),
|
||||
parsed.descuento, m(parsed.descuento),
|
||||
parsed.total, m(parsed.total),
|
||||
parsed.moneda, tc,
|
||||
parsed.metodoPago, parsed.formaPago, parsed.usoCfdi,
|
||||
parsed.pac, parsed.fechaCertSat,
|
||||
parsed.uuidRelacionado,
|
||||
parsed.isrRetencion, m(parsed.isrRetencion),
|
||||
parsed.ivaTraslado, m(parsed.ivaTraslado),
|
||||
parsed.ivaRetencion, m(parsed.ivaRetencion),
|
||||
parsed.iepsTraslado, m(parsed.iepsTraslado),
|
||||
parsed.iepsRetencion, m(parsed.iepsRetencion),
|
||||
parsed.impuestosLocalesTrasladado, m(parsed.impuestosLocalesTrasladado),
|
||||
parsed.impuestosLocalesRetenidos, m(parsed.impuestosLocalesRetenidos),
|
||||
parsed.regimenFiscalEmisor, parsed.regimenFiscalReceptor,
|
||||
parsed.codigoPostalReceptor,
|
||||
parsed.serie, parsed.folio,
|
||||
parsed.cfdiTipoRelacion, parsed.cfdisRelacionados,
|
||||
row.id,
|
||||
]);
|
||||
|
||||
fixed++;
|
||||
} catch (err: any) {
|
||||
console.error(`[${rfc}] Error reparando UUID ${row.uuid}:`, err.message);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
return { fixed, errors };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let totalFixed = 0;
|
||||
let totalErrors = 0;
|
||||
|
||||
for (const tenant of TENANTS) {
|
||||
try {
|
||||
const result = await fixTenant(tenant.db, tenant.rfc);
|
||||
totalFixed += result.fixed;
|
||||
totalErrors += result.errors;
|
||||
console.log(`[${tenant.rfc}] Reparados: ${result.fixed}, Errores: ${result.errors}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[${tenant.rfc}] Error general:`, err.message);
|
||||
totalErrors++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== TOTAL: Reparados ${totalFixed}, Errores ${totalErrors} ===`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
16
apps/api/scripts/mint-token.ts
Normal file
16
apps/api/scripts/mint-token.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'dotenv/config';
|
||||
import { generateAccessToken } from '../src/auth/tokens.js';
|
||||
|
||||
async function main() {
|
||||
const token = generateAccessToken({
|
||||
userId: 'fbcb8ed9-a92a-4f13-ab0f-5b6299c7ec0d',
|
||||
email: 'carlos@horuxfin.com',
|
||||
role: 'owner' as any,
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: ['platform_admin' as any],
|
||||
tokenVersion: 1,
|
||||
});
|
||||
process.stdout.write(token + '\n');
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
65
apps/api/scripts/probe-sat-raw.ts
Normal file
65
apps/api/scripts/probe-sat-raw.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'dotenv/config';
|
||||
import { inspect } from 'node:util';
|
||||
import { tenantDb } from '../src/config/database.js';
|
||||
import { getDecryptedFielContribuyente } from '../src/services/contribuyente-fiel.service.js';
|
||||
import { createSatService } from '../src/services/sat/sat-client.service.js';
|
||||
import {
|
||||
QueryParameters,
|
||||
DateTimePeriod,
|
||||
DownloadType,
|
||||
RequestType,
|
||||
DocumentStatus,
|
||||
} from '@nodecfdi/sat-ws-descarga-masiva';
|
||||
|
||||
// Captura la respuesta CRUDA del SAT cuando rechaza con "Error no controlado".
|
||||
|
||||
const TENANT_ID = '81116985-03cd-4843-97ba-05e8be9917c6'; // auza
|
||||
const DB = 'horux_auza640701ti9';
|
||||
const CONTRIBUYENTE_ID = 'bb921e1d-ed49-4139-bb6f-bca28980050f';
|
||||
|
||||
function formatDateForSatLocal(d: Date): string {
|
||||
// mismo formato que usa el cliente: YYYY-MM-DDTHH:mm:ss en hora México
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'America/Mexico_City',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
}).format(d).replace(' ', 'T');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool(TENANT_ID, DB);
|
||||
const fiel = await getDecryptedFielContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
if (!fiel) { console.log('Sin FIEL'); return; }
|
||||
const service = createSatService({ cerContent: fiel.cerContent, keyContent: fiel.keyContent, password: fiel.password });
|
||||
|
||||
const now = new Date();
|
||||
const inicio = new Date(now.getTime() - 2 * 86400000);
|
||||
const fin = new Date(now.getTime() - 1 * 86400000);
|
||||
|
||||
console.log(`Request: emitidos/cfdi(xml) ${formatDateForSatLocal(inicio)} → ${formatDateForSatLocal(fin)}`);
|
||||
|
||||
const period = DateTimePeriod.createFromValues(formatDateForSatLocal(inicio), formatDateForSatLocal(fin));
|
||||
const params = QueryParameters
|
||||
.create(period, new DownloadType('issued'), new RequestType('xml'))
|
||||
.withDocumentStatus(new DocumentStatus('active'));
|
||||
|
||||
try {
|
||||
const result = await service.query(params);
|
||||
const status = result.getStatus();
|
||||
console.log('--- Status ---');
|
||||
console.log('code:', status.getCode());
|
||||
console.log('message:', JSON.stringify(status.getMessage()));
|
||||
console.log('isAccepted:', status.isAccepted());
|
||||
console.log('status props:', Object.getOwnPropertyNames(status));
|
||||
console.log('status dump:', inspect(status, { depth: 4 }));
|
||||
console.log('--- Result ---');
|
||||
console.log('result props:', Object.getOwnPropertyNames(result));
|
||||
try { console.log('requestId:', result.getRequestId()); } catch { console.log('requestId: (no disponible)'); }
|
||||
console.log('result dump:', inspect(result, { depth: 3 }));
|
||||
} catch (e: any) {
|
||||
console.log('EXCEPTION:', e?.message);
|
||||
console.log(inspect(e, { depth: 3 }));
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
40
apps/api/scripts/probe-sat.ts
Normal file
40
apps/api/scripts/probe-sat.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'dotenv/config';
|
||||
import { tenantDb } from '../src/config/database.js';
|
||||
import { getDecryptedFielContribuyente } from '../src/services/contribuyente-fiel.service.js';
|
||||
import { createSatService, querySat } from '../src/services/sat/sat-client.service.js';
|
||||
|
||||
// Sonda de UNA sola solicitud al SAT (bajo volumen, hora normal) para discriminar
|
||||
// rate-limit por ráfaga vs. bloqueo/caída del SAT. No descarga paquetes; solo
|
||||
// crea la solicitud y reporta el statusCode/mensaje del SAT.
|
||||
|
||||
const TENANT_ID = '81116985-03cd-4843-97ba-05e8be9917c6'; // auza
|
||||
const DB = 'horux_auza640701ti9';
|
||||
const CONTRIBUYENTE_ID = 'bb921e1d-ed49-4139-bb6f-bca28980050f';
|
||||
|
||||
async function probe(label: string, fn: () => Promise<any>) {
|
||||
try {
|
||||
const r = await fn();
|
||||
console.log(`[${label}] success=${r.success} statusCode=${r.statusCode ?? '-'} message="${r.message}" requestId=${r.requestId ?? '-'}`);
|
||||
} catch (e: any) {
|
||||
console.log(`[${label}] EXCEPTION: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool(TENANT_ID, DB);
|
||||
const fiel = await getDecryptedFielContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
if (!fiel) { console.log('Sin FIEL'); return; }
|
||||
console.log(`FIEL rfc=${fiel.rfc} ok. Sondando SAT con UNA solicitud...`);
|
||||
|
||||
const service = createSatService({ cerContent: fiel.cerContent, keyContent: fiel.keyContent, password: fiel.password });
|
||||
|
||||
const now = new Date();
|
||||
const d1 = new Date(now.getTime() - 2 * 86400000);
|
||||
const d0 = new Date(now.getTime() - 1 * 86400000);
|
||||
|
||||
await probe('cfdi/emitidos 1d', () => querySat(service, d1, d0, 'emitidos', 'cfdi'));
|
||||
await probe('cfdi/recibidos 1d', () => querySat(service, d1, d0, 'recibidos', 'cfdi'));
|
||||
await probe('metadata/emitidos 1d', () => querySat(service, d1, d0, 'emitidos', 'metadata'));
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
45
apps/api/scripts/probe-sat2.ts
Normal file
45
apps/api/scripts/probe-sat2.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'dotenv/config';
|
||||
import { tenantDb } from '../src/config/database.js';
|
||||
import { getDecryptedFielContribuyente } from '../src/services/contribuyente-fiel.service.js';
|
||||
import { createSatService, querySat } from '../src/services/sat/sat-client.service.js';
|
||||
|
||||
// Discrimina la causa del "Error no controlado": ¿hora del día, largo del rango
|
||||
// o que el rango termine "ahora"? 3 solicitudes de UNA FIEL, una a la vez.
|
||||
|
||||
const TENANT_ID = '81116985-03cd-4843-97ba-05e8be9917c6'; // auza
|
||||
const DB = 'horux_auza640701ti9';
|
||||
const CONTRIBUYENTE_ID = 'bb921e1d-ed49-4139-bb6f-bca28980050f';
|
||||
|
||||
async function probe(label: string, fn: () => Promise<any>) {
|
||||
try {
|
||||
const r = await fn();
|
||||
console.log(`[${label}] success=${r.success} statusCode=${r.statusCode ?? '-'} message="${r.message}" requestId=${r.requestId ?? '-'}`);
|
||||
} catch (e: any) {
|
||||
console.log(`[${label}] EXCEPTION: ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pool = await tenantDb.getPool(TENANT_ID, DB);
|
||||
const fiel = await getDecryptedFielContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
if (!fiel) { console.log('Sin FIEL'); return; }
|
||||
const service = createSatService({ cerContent: fiel.cerContent, keyContent: fiel.keyContent, password: fiel.password });
|
||||
|
||||
const now = new Date();
|
||||
console.log(`now = ${now.toISOString()}`);
|
||||
|
||||
const d = (daysBack: number, endOfDay = false) => {
|
||||
const x = new Date(now.getTime() - daysBack * 86400000);
|
||||
if (endOfDay) x.setUTCHours(23, 59, 59, 0);
|
||||
return x;
|
||||
};
|
||||
|
||||
// A: réplica exacta de la sonda exitosa de mediodía (1 día, terminando ayer)
|
||||
await probe('A 1d ayer (replica mediodia)', () => querySat(service, d(2), d(1), 'emitidos', 'cfdi'));
|
||||
// B: réplica del daily que falla (7 días, terminando AHORA)
|
||||
await probe('B 7d terminando ahora (replica daily)', () => querySat(service, d(7), now, 'emitidos', 'cfdi'));
|
||||
// C: 7 días pero terminando ayer 23:59 (aisla "termina ahora" vs "7 días")
|
||||
await probe('C 7d terminando ayer', () => querySat(service, d(8, false), d(1, true), 'emitidos', 'cfdi'));
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });
|
||||
28
apps/api/scripts/register-metabase-existing.ts
Normal file
28
apps/api/scripts/register-metabase-existing.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import * as metabaseService from '../src/services/metabase.service.js';
|
||||
|
||||
async function main() {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
console.log(`[METABASE] Registering ${tenants.length} existing tenants...`);
|
||||
|
||||
for (const tenant of tenants) {
|
||||
if (!tenant.databaseName) continue;
|
||||
console.log(`[METABASE] Registering: ${tenant.nombre} (${tenant.databaseName})`);
|
||||
await metabaseService.registerDatabase({
|
||||
nombre: tenant.nombre,
|
||||
dbName: tenant.databaseName,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[METABASE] Done');
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
31
apps/api/scripts/relanzar-daily.ts
Normal file
31
apps/api/scripts/relanzar-daily.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma } from '../src/config/database.js';
|
||||
|
||||
async function main() {
|
||||
const jobs = await prisma.$queryRawUnsafe(`
|
||||
SELECT DISTINCT ON (tenant_id, contribuyente_id)
|
||||
tenant_id, contribuyente_id
|
||||
FROM sat_sync_jobs
|
||||
WHERE type = 'daily' AND DATE(started_at) = CURRENT_DATE AND status = 'failed'
|
||||
ORDER BY tenant_id, contribuyente_id, started_at DESC
|
||||
`) as any[];
|
||||
console.log('Relanzando', jobs.length, 'daily syncs');
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const contribuyenteId = job.contribuyente_id;
|
||||
const newId = await startSync(job.tenant_id, 'daily', undefined, undefined, contribuyenteId);
|
||||
console.log('Relanzado', newId, 'para tenant', job.tenant_id, 'contrib', contribuyenteId);
|
||||
success++;
|
||||
} catch (err: any) {
|
||||
console.error('Error relanzando', job.tenant_id, job.contribuyente_id, err.message);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
console.log('Done. Éxitos:', success, 'Fallos:', failed);
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
12
apps/api/scripts/run-recovery-now.ts
Normal file
12
apps/api/scripts/run-recovery-now.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { runRecoverySyncJob } from '../src/jobs/sat-sync.job.js';
|
||||
|
||||
async function main() {
|
||||
console.log('[RECOVERY NOW] Ejecutando recovery manual para todos los tenants con FIEL');
|
||||
await runRecoverySyncJob();
|
||||
console.log('[RECOVERY NOW] Finalizado');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[RECOVERY NOW] Error fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
22
apps/api/scripts/sync-aaron-mayo.ts
Normal file
22
apps/api/scripts/sync-aaron-mayo.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
|
||||
const TENANT_ID = '81116985-03cd-4843-97ba-05e8be9917c6';
|
||||
const CONTRIBUYENTE_ID = 'bb921e1d-ed49-4139-bb6f-bca28980050f';
|
||||
|
||||
function getYesterdayEnd(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dateFrom = new Date('2026-05-01T00:00:00');
|
||||
const dateTo = getYesterdayEnd();
|
||||
console.log(`[AARON MAYO-JUN] Lanzando sync ${dateFrom.toISOString()} → ${dateTo.toISOString()}`);
|
||||
const jobId = await startSync(TENANT_ID, 'initial', dateFrom, dateTo, CONTRIBUYENTE_ID);
|
||||
console.log(`[AARON MAYO-JUN] Job iniciado: ${jobId}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[AARON MAYO-JUN] Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
22
apps/api/scripts/sync-aaron-recovery.ts
Normal file
22
apps/api/scripts/sync-aaron-recovery.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
|
||||
const TENANT_ID = '81116985-03cd-4843-97ba-05e8be9917c6';
|
||||
const CONTRIBUYENTE_ID = 'bb921e1d-ed49-4139-bb6f-bca28980050f';
|
||||
|
||||
function getYesterdayEnd(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dateFrom = new Date('2026-01-01T00:00:00');
|
||||
const dateTo = getYesterdayEnd();
|
||||
console.log(`[AARON RECOVERY] Lanzando sync ${dateFrom.toISOString()} → ${dateTo.toISOString()}`);
|
||||
const jobId = await startSync(TENANT_ID, 'initial', dateFrom, dateTo, CONTRIBUYENTE_ID);
|
||||
console.log(`[AARON RECOVERY] Job iniciado: ${jobId}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[AARON RECOVERY] Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
apps/api/scripts/sync-contribuyente.ts
Normal file
25
apps/api/scripts/sync-contribuyente.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
|
||||
async function main() {
|
||||
const [tenantId, contribuyenteId, rfc, dateFromStr, dateToStr] = process.argv.slice(2);
|
||||
|
||||
if (!tenantId || !contribuyenteId || !rfc) {
|
||||
console.error('Uso: npx tsx scripts/sync-contribuyente.ts <tenantId> <contribuyenteId> <rfc> [dateFrom] [dateTo]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dateFrom = dateFromStr ? new Date(dateFromStr) : new Date(new Date().getFullYear() - 6, 0, 1);
|
||||
const dateTo = dateToStr ? new Date(dateToStr) : new Date();
|
||||
|
||||
try {
|
||||
console.log(`[SYNC] Iniciando sync para ${rfc} (${contribuyenteId}) en tenant ${tenantId}`);
|
||||
console.log(`[SYNC] Rango: ${dateFrom.toISOString()} → ${dateTo.toISOString()}`);
|
||||
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo, contribuyenteId);
|
||||
console.log(`[SYNC] Job iniciado: ${jobId}`);
|
||||
} catch (error: any) {
|
||||
console.error('[SYNC] Error:', error.message || error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
144
apps/api/scripts/sync-daily-cyl-failed.ts
Normal file
144
apps/api/scripts/sync-daily-cyl-failed.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_ID = '49b60455-c501-4ca2-b4bc-36ea7f2951a2';
|
||||
const CONCURRENCY = 1;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const FAILED_SINCE = '2026-07-21 19:00:00';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForActiveSync(tenantId: string, contribuyenteId: string): Promise<'clean' | 'active'> {
|
||||
const active = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contribuyenteId,
|
||||
status: { in: ['pending', 'running'] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!active) return 'clean';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: string): Promise<{ status: string; error: string | null; found: number; inserted: number; updated: number; progress: number }> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new Error(`Job ${jobId} no encontrado`);
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'pending') {
|
||||
return {
|
||||
status: job.status,
|
||||
error: job.errorMessage,
|
||||
found: job.cfdisFound || 0,
|
||||
inserted: job.cfdisInserted || 0,
|
||||
updated: job.cfdisUpdated || 0,
|
||||
progress: job.progressPercent || 0,
|
||||
};
|
||||
}
|
||||
console.log(`[WAIT] Job ${jobId} status=${job.status}, progress=${job.progressPercent}%. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const item = items[index++];
|
||||
await fn(item);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: concurrency }, () => worker());
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: TENANT_ID },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`[C&L Failed] Tenant ${TENANT_ID} no encontrado`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[C&L Failed] Tenant: ${tenant.nombre} (${tenant.rfc}) | DB: ${tenant.databaseName}`);
|
||||
|
||||
// Obtener contribuyentes que tuvieron al menos un job failed desde FAILED_SINCE
|
||||
const failedRows = await prisma.$queryRawUnsafe<Array<{ contribuyente_id: string }>>(`
|
||||
SELECT contribuyente_id
|
||||
FROM sat_sync_jobs
|
||||
WHERE tenant_id = '${TENANT_ID}'
|
||||
AND status = 'failed'
|
||||
AND created_at > '${FAILED_SINCE}'
|
||||
GROUP BY contribuyente_id
|
||||
`);
|
||||
|
||||
const failedIds = failedRows.map((r) => r.contribuyente_id);
|
||||
console.log(`[C&L Failed] Contribuyentes con fallos desde ${FAILED_SINCE}: ${failedIds.length}`);
|
||||
|
||||
if (failedIds.length === 0) {
|
||||
console.log('[C&L Failed] No hay contribuyentes para reintentar');
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const { rows: contribuyentes } = await pool.query(
|
||||
`
|
||||
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = ANY($1::uuid[])
|
||||
ORDER BY eg.nombre
|
||||
`,
|
||||
[failedIds]
|
||||
);
|
||||
|
||||
console.log(`[C&L Failed] Contribuyentes resueltos: ${contribuyentes.length}`);
|
||||
|
||||
const errors: string[] = [];
|
||||
let completed = 0;
|
||||
|
||||
await runWithConcurrency(contribuyentes, CONCURRENCY, async (c: any) => {
|
||||
console.log(`\n[SYNC] === ${c.rfc} | ${c.nombre} ===`);
|
||||
try {
|
||||
const activeState = await waitForActiveSync(tenant.id, c.id);
|
||||
if (activeState === 'active') {
|
||||
console.log(`[SKIP] ${c.rfc} tiene sync activo/pendiente; se omite`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = await startSync(tenant.id, 'daily', undefined, undefined, c.id);
|
||||
console.log(`[SYNC] Job iniciado: ${jobId} para ${c.rfc}`);
|
||||
|
||||
const result = await waitForJob(jobId);
|
||||
console.log(`[SYNC] ${c.rfc} finalizado: status=${result.status}, found=${result.found}, inserted=${result.inserted}, updated=${result.updated}, progress=${result.progress}%`);
|
||||
if (result.status === 'completed') completed++;
|
||||
if (result.error) {
|
||||
console.error(`[SYNC] Error en ${c.rfc}: ${result.error}`);
|
||||
errors.push(`${c.rfc}: ${result.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SYNC] Error lanzando sync para ${c.rfc}:`, error.message || error);
|
||||
errors.push(`${c.rfc}: ${error.message || error}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n[C&L Failed] Proceso finalizado. Completados: ${completed}/${contribuyentes.length}`);
|
||||
if (errors.length > 0) {
|
||||
console.error(`[C&L Failed] Errores (${errors.length}):`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('[C&L Failed] Error fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
145
apps/api/scripts/sync-daily-cyl-staged.ts
Normal file
145
apps/api/scripts/sync-daily-cyl-staged.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_ID = '49b60455-c501-4ca2-b4bc-36ea7f2951a2';
|
||||
const CONCURRENCY = 3;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const CHUNK_SIZE = 12;
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForActiveSync(tenantId: string, contribuyenteId: string): Promise<'clean' | 'active'> {
|
||||
const active = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contribuyenteId,
|
||||
status: { in: ['pending', 'running'] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!active) return 'clean';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: string): Promise<{ status: string; error: string | null; found: number; inserted: number; updated: number; progress: number }> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new Error(`Job ${jobId} no encontrado`);
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'pending') {
|
||||
return {
|
||||
status: job.status,
|
||||
error: job.errorMessage,
|
||||
found: job.cfdisFound || 0,
|
||||
inserted: job.cfdisInserted || 0,
|
||||
updated: job.cfdisUpdated || 0,
|
||||
progress: job.progressPercent || 0,
|
||||
};
|
||||
}
|
||||
console.log(`[WAIT] Job ${jobId} status=${job.status}, progress=${job.progressPercent}%. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const item = items[index++];
|
||||
await fn(item);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: concurrency }, () => worker());
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
chunks.push(arr.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: TENANT_ID },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`[C&L Staged] Tenant ${TENANT_ID} no encontrado`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[C&L Staged] Tenant: ${tenant.nombre} (${tenant.rfc}) | DB: ${tenant.databaseName}`);
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const { rows: contribuyentes } = await pool.query(`
|
||||
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
|
||||
WHERE f.is_active = true
|
||||
AND f.valid_until >= NOW()
|
||||
ORDER BY eg.nombre
|
||||
`);
|
||||
|
||||
console.log(`[C&L Staged] Contribuyentes con FIEL activa: ${contribuyentes.length}`);
|
||||
|
||||
const chunks = chunkArray(contribuyentes, CHUNK_SIZE);
|
||||
console.log(`[C&L Staged] Rondas: ${chunks.length} de hasta ${CHUNK_SIZE} contribuyentes`);
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
console.log(`\n[C&L Staged] === RONDA ${i + 1}/${chunks.length} (${chunk.length} contribuyentes) ===`);
|
||||
|
||||
await runWithConcurrency(chunk, CONCURRENCY, async (c: any) => {
|
||||
console.log(`\n[SYNC] === ${c.rfc} | ${c.nombre} ===`);
|
||||
try {
|
||||
const activeState = await waitForActiveSync(tenant.id, c.id);
|
||||
if (activeState === 'active') {
|
||||
console.log(`[SKIP] ${c.rfc} tiene sync activo/pendiente; se omite`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = await startSync(tenant.id, 'daily', undefined, undefined, c.id);
|
||||
console.log(`[SYNC] Job iniciado: ${jobId} para ${c.rfc}`);
|
||||
|
||||
const result = await waitForJob(jobId);
|
||||
console.log(`[SYNC] ${c.rfc} finalizado: status=${result.status}, found=${result.found}, inserted=${result.inserted}, updated=${result.updated}, progress=${result.progress}%`);
|
||||
if (result.error) {
|
||||
console.error(`[SYNC] Error en ${c.rfc}: ${result.error}`);
|
||||
errors.push(`${c.rfc}: ${result.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SYNC] Error lanzando sync para ${c.rfc}:`, error.message || error);
|
||||
errors.push(`${c.rfc}: ${error.message || error}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (i < chunks.length - 1) {
|
||||
console.log(`[C&L Staged] Pausa de 1 hora antes de la siguiente ronda...`);
|
||||
await sleep(HOUR_MS);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n[C&L Staged] Proceso finalizado');
|
||||
if (errors.length > 0) {
|
||||
console.error(`[C&L Staged] Errores (${errors.length}):`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('[C&L Staged] Error fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
140
apps/api/scripts/sync-daily-cyl.ts
Normal file
140
apps/api/scripts/sync-daily-cyl.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_ID = '49b60455-c501-4ca2-b4bc-36ea7f2951a2';
|
||||
const CONCURRENCY = 3;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForActiveSync(tenantId: string, contribuyenteId: string): Promise<'clean' | 'pending-retry'> {
|
||||
const active = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contribuyenteId,
|
||||
status: { in: ['pending', 'running'] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!active) return 'clean';
|
||||
|
||||
if (active.status === 'pending') {
|
||||
console.log(`[SKIP] Contribuyente ${contribuyenteId} tiene reintento programado (${active.id}); se omite en este ciclo.`);
|
||||
return 'pending-retry';
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const running = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contribuyenteId,
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
if (!running) return 'clean';
|
||||
console.log(`[WAIT] Sync activo para ${contribuyenteId}. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: string): Promise<{ status: string; error: string | null; found: number; inserted: number; updated: number; progress: number }> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new Error(`Job ${jobId} no encontrado`);
|
||||
// Un job que queda `pending` con nextRetryAt fue un fallo transitorio; el
|
||||
// retry automático lo retomará. No bloquear el batch manual esperando horas.
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'pending') {
|
||||
return {
|
||||
status: job.status,
|
||||
error: job.errorMessage,
|
||||
found: job.cfdisFound || 0,
|
||||
inserted: job.cfdisInserted || 0,
|
||||
updated: job.cfdisUpdated || 0,
|
||||
progress: job.progressPercent || 0,
|
||||
};
|
||||
}
|
||||
console.log(`[WAIT] Job ${jobId} status=${job.status}, progress=${job.progressPercent}%. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const item = items[index++];
|
||||
await fn(item);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: concurrency }, () => worker());
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: TENANT_ID },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`[C&L Daily] Tenant ${TENANT_ID} no encontrado`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[C&L Daily] Tenant: ${tenant.nombre} (${tenant.rfc}) | DB: ${tenant.databaseName}`);
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const { rows: contribuyentes } = await pool.query(`
|
||||
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
|
||||
WHERE f.is_active = true
|
||||
AND f.valid_until >= NOW()
|
||||
ORDER BY eg.nombre
|
||||
`);
|
||||
|
||||
console.log(`[C&L Daily] Contribuyentes con FIEL activa: ${contribuyentes.length}`);
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
await runWithConcurrency(contribuyentes, CONCURRENCY, async (c: any) => {
|
||||
console.log(`\n[SYNC] === ${c.rfc} | ${c.nombre} ===`);
|
||||
try {
|
||||
const activeState = await waitForActiveSync(tenant.id, c.id);
|
||||
if (activeState === 'pending-retry') {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = await startSync(tenant.id, 'daily', undefined, undefined, c.id);
|
||||
console.log(`[SYNC] Job iniciado: ${jobId} para ${c.rfc}`);
|
||||
|
||||
const result = await waitForJob(jobId);
|
||||
console.log(`[SYNC] ${c.rfc} finalizado: status=${result.status}, found=${result.found}, inserted=${result.inserted}, updated=${result.updated}, progress=${result.progress}%`);
|
||||
if (result.error) {
|
||||
console.error(`[SYNC] Error en ${c.rfc}: ${result.error}`);
|
||||
errors.push(`${c.rfc}: ${result.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SYNC] Error lanzando sync para ${c.rfc}:`, error.message || error);
|
||||
errors.push(`${c.rfc}: ${error.message || error}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n[C&L Daily] Proceso finalizado');
|
||||
if (errors.length > 0) {
|
||||
console.error(`[C&L Daily] Errores (${errors.length}):`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('[C&L Daily] Error fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
19
apps/api/scripts/sync-husberto-custom.ts
Normal file
19
apps/api/scripts/sync-husberto-custom.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
|
||||
const TENANT_ID = 'd75bf020-2008-4881-ab96-77467bf9e1fd';
|
||||
const CONTRIBUYENTE_ID = '128c0ab0-b307-492b-bb82-7e55d390f41f';
|
||||
|
||||
async function main() {
|
||||
const dateFrom = new Date('2023-01-01T00:00:00');
|
||||
const dateTo = new Date('2026-12-31T23:59:59');
|
||||
|
||||
try {
|
||||
const jobId = await startSync(TENANT_ID, 'initial', dateFrom, dateTo, CONTRIBUYENTE_ID);
|
||||
console.log(`[SYNC] Job iniciado: ${jobId}`);
|
||||
} catch (error: any) {
|
||||
console.error('[SYNC] Error:', error.message || error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
108
apps/api/scripts/sync-incomplete-contribuyentes.ts
Normal file
108
apps/api/scripts/sync-incomplete-contribuyentes.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma } from '../src/config/database.js';
|
||||
|
||||
interface ContribuyenteSync {
|
||||
tenantId: string;
|
||||
contribuyenteId: string;
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
// Fecha final = ayer (el SAT rechaza fechas futuras y posiblemente hoy por zona horaria)
|
||||
function getYesterdayEnd(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
|
||||
}
|
||||
|
||||
const CONTRIBUYENTES: ContribuyenteSync[] = [
|
||||
{ tenantId: '81116985-03cd-4843-97ba-05e8be9917c6', contribuyenteId: 'bb921e1d-ed49-4139-bb6f-bca28980050f', rfc: 'AUZA640701TI9', nombre: 'AARON AHUMADA ZEPEDA', dateFrom: '2026-01-01T00:00:00' },
|
||||
{ tenantId: '45ddd745-5037-4325-b3ec-1a85cbf7b849', contribuyenteId: 'a75fd2aa-b1c9-427a-b2e0-8a449a216f09', rfc: 'TORA0007099R6', nombre: 'ALEXA GUADALUPE TORRES ROMERO', dateFrom: '2026-01-01T00:00:00' },
|
||||
{ tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a', contribuyenteId: '4a1d6014-f705-424b-b185-7740be6a80c6', rfc: 'TORC9611214CA', nombre: 'CARLOS HUSBERTO TORRES ROMERO', dateFrom: '2026-01-01T00:00:00' },
|
||||
{ tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a', contribuyenteId: '300fb54b-e3c2-4cdc-9f4d-3893f83d74b5', rfc: 'TORC9611214CB', nombre: 'CARLOS DEMO', dateFrom: '2024-01-01T00:00:00' },
|
||||
{ tenantId: 'bcffc3ce-e5f5-4a66-b6bf-28da47bf7a41', contribuyenteId: '75a26067-66b2-4b5c-9dde-9ecc628a1e03', rfc: 'ROEM691011EZ4', nombre: 'MIGUEL ANGEL ROMERO ESTRADA', dateFrom: '2026-01-01T00:00:00' },
|
||||
{ tenantId: '49b60455-c501-4ca2-b4bc-36ea7f2951a2', contribuyenteId: '3e0fdfe6-21b4-4f21-b14c-5ff5a67e7e49', rfc: 'AAG191008HS0', nombre: 'ACTIVO AGRICOLA', dateFrom: '2022-01-01T00:00:00' },
|
||||
{ tenantId: '49b60455-c501-4ca2-b4bc-36ea7f2951a2', contribuyenteId: '8c9a714f-df72-4e97-b796-ed0d415a3829', rfc: 'DDA130507L51', nombre: 'DESARROLLO DE ALTO VALOR', dateFrom: '2026-01-01T00:00:00' },
|
||||
{ tenantId: '49b60455-c501-4ca2-b4bc-36ea7f2951a2', contribuyenteId: '41ae59e0-1945-4d9a-90bb-69e577ce8c38', rfc: 'BME0710245XA', nombre: 'BRATECH MEXICO', dateFrom: '2023-01-01T00:00:00' },
|
||||
{ tenantId: '49b60455-c501-4ca2-b4bc-36ea7f2951a2', contribuyenteId: '36b43d67-3b92-4307-95a6-c8e4053e0140', rfc: 'GCC080208478', nombre: 'GRUPO CORPORATIVO C&L Y ASOCIADOS, S.C.', dateFrom: '2022-01-01T00:00:00' },
|
||||
];
|
||||
|
||||
const DATE_TO = getYesterdayEnd();
|
||||
|
||||
const POLL_INTERVAL_MS = 60000; // 1 minuto
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForActiveSync(tenantId: string, contribuyenteId: string): Promise<void> {
|
||||
while (true) {
|
||||
const active = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contribuyenteId,
|
||||
status: { in: ['pending', 'running'] },
|
||||
},
|
||||
});
|
||||
if (!active) return;
|
||||
console.log(`[WAIT] Sync activo para ${contribuyenteId}. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: string): Promise<{ status: string; error: string | null; found: number; downloaded: number; inserted: number; updated: number; progress: number }> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new Error(`Job ${jobId} no encontrado`);
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
return {
|
||||
status: job.status,
|
||||
error: job.errorMessage,
|
||||
found: job.cfdisFound || 0,
|
||||
downloaded: job.cfdisDownloaded || 0,
|
||||
inserted: job.cfdisInserted || 0,
|
||||
updated: job.cfdisUpdated || 0,
|
||||
progress: job.progressPercent || 0,
|
||||
};
|
||||
}
|
||||
console.log(`[WAIT] Job ${jobId} status=${job.status}, progress=${job.progressPercent}%. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[SYNC ALL] Iniciando syncs secuenciales con rangos optimizados');
|
||||
|
||||
for (const c of CONTRIBUYENTES) {
|
||||
console.log(`\n[SYNC ALL] === ${c.nombre} (${c.rfc}) | ${c.dateFrom} → ${DATE_TO.toISOString()} ===`);
|
||||
|
||||
await waitForActiveSync(c.tenantId, c.contribuyenteId);
|
||||
|
||||
try {
|
||||
const jobId = await startSync(
|
||||
c.tenantId,
|
||||
'initial',
|
||||
new Date(c.dateFrom),
|
||||
DATE_TO,
|
||||
c.contribuyenteId,
|
||||
);
|
||||
console.log(`[SYNC ALL] Job iniciado: ${jobId}`);
|
||||
|
||||
const result = await waitForJob(jobId);
|
||||
console.log(`[SYNC ALL] Job ${jobId} finalizado: status=${result.status}, found=${result.found}, downloaded=${result.downloaded}, inserted=${result.inserted}, updated=${result.updated}, progress=${result.progress}%`);
|
||||
if (result.error) {
|
||||
console.error(`[SYNC ALL] Error en job: ${result.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SYNC ALL] Error lanzando sync para ${c.rfc}:`, error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n[SYNC ALL] Todos los syncs completados');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[SYNC ALL] Error fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
154
apps/api/scripts/sync-initial-cyl.ts
Normal file
154
apps/api/scripts/sync-initial-cyl.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma, tenantDb } from '../src/config/database.js';
|
||||
|
||||
const TENANT_RFC = 'DESPACHO_MPG95QP7_XZVFF';
|
||||
const CONCURRENCY = 6;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
function getYesterdayEnd(): Date {
|
||||
const now = new Date();
|
||||
// Fecha de ayer a mediodía UTC. Se usa UTC para evitar que Prisma @db.Date
|
||||
// desplace el día al convertir de local a UTC (el servidor corre en UTC).
|
||||
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate() - 1, 12, 0, 0));
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForActiveSync(tenantId: string, contribuyenteId: string): Promise<void> {
|
||||
while (true) {
|
||||
const active = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
contribuyenteId,
|
||||
status: { in: ['pending', 'running'] },
|
||||
},
|
||||
});
|
||||
if (!active) return;
|
||||
console.log(`[WAIT] Sync activo para ${contribuyenteId}. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: string): Promise<{ status: string; error: string | null; found: number; inserted: number; progress: number }> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new Error(`Job ${jobId} no encontrado`);
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
return {
|
||||
status: job.status,
|
||||
error: job.errorMessage,
|
||||
found: job.cfdisFound || 0,
|
||||
inserted: job.cfdisInserted || 0,
|
||||
progress: job.progressPercent || 0,
|
||||
};
|
||||
}
|
||||
console.log(`[WAIT] Job ${jobId} status=${job.status}, progress=${job.progressPercent}%. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let index = 0;
|
||||
|
||||
async function worker() {
|
||||
while (index < items.length) {
|
||||
const item = items[index++];
|
||||
await fn(item);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: concurrency }, () => worker());
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: TENANT_RFC },
|
||||
select: { id: true, rfc: true, nombre: true, databaseName: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
console.error(`[C&L] Tenant con RFC ${TENANT_RFC} no encontrado`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[C&L] Tenant: ${tenant.nombre} (${tenant.rfc}) | DB: ${tenant.databaseName} | ID: ${tenant.id}`);
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const { rows: contribuyentes } = await pool.query(`
|
||||
SELECT c.entidad_id AS id, c.rfc, eg.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
|
||||
WHERE f.is_active = true
|
||||
AND f.valid_until >= NOW()
|
||||
ORDER BY eg.nombre
|
||||
`);
|
||||
|
||||
console.log(`[C&L] Contribuyentes con FIEL activa: ${contribuyentes.length}`);
|
||||
|
||||
const pendientes = [];
|
||||
for (const c of contribuyentes) {
|
||||
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId: tenant.id,
|
||||
contribuyenteId: c.id,
|
||||
type: 'initial',
|
||||
status: { in: ['completed', 'running'] },
|
||||
},
|
||||
});
|
||||
if (!hasInitial) {
|
||||
pendientes.push(c);
|
||||
} else {
|
||||
console.log(`[SKIP] ${c.rfc} (${c.nombre}) ya tiene sync inicial iniciada o completada`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[C&L] Contribuyentes a sincronizar: ${pendientes.length}`);
|
||||
for (const c of pendientes) {
|
||||
console.log(` - ${c.rfc} | ${c.nombre} | ${c.id}`);
|
||||
}
|
||||
|
||||
if (pendientes.length === 0) {
|
||||
console.log('[C&L] Nada que sincronizar');
|
||||
return;
|
||||
}
|
||||
|
||||
const dateTo = getYesterdayEnd();
|
||||
const errors: string[] = [];
|
||||
|
||||
await runWithConcurrency(pendientes, CONCURRENCY, async (c: any) => {
|
||||
console.log(`\n[SYNC] === ${c.rfc} | ${c.nombre} ===`);
|
||||
try {
|
||||
await waitForActiveSync(tenant.id, c.id);
|
||||
|
||||
const jobId = await startSync(tenant.id, 'initial', undefined, dateTo, c.id);
|
||||
console.log(`[SYNC] Job iniciado: ${jobId} para ${c.rfc}`);
|
||||
|
||||
const result = await waitForJob(jobId);
|
||||
console.log(`[SYNC] ${c.rfc} finalizado: status=${result.status}, found=${result.found}, inserted=${result.inserted}, progress=${result.progress}%`);
|
||||
if (result.error) {
|
||||
console.error(`[SYNC] Error en ${c.rfc}: ${result.error}`);
|
||||
errors.push(`${c.rfc}: ${result.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SYNC] Error lanzando sync para ${c.rfc}:`, error.message || error);
|
||||
errors.push(`${c.rfc}: ${error.message || error}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n[C&L] Proceso finalizado');
|
||||
if (errors.length > 0) {
|
||||
console.error(`[C&L] Errores (${errors.length}):`);
|
||||
for (const e of errors) console.error(` - ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('[C&L] Error fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
40
apps/api/scripts/sync-single-cyl-gcc.ts
Normal file
40
apps/api/scripts/sync-single-cyl-gcc.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma } from '../src/config/database.js';
|
||||
|
||||
const TENANT_ID = '49b60455-c501-4ca2-b4bc-36ea7f2951a2';
|
||||
const CONTRIBUYENTE_ID = '36b43d67-3b92-4307-95a6-c8e4053e0140';
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: string): Promise<void> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new Error(`Job ${jobId} no encontrado`);
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'pending') {
|
||||
console.log(`[GCC] Job ${jobId} finalizado: status=${job.status}, found=${job.cfdisFound}, inserted=${job.cfdisInserted}, updated=${job.cfdisUpdated}, progress=${job.progressPercent}%`);
|
||||
if (job.errorMessage) {
|
||||
console.error(`[GCC] Error: ${job.errorMessage}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.log(`[GCC] Job ${jobId} status=${job.status}, progress=${job.progressPercent}%. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`[GCC] Iniciando sync diario para contribuyente ${CONTRIBUYENTE_ID}`);
|
||||
const jobId = await startSync(TENANT_ID, 'daily', undefined, undefined, CONTRIBUYENTE_ID);
|
||||
console.log(`[GCC] Job iniciado: ${jobId}`);
|
||||
await waitForJob(jobId);
|
||||
console.log('[GCC] Proceso finalizado');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('[GCC] Error fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
78
apps/api/scripts/sync-test-rfc-vs-ip.ts
Normal file
78
apps/api/scripts/sync-test-rfc-vs-ip.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
import { prisma } from '../src/config/database.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
|
||||
interface TestCase {
|
||||
name: string;
|
||||
tenantId: string;
|
||||
contribuyenteId: string;
|
||||
rfc: string;
|
||||
}
|
||||
|
||||
const tests: TestCase[] = [
|
||||
{
|
||||
name: 'Horux 360',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
contribuyenteId: '4a1d6014-f705-424b-b185-7740be6a80c6',
|
||||
rfc: 'TORC9611214CA',
|
||||
},
|
||||
{
|
||||
name: 'C&L (falló antes)',
|
||||
tenantId: '49b60455-c501-4ca2-b4bc-36ea7f2951a2',
|
||||
contribuyenteId: '36b43d67-3b92-4307-95a6-c8e4053e0140',
|
||||
rfc: 'GCC080208478',
|
||||
},
|
||||
];
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForJob(jobId: string): Promise<{ status: string; error: string | null; found: number; inserted: number; updated: number; progress: number }> {
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new Error(`Job ${jobId} no encontrado`);
|
||||
if (job.status === 'completed' || job.status === 'failed' || job.status === 'pending') {
|
||||
return {
|
||||
status: job.status,
|
||||
error: job.errorMessage,
|
||||
found: job.cfdisFound || 0,
|
||||
inserted: job.cfdisInserted || 0,
|
||||
updated: job.cfdisUpdated || 0,
|
||||
progress: job.progressPercent || 0,
|
||||
};
|
||||
}
|
||||
console.log(`[WAIT] Job ${jobId} status=${job.status}, progress=${job.progressPercent}%. Esperando 60s...`);
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
async function runTest(test: TestCase): Promise<void> {
|
||||
console.log(`\n[TEST] === ${test.name} | ${test.rfc} ===`);
|
||||
try {
|
||||
const jobId = await startSync(test.tenantId, 'daily', undefined, undefined, test.contribuyenteId);
|
||||
console.log(`[TEST] Job iniciado: ${jobId}`);
|
||||
const result = await waitForJob(jobId);
|
||||
console.log(`[TEST] ${test.rfc} finalizado: status=${result.status}, found=${result.found}, inserted=${result.inserted}, updated=${result.updated}, progress=${result.progress}%`);
|
||||
if (result.error) {
|
||||
console.error(`[TEST] Error en ${test.rfc}: ${result.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[TEST] Error lanzando sync para ${test.rfc}:`, error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('[TEST] Iniciando prueba RFC vs IP');
|
||||
for (const test of tests) {
|
||||
await runTest(test);
|
||||
}
|
||||
console.log('\n[TEST] Prueba finalizada');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('[TEST] Error fatal:', err);
|
||||
await prisma.$disconnect().catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
106
apps/api/scripts/test-daily-group.ts
Normal file
106
apps/api/scripts/test-daily-group.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Prueba del sharding: relanza el daily SOLO para los tenants del grupo 0
|
||||
* (mismo hash que usa sat-sync.job.ts para la ventana de la 1 AM).
|
||||
*
|
||||
* Uso: node --import <tsx-loader> scripts/test-daily-group.ts [GRUPO]
|
||||
*/
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { tenantDb } from '../src/config/database.js';
|
||||
import { startSync, getSyncStatus } from '../src/services/sat/sat.service.js';
|
||||
|
||||
const DAILY_GROUPS = 5;
|
||||
const GROUP = Number(process.argv[2] ?? 0);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Población daily real: tenants con job daily de hoy (fallaron a las 3 AM)
|
||||
const jobs = await prisma.satSyncJob.findMany({
|
||||
where: { type: 'daily', createdAt: { gte: new Date('2026-07-12T06:00:00Z') } },
|
||||
select: { tenantId: true },
|
||||
distinct: ['tenantId'],
|
||||
});
|
||||
const allTenants = jobs.map(j => j.tenantId);
|
||||
const tenantArg = process.argv.find(a => a.startsWith('tenant:'));
|
||||
const groupTenants = tenantArg
|
||||
? [tenantArg.slice('tenant:'.length)]
|
||||
: allTenants.filter(id => tenantGroup(id) === GROUP);
|
||||
|
||||
console.log(`[Test] ${allTenants.length} tenants daily de hoy; ${tenantArg ? 'tenant manual' : `grupo ${GROUP}`}: ${groupTenants.length} tenants`);
|
||||
console.log(`[Test] Tenants: ${groupTenants.join(', ')}`);
|
||||
|
||||
const launchedJobIds: string[] = [];
|
||||
|
||||
for (const tenantId of groupTenants) {
|
||||
try {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
let contribuyenteIds: string[] = [];
|
||||
if (tenant?.databaseName) {
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
||||
}
|
||||
|
||||
if (contribuyenteIds.length === 0) {
|
||||
const status = await getSyncStatus(tenantId);
|
||||
if (status.hasActiveSync) {
|
||||
console.log(`[Test] ${tenantId}: sync activo, omitido`);
|
||||
continue;
|
||||
}
|
||||
const completed = await prisma.satSyncJob.findFirst({
|
||||
where: { tenantId, type: 'initial', status: 'completed' },
|
||||
});
|
||||
const syncType = completed ? 'daily' : 'initial';
|
||||
const jobId = await startSync(tenantId, syncType);
|
||||
launchedJobIds.push(jobId);
|
||||
console.log(`[Test] ${tenantId}: job ${jobId} (${syncType}, sin contribuyentes)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const contribuyenteId of contribuyenteIds) {
|
||||
const status = await getSyncStatus(tenantId, contribuyenteId);
|
||||
if (status.hasActiveSync) {
|
||||
console.log(`[Test] ${tenantId}/${contribuyenteId}: sync activo, omitido`);
|
||||
continue;
|
||||
}
|
||||
const completed = await prisma.satSyncJob.findFirst({
|
||||
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
|
||||
});
|
||||
const syncType = completed ? 'daily' : 'initial';
|
||||
const jobId = await startSync(tenantId, syncType, undefined, undefined, contribuyenteId);
|
||||
launchedJobIds.push(jobId);
|
||||
console.log(`[Test] ${tenantId}/${contribuyenteId}: job ${jobId} (${syncType})`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[Test] Error en ${tenantId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Test] Lanzamiento del grupo terminado. Esperando a que los jobs terminen...');
|
||||
|
||||
// Esperar a que todos los jobs alcancen estado terminal (el sync corre en
|
||||
// ESTE proceso — si salimos antes, matamos el trabajo en vuelo).
|
||||
const maxWaitMs = 45 * 60 * 1000;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
const jobsStatus = await prisma.satSyncJob.findMany({
|
||||
where: { id: { in: launchedJobIds } },
|
||||
select: { id: true, status: true, progressPercent: true },
|
||||
});
|
||||
const pending = jobsStatus.filter(j => j.status === 'running' || j.status === 'queued');
|
||||
console.log(`[Test] ${new Date().toISOString()} — ${pending.length}/${jobsStatus.length} en vuelo: ` +
|
||||
jobsStatus.map(j => `${j.id.slice(0, 8)}=${j.status}(${j.progressPercent}%)`).join(', '));
|
||||
if (pending.length === 0) break;
|
||||
await new Promise(r => setTimeout(r, 30000));
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });
|
||||
49
apps/api/scripts/test-opinion-csf-sequential.ts
Normal file
49
apps/api/scripts/test-opinion-csf-sequential.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { consultarConstanciaContribuyente } from '../src/services/constancia.service.js';
|
||||
import { consultarOpinionContribuyente } from '../src/services/opinion-cumplimiento.service.js';
|
||||
import { tenantDb, prisma } from '../src/config/database.js';
|
||||
|
||||
const CONTRIBUYENTE_ID = 'cc41d462-4270-4949-aa9b-f0a175784659'; // AFP7404301DA - tiene FIEL, sin CSF/opinión
|
||||
|
||||
async function main() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: 'DESPACHO_MPG95QP7_XZVFF' },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
|
||||
console.log(`[TEST] Iniciando prueba secuencial para contribuyente ${CONTRIBUYENTE_ID}`);
|
||||
console.log(`[TEST] Timestamp: ${new Date().toISOString()}`);
|
||||
|
||||
// 1. CSF
|
||||
console.log('\n[TEST] === 1. Constancia de Situación Fiscal ===');
|
||||
const csfStart = Date.now();
|
||||
try {
|
||||
const csf = await consultarConstanciaContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
console.log(`[TEST] CSF OK en ${(Date.now() - csfStart) / 1000}s`);
|
||||
console.log(`[TEST] RFC: ${csf.rfc}, Razón: ${csf.razonSocial}, Estatus: ${csf.estatusPadron}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[TEST] CSF FALLÓ en ${(Date.now() - csfStart) / 1000}s:`, err.message || err);
|
||||
}
|
||||
|
||||
// 2. Opinión
|
||||
console.log('\n[TEST] === 2. Opinión de Cumplimiento ===');
|
||||
const opinionStart = Date.now();
|
||||
try {
|
||||
const opinion = await consultarOpinionContribuyente(pool, CONTRIBUYENTE_ID);
|
||||
console.log(`[TEST] Opinión OK en ${(Date.now() - opinionStart) / 1000}s`);
|
||||
console.log(`[TEST] RFC: ${opinion.rfc}, Estatus: ${opinion.estatus}, Folio: ${opinion.folio}`);
|
||||
} catch (err: any) {
|
||||
console.error(`[TEST] Opinión FALLÓ en ${(Date.now() - opinionStart) / 1000}s:`, err.message || err);
|
||||
}
|
||||
|
||||
console.log('\n[TEST] Prueba finalizada');
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => process.exit(0))
|
||||
.catch((e) => {
|
||||
console.error('[TEST] Error fatal:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
71
apps/api/scripts/test-proxy-sync.ts
Normal file
71
apps/api/scripts/test-proxy-sync.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'dotenv/config';
|
||||
import { prisma } from '../src/config/database.js';
|
||||
import { startSync } from '../src/services/sat/sat.service.js';
|
||||
|
||||
const TENANT_ID = 'bcffc3ce-e5f5-4a66-b6bf-28da47bf7a41';
|
||||
const CONTRIBUYENTE_ID = '75a26067-66b2-4b5c-9dde-9ecc628a1e03';
|
||||
|
||||
function getYesterdayEndCDMX(): Date {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1, 12, 0, 0));
|
||||
}
|
||||
|
||||
function getThreeDaysAgoStartCDMX(): Date {
|
||||
const yesterday = getYesterdayEndCDMX();
|
||||
return new Date(Date.UTC(yesterday.getUTCFullYear(), yesterday.getUTCMonth(), yesterday.getUTCDate() - 2, 0, 0, 0));
|
||||
}
|
||||
|
||||
async function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dateFrom = getThreeDaysAgoStartCDMX();
|
||||
const dateTo = getYesterdayEndCDMX();
|
||||
|
||||
console.log('[Proxy Test] Iniciando sync con proxy para Miguel Angel Romero Estrada');
|
||||
console.log(`[Proxy Test] RFC: ROEM691011EZ4`);
|
||||
console.log(`[Proxy Test] Rango: ${dateFrom.toISOString()} → ${dateTo.toISOString()}`);
|
||||
|
||||
const jobId = await startSync(TENANT_ID, 'daily', dateFrom, dateTo, CONTRIBUYENTE_ID);
|
||||
console.log(`[Proxy Test] Job creado: ${jobId}`);
|
||||
|
||||
let previousStatus: string | null = null;
|
||||
while (true) {
|
||||
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) {
|
||||
console.log('[Proxy Test] Job no encontrado');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const statusLine = `[Proxy Test] Status: ${job.status} | found=${job.cfdisFound} downloaded=${job.cfdisDownloaded} inserted=${job.cfdisInserted} updated=${job.cfdisUpdated} progress=${job.progressPercent}% | error=${job.errorMessage || 'none'}`;
|
||||
if (job.status !== previousStatus) {
|
||||
console.log(statusLine);
|
||||
previousStatus = job.status;
|
||||
} else {
|
||||
process.stdout.write('.');
|
||||
}
|
||||
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
console.log('\n[Proxy Test] Resultado final:');
|
||||
console.log(JSON.stringify({
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
cfdisFound: job.cfdisFound,
|
||||
cfdisDownloaded: job.cfdisDownloaded,
|
||||
cfdisInserted: job.cfdisInserted,
|
||||
cfdisUpdated: job.cfdisUpdated,
|
||||
errorMessage: job.errorMessage,
|
||||
completedAt: job.completedAt,
|
||||
}, null, 2));
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(15000);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[Proxy Test] Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
47
apps/api/scripts/test_auza_sync.ts
Normal file
47
apps/api/scripts/test_auza_sync.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { startSync, getSyncStatus } from '../src/services/sat/sat.service.js';
|
||||
|
||||
const TENANT_ID = '81116985-03cd-4843-97ba-05e8be9917c6';
|
||||
const DAYS = 15;
|
||||
|
||||
async function main() {
|
||||
const dateTo = new Date('2026-05-04T23:59:59Z'); // Fecha válida para el SAT
|
||||
const dateFrom = new Date(dateTo);
|
||||
dateFrom.setDate(dateFrom.getDate() - DAYS);
|
||||
|
||||
console.log(`[Test] Iniciando sync initial para AUZA640701TI9`);
|
||||
console.log(`[Test] Rango: ${dateFrom.toISOString().slice(0,10)} → ${dateTo.toISOString().slice(0,10)}`);
|
||||
|
||||
try {
|
||||
const jobId = await startSync(TENANT_ID, 'initial', dateFrom, dateTo);
|
||||
console.log(`[Test] Job creado: ${jobId}`);
|
||||
|
||||
let completed = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
|
||||
while (!completed && attempts < maxAttempts) {
|
||||
await new Promise(r => setTimeout(r, 30000));
|
||||
attempts++;
|
||||
|
||||
const status = await getSyncStatus(TENANT_ID);
|
||||
const job = status.currentJob;
|
||||
console.log(`[Test] Intento ${attempts}: status=${job?.status || 'none'}, progress=${job?.progressPercent ?? 0}%, found=${job?.cfdisFound ?? 0}, inserted=${job?.cfdisInserted ?? 0}`);
|
||||
|
||||
if (!status.hasActiveSync) {
|
||||
completed = true;
|
||||
console.log(`[Test] Sync finalizado. Último job: ${status.lastCompletedJob?.status || 'N/A'}`);
|
||||
if (status.lastCompletedJob?.errorMessage) {
|
||||
console.log(`[Test] Error: ${status.lastCompletedJob.errorMessage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!completed) {
|
||||
console.log(`[Test] Timeout después de ${maxAttempts} intentos. El job sigue corriendo.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Test] Error iniciando sync:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
68
apps/api/scripts/test_e2e_ivan.ts
Normal file
68
apps/api/scripts/test_e2e_ivan.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
include: { platformRoles: true }
|
||||
});
|
||||
|
||||
if (!user) { console.log('User not found'); return; }
|
||||
|
||||
// Simulate a FRESH login JWT (with platformRoles)
|
||||
const token = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: user.platformRoles.map(r => r.role as any),
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
async function test(name: string, url: string, headers?: any) {
|
||||
try {
|
||||
const r = await axios.get(`${baseURL}${url}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log(`✅ ${name}: ${r.status}`);
|
||||
return r.data;
|
||||
} catch (e: any) {
|
||||
console.log(`❌ ${name}: ${e.response?.status} ${JSON.stringify(e.response?.data)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Ivan loads /despachos/contribuyentes (HORUX 360 default) ===');
|
||||
await test('admin/dashboard/despachos', '/admin/dashboard/despachos');
|
||||
await test('contribuyentes-stats (HORUX)', '/despachos/contribuyentes-stats?año=2026&mes=5');
|
||||
await test('contribuyentes (HORUX)', '/contribuyentes');
|
||||
|
||||
console.log('');
|
||||
console.log('=== Ivan selects Husberto from dropdown ===');
|
||||
const husbertoHeaders = { 'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd' };
|
||||
await test('contribuyentes-stats (Husberto)', '/despachos/contribuyentes-stats?año=2026&mes=5', husbertoHeaders);
|
||||
await test('contribuyentes (Husberto)', '/contribuyentes', husbertoHeaders);
|
||||
|
||||
// Test other endpoints that might fire on the page
|
||||
await test('carteras (Husberto)', '/carteras', husbertoHeaders);
|
||||
await test('cfdi (Husberto)', '/cfdi', husbertoHeaders);
|
||||
await test('alertas (Husberto)', '/alertas', husbertoHeaders);
|
||||
await test('bancos (Husberto)', '/bancos', husbertoHeaders);
|
||||
await test('tareas (Husberto)', '/tareas?contribuyenteId=all', husbertoHeaders);
|
||||
|
||||
console.log('');
|
||||
console.log('=== Ivan selects AUZA from dropdown ===');
|
||||
const auzaHeaders = { 'X-View-Tenant': '81116985-03cd-4843-97ba-05e8be9917c6' };
|
||||
await test('contribuyentes-stats (AUZA)', '/despachos/contribuyentes-stats?año=2026&mes=5', auzaHeaders);
|
||||
await test('contribuyentes (AUZA)', '/contribuyentes', auzaHeaders);
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
60
apps/api/scripts/test_ivan_comprehensive.ts
Normal file
60
apps/api/scripts/test_ivan_comprehensive.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
include: { platformRoles: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log('User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: user.platformRoles.map(r => r.role as any),
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
const headers = { 'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd' };
|
||||
|
||||
const endpoints = [
|
||||
'/contribuyentes',
|
||||
'/despachos/contribuyentes-stats?año=2026&mes=5',
|
||||
'/despachos/mis-asignados?año=2026&mes=5',
|
||||
'/carteras',
|
||||
'/cfdi',
|
||||
'/tareas',
|
||||
'/alertas',
|
||||
'/documentos',
|
||||
'/impuestos',
|
||||
'/dashboard',
|
||||
'/bancos',
|
||||
'/conciliacion',
|
||||
];
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const r = await axios.get(`${baseURL}${endpoint}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log(`${endpoint}: ${r.status} OK`);
|
||||
} catch (e: any) {
|
||||
console.log(`${endpoint}: ${e.response?.status} ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
69
apps/api/scripts/test_ivan_full.ts
Normal file
69
apps/api/scripts/test_ivan_full.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log('User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate OLD JWT (without platformRoles) — as if Ivan logged in before getting platform_ti
|
||||
const oldToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
// Simulate NEW JWT (with platformRoles)
|
||||
const platformRoles = await prisma.userPlatformRole.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { role: true },
|
||||
});
|
||||
const newToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: platformRoles.map(r => r.role as any),
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
async function test(name: string, token: string, url: string, headers?: any) {
|
||||
try {
|
||||
const r = await axios.get(`${baseURL}${url}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers }
|
||||
});
|
||||
console.log(`${name}: ${r.status} OK`);
|
||||
} catch (e: any) {
|
||||
console.log(`${name}: ${e.response?.status} ${JSON.stringify(e.response?.data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== WITH OLD JWT (no platformRoles) ===');
|
||||
await test('contribuyentes (no XVT)', oldToken, '/contribuyentes');
|
||||
await test('contribuyentes-stats (no XVT)', oldToken, '/despachos/contribuyentes-stats?año=2026&mes=5');
|
||||
await test('admin/dashboard/despachos', oldToken, '/admin/dashboard/despachos');
|
||||
await test('contribuyentes (XVT=Husberto)', oldToken, '/contribuyentes', { 'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd' });
|
||||
|
||||
console.log('');
|
||||
console.log('=== WITH NEW JWT (with platformRoles) ===');
|
||||
await test('contribuyentes (no XVT)', newToken, '/contribuyentes');
|
||||
await test('contribuyentes-stats (no XVT)', newToken, '/despachos/contribuyentes-stats?año=2026&mes=5');
|
||||
await test('admin/dashboard/despachos', newToken, '/admin/dashboard/despachos');
|
||||
await test('contribuyentes (XVT=Husberto)', newToken, '/contribuyentes', { 'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd' });
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
78
apps/api/scripts/test_ivan_request.ts
Normal file
78
apps/api/scripts/test_ivan_request.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
include: { platformRoles: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log('User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: user.platformRoles.map(r => r.role as any),
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
// Test 1: Request without X-View-Tenant (should work - HORUX 360)
|
||||
try {
|
||||
const r1 = await axios.get(`${baseURL}/contribuyentes`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
console.log('Test 1 (no X-View-Tenant):', r1.status, '- contribuyentes count:', r1.data.data?.length ?? 'N/A');
|
||||
} catch (e: any) {
|
||||
console.log('Test 1 (no X-View-Tenant):', e.response?.status, e.response?.data);
|
||||
}
|
||||
|
||||
// Test 2: Request with X-View-Tenant = Husberto (should work for platform_ti)
|
||||
try {
|
||||
const r2 = await axios.get(`${baseURL}/contribuyentes`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd'
|
||||
}
|
||||
});
|
||||
console.log('Test 2 (X-View-Tenant=Husberto):', r2.status, '- contribuyentes count:', r2.data.data?.length ?? 'N/A');
|
||||
} catch (e: any) {
|
||||
console.log('Test 2 (X-View-Tenant=Husberto):', e.response?.status, e.response?.data);
|
||||
}
|
||||
|
||||
// Test 3: Request with X-View-Tenant = AUZA (should work for platform_ti)
|
||||
try {
|
||||
const r3 = await axios.get(`${baseURL}/contribuyentes`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-View-Tenant': '81116985-03cd-4843-97ba-05e8be9917c6'
|
||||
}
|
||||
});
|
||||
console.log('Test 3 (X-View-Tenant=AUZA):', r3.status, '- contribuyentes count:', r3.data.data?.length ?? 'N/A');
|
||||
} catch (e: any) {
|
||||
console.log('Test 3 (X-View-Tenant=AUZA):', e.response?.status, e.response?.data);
|
||||
}
|
||||
|
||||
// Test 4: Request admin/dashboard/despachos (should work for platform_ti)
|
||||
try {
|
||||
const r4 = await axios.get(`${baseURL}/admin/dashboard/despachos`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
console.log('Test 4 (/admin/dashboard/despachos):', r4.status, '- despachos count:', r4.data.data?.length ?? 'N/A');
|
||||
} catch (e: any) {
|
||||
console.log('Test 4 (/admin/dashboard/despachos):', e.response?.status, e.response?.data);
|
||||
}
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
58
apps/api/scripts/test_ivan_switch.ts
Normal file
58
apps/api/scripts/test_ivan_switch.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
include: { platformRoles: true }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log('User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: user.platformRoles.map(r => r.role as any),
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
async function test(name: string, url: string, headers?: any) {
|
||||
try {
|
||||
const r = await axios.get(`${baseURL}${url}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log(`${name}: ${r.status} OK`);
|
||||
} catch (e: any) {
|
||||
console.log(`${name}: ${e.response?.status} ${JSON.stringify(e.response?.data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Simulating Ivan switching to Husberto ===');
|
||||
|
||||
// This is what fires when Ivan selects Husberto in the dropdown
|
||||
await test('contribuyentes-stats (Husberto)', '/despachos/contribuyentes-stats?año=2026&mes=5', { 'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd' });
|
||||
|
||||
// Also test other endpoints that may fire on the page
|
||||
await test('contribuyentes (Husberto)', '/contribuyentes', { 'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd' });
|
||||
await test('admin/dashboard/despachos', '/admin/dashboard/despachos');
|
||||
|
||||
console.log('');
|
||||
console.log('=== Simulating Ivan switching to AUZA ===');
|
||||
await test('contribuyentes-stats (AUZA)', '/despachos/contribuyentes-stats?año=2026&mes=5', { 'X-View-Tenant': '81116985-03cd-4843-97ba-05e8be9917c6' });
|
||||
await test('contribuyentes (AUZA)', '/contribuyentes', { 'X-View-Tenant': '81116985-03cd-4843-97ba-05e8be9917c6' });
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
47
apps/api/scripts/test_old_jwt.ts
Normal file
47
apps/api/scripts/test_old_jwt.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
});
|
||||
|
||||
if (!user) { console.log('User not found'); return; }
|
||||
|
||||
// Simulate OLD JWT (NO platformRoles) — exactly what Ivan has if he never re-logged
|
||||
const oldToken = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
tokenVersion: user.tokenVersion,
|
||||
// NO platformRoles field
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
async function test(name: string, url: string, headers?: any) {
|
||||
try {
|
||||
const r = await axios.get(`${baseURL}${url}`, {
|
||||
headers: { Authorization: `Bearer ${oldToken}`, ...headers },
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log(`✅ ${name}: ${r.status}`);
|
||||
} catch (e: any) {
|
||||
console.log(`❌ ${name}: ${e.response?.status} ${JSON.stringify(e.response?.data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== Testing with OLD JWT (no platformRoles) ===');
|
||||
await test('/tenants', '/tenants');
|
||||
await test('/tenants/mine', '/tenants/mine');
|
||||
await test('/admin/dashboard/despachos', '/admin/dashboard/despachos');
|
||||
await test('/contribuyentes', '/contribuyentes');
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
45
apps/api/scripts/test_tenants.ts
Normal file
45
apps/api/scripts/test_tenants.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: 'ivan@horuxfin.com' },
|
||||
include: { platformRoles: true }
|
||||
});
|
||||
|
||||
if (!user) { console.log('User not found'); return; }
|
||||
|
||||
const token = generateAccessToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: user.platformRoles.map(r => r.role as any),
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
async function test(name: string, url: string, headers?: any) {
|
||||
try {
|
||||
const r = await axios.get(`${baseURL}${url}`, {
|
||||
headers: { Authorization: `Bearer ${token}`, ...headers },
|
||||
timeout: 10000,
|
||||
});
|
||||
console.log(`✅ ${name}: ${r.status}`);
|
||||
} catch (e: any) {
|
||||
console.log(`❌ ${name}: ${e.response?.status} ${JSON.stringify(e.response?.data)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await test('/tenants (no XVT)', '/tenants');
|
||||
await test('/tenants (Husberto)', '/tenants', { 'X-View-Tenant': 'd75bf020-2008-4881-ab96-77467bf9e1fd' });
|
||||
await test('/tenants/mine', '/tenants/mine');
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
26
apps/api/scripts/test_usuarios.ts
Normal file
26
apps/api/scripts/test_usuarios.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { generateAccessToken } from '../src/auth/tokens';
|
||||
import axios from 'axios';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const user = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { platformRoles: true } });
|
||||
if (!user) return;
|
||||
|
||||
const token = generateAccessToken({
|
||||
userId: user.id, email: user.email, role: 'contador',
|
||||
tenantId: 'c52c2f5d-b1ae-45c6-8cc8-b11c9611618a',
|
||||
platformRoles: user.platformRoles.map(r => r.role as any),
|
||||
tokenVersion: user.tokenVersion,
|
||||
});
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
try {
|
||||
const r = await axios.get(`${baseURL}/usuarios/global/all`, { headers: { Authorization: `Bearer ${token}` }, timeout: 10000 });
|
||||
console.log('/usuarios/global/all:', r.status, 'OK - count:', r.data?.length ?? 'N/A');
|
||||
} catch (e: any) {
|
||||
console.log('/usuarios/global/all:', e.response?.status, JSON.stringify(e.response?.data));
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
main().catch(console.error);
|
||||
@@ -7,6 +7,7 @@ import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
|
||||
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
|
||||
import { startNotificationsJob } from './jobs/notifications.job.js';
|
||||
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js';
|
||||
import { startSatProxyReportJob } from './jobs/sat-proxy-report.job.js';
|
||||
import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js';
|
||||
|
||||
const PORT = parseInt(env.PORT, 10);
|
||||
@@ -26,6 +27,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
startSatSyncJob();
|
||||
startMetricasInvalidationsJob();
|
||||
startSatSyncMonitorJob();
|
||||
startSatProxyReportJob();
|
||||
startRecordatoriosPeriodicosJob();
|
||||
if (sendRealEmails) {
|
||||
startWeeklyUpdateJob();
|
||||
|
||||
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal file
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import cron from 'node-cron';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import type { SatProxyReportData } from '../services/email/templates/sat-proxy-report.js';
|
||||
|
||||
const PROXY_REPORT_CRON_SCHEDULE = '0 8 * * *'; // 8:00 AM CDMX diario
|
||||
|
||||
let reportTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
|
||||
function cdmxDateString(d: Date): string {
|
||||
return d.toLocaleDateString('es-MX', { timeZone: 'America/Mexico_City' });
|
||||
}
|
||||
|
||||
function hoursAgo(hours: number): Date {
|
||||
return new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export async function runSatProxyReport(): Promise<void> {
|
||||
console.log('[SAT Proxy Report] Generando reporte diario de errores por proxy');
|
||||
|
||||
try {
|
||||
const cutoff = hoursAgo(24);
|
||||
const rows = await prisma.$queryRaw<Array<{ proxy: string | null; errorCode: string | null; count: bigint }>>`
|
||||
SELECT proxy_used AS proxy, error_code AS "errorCode", COUNT(*) AS count
|
||||
FROM sat_proxy_errors
|
||||
WHERE created_at >= ${cutoff}
|
||||
GROUP BY proxy_used, error_code
|
||||
ORDER BY count DESC, proxy_used ASC, error_code ASC
|
||||
`;
|
||||
|
||||
const byProxy = rows.map(r => ({
|
||||
proxy: r.proxy || 'IP directa del servidor',
|
||||
errorCode: r.errorCode || '—',
|
||||
count: Number(r.count),
|
||||
}));
|
||||
|
||||
const totalErrors = byProxy.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
const now = new Date();
|
||||
const data: SatProxyReportData = {
|
||||
generatedAt: now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }),
|
||||
recipient: env.ADMIN_EMAIL,
|
||||
dateFrom: cdmxDateString(hoursAgo(24)),
|
||||
dateTo: cdmxDateString(now),
|
||||
totalErrors,
|
||||
byProxy,
|
||||
};
|
||||
|
||||
const recipient = env.ADMIN_EMAIL;
|
||||
if (!recipient) {
|
||||
console.warn('[SAT Proxy Report] ADMIN_EMAIL no configurado, no se envía reporte');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SAT Proxy Report] Enviando reporte a ${recipient}: ${totalErrors} errores en ${byProxy.length} grupos`);
|
||||
await emailService.sendSatProxyReport(recipient, data);
|
||||
console.log('[SAT Proxy Report] Reporte enviado');
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Proxy Report] Error generando reporte:', error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
export function startSatProxyReportJob(): void {
|
||||
if (reportTask) {
|
||||
console.log('[SAT Proxy Report] Job ya está programado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cron.validate(PROXY_REPORT_CRON_SCHEDULE)) {
|
||||
console.error('[SAT Proxy Report] Expresión cron inválida:', PROXY_REPORT_CRON_SCHEDULE);
|
||||
return;
|
||||
}
|
||||
|
||||
reportTask = cron.schedule(PROXY_REPORT_CRON_SCHEDULE, async () => {
|
||||
try {
|
||||
await runSatProxyReport();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Proxy Report Cron] Error:', error.message || error);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
console.log(`[SAT Proxy Report] Programado: ${PROXY_REPORT_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
}
|
||||
|
||||
export function stopSatProxyReportJob(): void {
|
||||
if (reportTask) {
|
||||
reportTask.stop();
|
||||
reportTask = null;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ export const emailService = {
|
||||
await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data));
|
||||
},
|
||||
|
||||
sendSatProxyReport: async (to: string, data: import('./templates/sat-proxy-report.js').SatProxyReportData) => {
|
||||
const { satProxyReportEmail } = await import('./templates/sat-proxy-report.js');
|
||||
await sendEmail(to, `📊 Reporte diario SAT: ${data.totalErrors} errores por proxy`, satProxyReportEmail(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));
|
||||
|
||||
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal file
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export interface ProxyErrorRow {
|
||||
proxy: string;
|
||||
errorCode: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SatProxyReportData {
|
||||
generatedAt: string;
|
||||
recipient: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
totalErrors: number;
|
||||
byProxy: ProxyErrorRow[];
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
export function satProxyReportEmail(data: SatProxyReportData): string {
|
||||
const rowsHtml = data.byProxy.length > 0
|
||||
? data.byProxy.map(r => tableRow([
|
||||
r.proxy ? `<code style="font-size:12px;">${r.proxy}</code>` : '<span style="color:#dc2626;">IP directa del servidor</span>',
|
||||
r.errorCode || '—',
|
||||
`<strong>${r.count}</strong>`,
|
||||
])).join('')
|
||||
: tableRow(['—', '—', '<span style="color:#16a34a;">Sin errores de bloqueo</span>']);
|
||||
|
||||
return baseTemplate(`
|
||||
${heading('📊 Reporte diario de errores SAT por proxy')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||
Resumen de errores de bloqueo/devolución del SAT en las últimas 24 horas,
|
||||
agrupados por proxy/IP usada.
|
||||
</p>
|
||||
${infoBox(`
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="padding:6px 0;color:${C.textMuted};">Período</td><td style="padding:6px 0;color:${C.textPrimary};font-weight:600;text-align:right;">${data.dateFrom} → ${data.dateTo}</td></tr>
|
||||
<tr><td style="padding:6px 0;color:${C.textMuted};">Errores totales</td><td style="padding:6px 0;color:${data.totalErrors > 0 ? '#dc2626' : '#16a34a'};font-weight:600;text-align:right;">${data.totalErrors}</td></tr>
|
||||
</table>
|
||||
`)}
|
||||
|
||||
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:28px 0 12px;font-size:16px;">Errores por proxy (${data.byProxy.length})</h3>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<thead>${tableHeader(['Proxy / IP', 'Código', 'Cantidad'])}</thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
</table>
|
||||
|
||||
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
|
||||
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
|
||||
Configura la lista de proxies con SAT_PROXY_LIST y la concurrencia con SAT_CONCURRENT_CONTRIBUYENTES.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export interface FielData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ProxyInfo {
|
||||
host: string;
|
||||
port: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout explícito para el cliente HTTP del SAT (ms).
|
||||
*
|
||||
@@ -38,7 +44,7 @@ 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
|
||||
*/
|
||||
export function createSatService(fielData: FielData): Service {
|
||||
export function createSatService(fielData: FielData): { service: Service; proxyInfo: ProxyInfo | null } {
|
||||
// Crear FIEL usando el método estático create
|
||||
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
|
||||
|
||||
@@ -50,7 +56,8 @@ export function createSatService(fielData: FielData): Service {
|
||||
// Crear cliente HTTP con timeout explícito para evitar el bug de la librería
|
||||
// cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno
|
||||
// del pool para reducir el riesgo de bloqueo por IP del SAT.
|
||||
const proxyAgent = proxyManager.createNextAgent();
|
||||
const proxy = proxyManager.getNextProxy();
|
||||
const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null;
|
||||
if (proxyAgent) {
|
||||
console.log('[SAT] Usando proxy para la conexión con el SAT');
|
||||
} else {
|
||||
@@ -68,7 +75,11 @@ export function createSatService(fielData: FielData): Service {
|
||||
const requestBuilder = new FielRequestBuilder(fiel);
|
||||
|
||||
// Crear y retornar el servicio
|
||||
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
||||
const service = new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
||||
return {
|
||||
service,
|
||||
proxyInfo: proxy ? { host: proxy.host, port: proxy.port, url: proxy.url } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
verifySatRequest,
|
||||
downloadSatPackage,
|
||||
type FielData,
|
||||
type ProxyInfo,
|
||||
} from './sat-client.service.js';
|
||||
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js';
|
||||
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js';
|
||||
@@ -91,6 +92,7 @@ function computeNextRetryAt(
|
||||
interface SyncContext {
|
||||
fielData: FielData;
|
||||
service: Service;
|
||||
proxyInfo: ProxyInfo | null;
|
||||
rfc: string;
|
||||
tenantId: string;
|
||||
databaseName: string;
|
||||
@@ -98,6 +100,33 @@ interface SyncContext {
|
||||
getPool: () => Promise<Pool>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra un error de bloqueo/devolución del SAT junto con el proxy usado.
|
||||
* Permite reportes diarios de errores por IP.
|
||||
*/
|
||||
async function recordSatProxyError(
|
||||
jobId: string,
|
||||
ctx: SyncContext,
|
||||
errorCode: string | null,
|
||||
stage: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const proxyUsed = ctx.proxyInfo ? `${ctx.proxyInfo.host}:${ctx.proxyInfo.port}` : null;
|
||||
await prisma.satProxyError.create({
|
||||
data: {
|
||||
jobId,
|
||||
proxyUsed,
|
||||
errorCode,
|
||||
stage,
|
||||
message,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[SAT] Error guardando sat_proxy_error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el progreso de un job
|
||||
*/
|
||||
@@ -117,6 +146,7 @@ async function updateJobProgress(
|
||||
completedAt: Date;
|
||||
retryCount: number;
|
||||
nextRetryAt: Date;
|
||||
proxyUsed: string | null;
|
||||
}>
|
||||
): Promise<void> {
|
||||
await prisma.satSyncJob.update({
|
||||
@@ -700,6 +730,7 @@ async function requestAndDownload(
|
||||
return { packageContents: [], totalCfdis: 0 };
|
||||
}
|
||||
if (/error no controlado/i.test(queryResult.message || '')) {
|
||||
await recordSatProxyError(jobId, ctx, queryResult.statusCode || '404', stageIdForTimeout(label), queryResult.message || 'Error no controlado');
|
||||
if (isDaily) {
|
||||
// En daily no detenemos el job por un 404 transitorio del SAT; se
|
||||
// registra como no fatal para diagnóstico y se continúa.
|
||||
@@ -709,6 +740,7 @@ async function requestAndDownload(
|
||||
console.warn(`[SAT] Rechazo transitorio del SAT (${label}): ${queryResult.message} — se reintentará`);
|
||||
throw new SatTransientError(stageIdForTimeout(label), queryResult.message);
|
||||
}
|
||||
await recordSatProxyError(jobId, ctx, queryResult.statusCode || null, stageIdForTimeout(label), queryResult.message || 'Error SAT');
|
||||
throw new Error(`Error SAT (${label}): ${queryResult.message}`);
|
||||
}
|
||||
|
||||
@@ -735,6 +767,7 @@ async function requestAndDownload(
|
||||
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`);
|
||||
return { packageContents: [], totalCfdis: 0 };
|
||||
}
|
||||
await recordSatProxyError(jobId, ctx, verifyResult.status, stageIdForTimeout(label), verifyResult.message || `Solicitud ${verifyResult.status}`);
|
||||
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1523,7 +1556,7 @@ export async function startSync(
|
||||
password: decryptedFiel.password,
|
||||
};
|
||||
|
||||
const service = createSatService(fielData);
|
||||
const { service, proxyInfo } = createSatService(fielData);
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
@@ -1564,12 +1597,14 @@ export async function startSync(
|
||||
dateTo: dateTo || now,
|
||||
startedAt: now,
|
||||
isCustomRange,
|
||||
proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null,
|
||||
},
|
||||
});
|
||||
|
||||
const ctx: SyncContext = {
|
||||
fielData,
|
||||
service,
|
||||
proxyInfo,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId,
|
||||
databaseName: tenant.databaseName,
|
||||
@@ -1721,7 +1756,7 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const service = createSatService({
|
||||
const { service, proxyInfo } = createSatService({
|
||||
cerContent: decryptedFiel.cerContent,
|
||||
keyContent: decryptedFiel.keyContent,
|
||||
password: decryptedFiel.password,
|
||||
@@ -1734,6 +1769,7 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
password: decryptedFiel.password,
|
||||
},
|
||||
service,
|
||||
proxyInfo,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId: job.tenantId,
|
||||
databaseName: job.tenant.databaseName,
|
||||
@@ -1744,7 +1780,7 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
// B: resetear startedAt al inicio de ESTE intento para que el watchdog
|
||||
// mida el intento actual (y no mate un retry legítimo por el startedAt
|
||||
// original del job). La política de retries se ancla a createdAt.
|
||||
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date() });
|
||||
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date(), proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null });
|
||||
|
||||
// Para jobs daily, intentamos retomar desde la última etapa completada.
|
||||
let resumeFromStage: string | undefined;
|
||||
@@ -1890,7 +1926,7 @@ export async function continuePendingDailyRequests(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const service = createSatService({
|
||||
const { service, proxyInfo } = createSatService({
|
||||
cerContent: decryptedFiel.cerContent,
|
||||
keyContent: decryptedFiel.keyContent,
|
||||
password: decryptedFiel.password,
|
||||
@@ -1903,6 +1939,7 @@ export async function continuePendingDailyRequests(): Promise<void> {
|
||||
password: decryptedFiel.password,
|
||||
},
|
||||
service,
|
||||
proxyInfo,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId: job.tenantId,
|
||||
databaseName: job.tenant.databaseName,
|
||||
|
||||
1185
cfdis_sin_xml.csv
Normal file
1185
cfdis_sin_xml.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -94,6 +94,7 @@ Definidos en `apps/api/src/jobs/sat-sync.job.ts`:
|
||||
| Incremental Enterprise | `0 11,15,19 * * *` | 11 AM, 3 PM, 7 PM | Sync incremental |
|
||||
| SAT Watchdog | `0 */2 * * *` | Cada 2 horas | Marcar jobs `running` sin heartbeat como failed |
|
||||
| SAT Monitor | `0 */2 * * *` | Cada 2 horas | Alertar por email de jobs fallidos |
|
||||
| SAT Proxy Report | `0 8 * * *` | 8:00 AM | Reporte diario de errores SAT por proxy |
|
||||
|
||||
## 6. Flujo de sincronización
|
||||
|
||||
@@ -144,6 +145,21 @@ SAT_CONCURRENT_CONTRIBUYENTES=10
|
||||
- **`ProxyManager`** (`apps/api/src/services/sat/proxy.service.ts`): parsea `SAT_PROXY_LIST`, rota proxies y crea `HttpsProxyAgent`.
|
||||
- **`sat-client.service.ts`**: usa el agente del proxy en cada petición SOAP al SAT.
|
||||
|
||||
### Reporte diario de errores por proxy
|
||||
|
||||
Cada error de bloqueo/devolución del SAT se guarda en `public.sat_proxy_errors` con el proxy usado.
|
||||
|
||||
Un cron a las **8:00 AM CDMX** envía un email a `ADMIN_EMAIL` con:
|
||||
|
||||
- Total de errores en las últimas 24 horas.
|
||||
- Tabla agrupada por `proxy` + `error_code`.
|
||||
|
||||
Archivos:
|
||||
|
||||
- `apps/api/src/jobs/sat-proxy-report.job.ts` — cron y consulta.
|
||||
- `apps/api/src/services/email/templates/sat-proxy-report.ts` — template del email.
|
||||
- `apps/api/src/services/email/email.service.ts` — `sendSatProxyReport`.
|
||||
|
||||
### Comportamiento
|
||||
|
||||
- Cada llamada a `getNextProxy()` devuelve el siguiente proxy del pool (round-robin).
|
||||
@@ -269,6 +285,8 @@ psql "$DATABASE_URL" -c "
|
||||
|
||||
- **Proxies SAT rotativos**: integración de `ProxyManager` y pool de proxies HTTP/HTTPS para evitar bloqueo por IP del SAT.
|
||||
- **Concurrencia por contribuyente**: el scheduler daily e incremental procesa hasta `SAT_CONCURRENT_CONTRIBUYENTES=10` RFCs en paralelo, sin importar a cuántos tenants pertenezcan.
|
||||
- **Reporte diario de errores por proxy**: cron a las 8 AM CDMX que envía a `ADMIN_EMAIL` un resumen de errores SAT agrupados por proxy/IP.
|
||||
- Tabla `public.sat_proxy_errors` para trazabilidad de bloqueos por IP.
|
||||
- Variables de entorno: `SAT_PROXY_LIST`, `SAT_PROXY_STRATEGY`, `SAT_PROXY_FALLBACK_DIRECT`, `SAT_CONCURRENT_CONTRIBUYENTES`.
|
||||
|
||||
### 2026-07-31
|
||||
@@ -288,6 +306,7 @@ psql "$DATABASE_URL" -c "
|
||||
## 15. Próximos pasos
|
||||
|
||||
- [x] Implementar proxies rotativos para evitar bloqueo por IP del SAT.
|
||||
- [x] Reporte diario de errores por proxy.
|
||||
- [ ] Monitorear tasa de éxito tras proxies + concurrencia por contribuyente.
|
||||
- [ ] Revisar/renovar FIELs inválidas reportadas por el monitor.
|
||||
- [ ] Evaluar ampliar ventana horaria del daily (6–10 AM) si el volumen de RFCs supera el throughput con 10 paralelos.
|
||||
|
||||
Reference in New Issue
Block a user