Validación técnica de la API de BIND contra producción (GET-only)
Plan A del saldo CONFIRMADO (Total − Payments − CreditNotes, misma fila); flujo MVP sostenible 100% en lectura; PDF del CFDI por API. Hallazgo duro: sin recurso de pagos individuales ni REP (escalar a Pedro). Cliente extendido (Quotes, Currencies, Locations, pdf/xml, GET por ID estilo REST) y tipos reales en types.real.ts. Detalle en bind-api-sandbox/VALIDACION-API.md; reporte crudo y token gitignoreados.
This commit is contained in:
@@ -0,0 +1,857 @@
|
||||
/**
|
||||
* Validación técnica de la API REAL de BIND ERP (producción, cuenta Balam).
|
||||
* Actividad "Validación técnica de la API de BIND" — Etapa 0.
|
||||
*
|
||||
* REGLAS DURAS (no negociables):
|
||||
* - SOLO LECTURA. Este archivo únicamente construye peticiones GET; no existe
|
||||
* código capaz de emitir POST/PUT/PATCH/DELETE.
|
||||
* - El token se lee de `.env` (BIND_API_TOKEN) y JAMÁS se imprime, se loguea
|
||||
* ni se escribe en el reporte. El serializado final pasa por un scrub que
|
||||
* además redacta cualquier patrón tipo RFC o email por si el sanitizador
|
||||
* estructural dejara pasar algo.
|
||||
* - El reporte solo conserva ESTRUCTURA: nombres de campos, tipos, formatos,
|
||||
* conteos, códigos de estatus. Nunca valores reales (nombres, RFCs, montos,
|
||||
* folios, correos).
|
||||
* - Presupuesto duro de peticiones (default 120 < 150 acordado; el límite de
|
||||
* BIND es 20K/día). Cada retry cuenta contra el presupuesto.
|
||||
*
|
||||
* Salida: validation-output/report.json (sanitizado; el folder está gitignoreado
|
||||
* por defensa en profundidad) + resumen sanitizado en consola.
|
||||
*
|
||||
* Uso: npm run validate:real
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, "..");
|
||||
const OUT_DIR = join(ROOT, "validation-output");
|
||||
|
||||
// ─── Configuración ──────────────────────────────────────────────────────────
|
||||
|
||||
const env = loadEnv();
|
||||
const BASE = (env.BIND_BASE_URL ?? process.env.BIND_BASE_URL ?? "https://api.bind.com.mx").replace(/\/+$/, "");
|
||||
const TOKEN = env.BIND_API_TOKEN ?? env.BIND_API_KEY ?? process.env.BIND_API_TOKEN ?? "";
|
||||
const BUDGET = Number(env.VALIDATION_BUDGET ?? 120);
|
||||
const PACE_MS = 120; // pausa entre peticiones — gentileza con producción
|
||||
|
||||
function loadEnv(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
try {
|
||||
const raw = readFileSync(join(ROOT, ".env"), "utf8");
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
||||
if (m && m[1] && m[2] !== undefined) out[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
||||
}
|
||||
} catch {
|
||||
/* sin .env — se valida abajo */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Sonda HTTP (GET-only por construcción) ─────────────────────────────────
|
||||
|
||||
let used = 0;
|
||||
|
||||
interface Probe {
|
||||
path: string;
|
||||
status: number;
|
||||
ok: boolean;
|
||||
contentType: string | null;
|
||||
headers: Record<string, string>; // allowlist no sensible
|
||||
bodyShape: "array" | "odata-value" | "object" | "empty" | "non-json";
|
||||
rowCount: number | null;
|
||||
count: number | string | null; // odata.count viene como string en OData v3
|
||||
nextLink: boolean;
|
||||
errorSnippet?: string;
|
||||
/** SOLO en memoria — nunca va al reporte. */
|
||||
rows: unknown[] | null;
|
||||
}
|
||||
|
||||
const HEADER_KEEP = /rate|limit|quota|remain|retry-after|dataserviceversion|odata-version|content-type|www-authenticate|apim/i;
|
||||
|
||||
async function probeGet(path: string, tokenOverride?: string | null): Promise<Probe> {
|
||||
if (used >= BUDGET) throw new Error(`Presupuesto de ${BUDGET} peticiones agotado — abortando por seguridad.`);
|
||||
const token = tokenOverride === undefined ? TOKEN : tokenOverride;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
used++;
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${BASE}${path}`, {
|
||||
method: "GET", // ÚNICO método en todo el archivo
|
||||
headers,
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
});
|
||||
} catch (err) {
|
||||
await sleep(PACE_MS);
|
||||
if (attempt === 0) continue;
|
||||
return {
|
||||
path, status: 0, ok: false, contentType: null, headers: {},
|
||||
bodyShape: "empty", rowCount: null, count: null, nextLink: false,
|
||||
errorSnippet: `network: ${scrub(String((err as Error).message)).slice(0, 120)}`, rows: null,
|
||||
};
|
||||
}
|
||||
|
||||
const keep: Record<string, string> = {};
|
||||
res.headers.forEach((v, k) => { if (HEADER_KEEP.test(k)) keep[k] = v; });
|
||||
|
||||
const text = await res.text();
|
||||
let json: unknown = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch { /* non-json */ }
|
||||
|
||||
if ((res.status === 429 || res.status >= 500) && attempt === 0) {
|
||||
const ra = Number(res.headers.get("Retry-After") ?? 2);
|
||||
console.log(` ⏳ ${res.status} en ${path} — retry en ${ra}s`);
|
||||
await sleep(Math.min(ra, 10) * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { rows, bodyShape, count, nextLink } = extractRows(json, text);
|
||||
const probe: Probe = {
|
||||
path, status: res.status, ok: res.ok,
|
||||
contentType: res.headers.get("content-type"),
|
||||
headers: keep, bodyShape,
|
||||
rowCount: rows ? rows.length : null,
|
||||
count, nextLink, rows,
|
||||
};
|
||||
if (!res.ok) {
|
||||
const raw = typeof json === "object" && json !== null ? JSON.stringify(json) : text;
|
||||
probe.errorSnippet = scrub(raw ?? "").slice(0, 300);
|
||||
}
|
||||
await sleep(PACE_MS);
|
||||
return probe;
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
function extractRows(json: unknown, text: string): Pick<Probe, "rows" | "bodyShape" | "count" | "nextLink"> {
|
||||
if (json === null) return { rows: null, bodyShape: text.trim() ? "non-json" : "empty", count: null, nextLink: false };
|
||||
if (Array.isArray(json)) return { rows: json, bodyShape: "array", count: null, nextLink: false };
|
||||
if (typeof json === "object") {
|
||||
const o = json as Record<string, unknown>;
|
||||
const value = o["value"];
|
||||
if (Array.isArray(value)) {
|
||||
const count = (o["odata.count"] ?? o["@odata.count"] ?? null) as number | string | null;
|
||||
const nextLink = Boolean(o["odata.nextLink"] ?? o["@odata.nextLink"]);
|
||||
return { rows: value, bodyShape: "odata-value", count, nextLink };
|
||||
}
|
||||
return { rows: [json], bodyShape: "object", count: null, nextLink: false };
|
||||
}
|
||||
return { rows: null, bodyShape: "non-json", count: null, nextLink: false };
|
||||
}
|
||||
|
||||
// ─── Sanitizador estructural ────────────────────────────────────────────────
|
||||
|
||||
interface FieldInfo {
|
||||
name: string;
|
||||
types: string[];
|
||||
formats: string[];
|
||||
nullable: boolean;
|
||||
enumValues?: string[];
|
||||
}
|
||||
|
||||
/** Campos cuyo VALOR es un código de proceso (no dato personal) y puede documentarse. */
|
||||
const ENUM_FIELD = /status|estatus|type|tipo|method|metodo|use|uso|currency|moneda|cfdi|way/i;
|
||||
/** Nunca documentar valores de campos que huelan a monto/cantidad aunque matcheen arriba. */
|
||||
const ENUM_EXCLUDE = /total|amount|monto|price|cost|balance|exchange|sum|qty|quantity|rate|saldo/i;
|
||||
/** Literales de catálogo SAT (PPD/PUE, uso CFDI) — seguros y valiosos; se permite más largo. */
|
||||
const SAT_CATALOG_FIELD = /cfdi(use|paymentmethod|paymentterm)|paymentmethod|regimenfiscal/i;
|
||||
|
||||
function formatHint(v: unknown): string {
|
||||
if (v === null || v === undefined) return "null";
|
||||
if (typeof v === "boolean") return "boolean";
|
||||
if (typeof v === "number") return Number.isInteger(v) ? "integer" : "decimal";
|
||||
if (Array.isArray(v)) return "array";
|
||||
if (typeof v === "object") return "object";
|
||||
const s = String(v);
|
||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) return "guid/uuid";
|
||||
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) return "datetime-iso";
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return "date-iso";
|
||||
if (/^\/Date\(-?\d+([+-]\d{4})?\)\/$/.test(s)) return "datetime-wcf(/Date(ms)/)";
|
||||
if (/^[\w.+-]+@[\w-]+\.[\w.]+$/.test(s)) return "email(REDACTADO)";
|
||||
if (/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/.test(s)) return "rfc(REDACTADO)";
|
||||
if (/^https?:\/\//.test(s)) return "url";
|
||||
if (/^[A-Z]{3}$/.test(s)) return "code-3letras";
|
||||
return `string(≈${lenBucket(s.length)})`;
|
||||
}
|
||||
|
||||
function lenBucket(n: number): string {
|
||||
if (n === 0) return "vacío";
|
||||
if (n <= 10) return "corto";
|
||||
if (n <= 40) return "medio";
|
||||
return "largo";
|
||||
}
|
||||
|
||||
function safeEnumValue(fieldName: string, v: unknown): string | null {
|
||||
if (ENUM_EXCLUDE.test(fieldName)) return null;
|
||||
if (!ENUM_FIELD.test(fieldName)) return null;
|
||||
// Números: solo enteros pequeños (códigos de catálogo), jamás montos/decimales.
|
||||
if (typeof v === "number") return Number.isInteger(v) && Math.abs(v) < 1000 ? String(v) : null;
|
||||
const maxLen = SAT_CATALOG_FIELD.test(fieldName) ? 60 : 14;
|
||||
if (typeof v !== "string" || v.length === 0 || v.length > maxLen) return null;
|
||||
const h = formatHint(v);
|
||||
if (/REDACTADO|guid|datetime|date-iso|url/.test(h)) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** Analiza filas y devuelve SOLO estructura. Recorre objetos anidados un nivel (Lines[].Campo). */
|
||||
function analyzeRows(rows: unknown[], cap = 5): FieldInfo[] {
|
||||
const acc = new Map<string, { types: Set<string>; formats: Set<string>; nullable: boolean; enums: Set<string> }>();
|
||||
|
||||
const visit = (obj: Record<string, unknown>, prefix: string) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const name = prefix + k;
|
||||
let rec = acc.get(name);
|
||||
if (!rec) { rec = { types: new Set(), formats: new Set(), nullable: false, enums: new Set() }; acc.set(name, rec); }
|
||||
if (v === null || v === undefined) { rec.nullable = true; rec.types.add("null"); continue; }
|
||||
rec.types.add(Array.isArray(v) ? "array" : typeof v);
|
||||
rec.formats.add(formatHint(v));
|
||||
const ev = safeEnumValue(k, v);
|
||||
if (ev !== null && rec.enums.size < 10) rec.enums.add(ev);
|
||||
if (!prefix && Array.isArray(v) && typeof v[0] === "object" && v[0] !== null) {
|
||||
visit(v[0] as Record<string, unknown>, `${k}[].`);
|
||||
} else if (!prefix && typeof v === "object" && !Array.isArray(v)) {
|
||||
visit(v as Record<string, unknown>, `${k}.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const row of rows.slice(0, cap)) {
|
||||
if (typeof row === "object" && row !== null && !Array.isArray(row)) {
|
||||
visit(row as Record<string, unknown>, "");
|
||||
}
|
||||
}
|
||||
|
||||
return [...acc.entries()].map(([name, r]) => {
|
||||
const fi: FieldInfo = {
|
||||
name,
|
||||
types: [...r.types],
|
||||
formats: [...r.formats],
|
||||
nullable: r.nullable,
|
||||
};
|
||||
if (r.enums.size > 0) fi.enumValues = [...r.enums];
|
||||
return fi;
|
||||
});
|
||||
}
|
||||
|
||||
function scrub(s: string): string {
|
||||
let out = s;
|
||||
if (TOKEN) out = out.replaceAll(TOKEN, "[TOKEN-REDACTADO]");
|
||||
return out
|
||||
.replace(/[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}/g, "[RFC-REDACTADO]")
|
||||
.replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, "[EMAIL-REDACTADO]");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// ─── Reporte ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InventoryEntry {
|
||||
resource: string;
|
||||
path: string;
|
||||
status: number;
|
||||
bodyShape: string;
|
||||
rowCount: number | null;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const report = {
|
||||
meta: {
|
||||
ranAt: new Date().toISOString(),
|
||||
baseUrl: BASE,
|
||||
tokenSource: ".env BIND_API_TOKEN (usuario BIND: Arturo Rosas, según correo de Pedro 6-jul-2026)",
|
||||
budget: BUDGET,
|
||||
requestsUsed: 0,
|
||||
note: "Reporte sanitizado: solo estructura (campos, tipos, formatos, conteos, códigos). Sin valores reales.",
|
||||
},
|
||||
auth: {} as Record<string, unknown>,
|
||||
inventory: [] as InventoryEntry[],
|
||||
shapes: {} as Record<string, { rowsAnalyzed: number; totalCount: number | string | null; fields: FieldInfo[] }>,
|
||||
odata: {} as Record<string, unknown>,
|
||||
byId: {} as Record<string, unknown>,
|
||||
multiCompany: {} as Record<string, unknown>,
|
||||
balanceQuestion: {} as Record<string, unknown>,
|
||||
headersObserved: {} as Record<string, string>,
|
||||
paymentsHunt: {} as Record<string, unknown>,
|
||||
statusSemantics: {} as Record<string, unknown>,
|
||||
prefactura: {} as Record<string, unknown>,
|
||||
pagination: {} as Record<string, unknown>,
|
||||
seriesHunt: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
function projection(p: Probe): Omit<Probe, "rows"> {
|
||||
const { rows: _rows, ...rest } = p;
|
||||
return rest;
|
||||
}
|
||||
|
||||
// ─── Fases ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const PRIORITY_RESOURCES = [
|
||||
"Invoices", "Clients", "Customers", "Payments",
|
||||
"Quotes", "Quotations", "Cotizaciones",
|
||||
"Products", "Currencies", "Warehouses", "Locations", "Branches", "Sucursales", "Series",
|
||||
];
|
||||
|
||||
const SECONDARY_RESOURCES = [
|
||||
"Activities", "CreditNotes", "Taxes", "PriceLists", "Prices",
|
||||
"Orders", "SalesOrders", "PurchaseOrders", "Providers", "Suppliers",
|
||||
"Banks", "BankAccounts", "Sellers", "Employees", "Users",
|
||||
"Companies", "Expenses", "Inventory", "CFDI", "CFDIs",
|
||||
];
|
||||
|
||||
async function phaseAuth(): Promise<string> {
|
||||
console.log("\n── Fase 1 · Autenticación");
|
||||
// Endpoint de referencia barato. Products está documentado públicamente.
|
||||
const ok = await probeGet("/api/Products?$top=1");
|
||||
const noToken = await probeGet("/api/Products?$top=1", null);
|
||||
const badToken = await probeGet("/api/Products?$top=1", "invalid-token-abc123");
|
||||
report.auth = {
|
||||
scheme: "Authorization: Bearer <token> (único header; sin Ocp-Apim-Subscription-Key)",
|
||||
validToken: projection(ok),
|
||||
missingToken: projection(noToken),
|
||||
invalidToken: projection(badToken),
|
||||
};
|
||||
Object.assign(report.headersObserved, ok.headers);
|
||||
console.log(` token válido → ${ok.status} · sin token → ${noToken.status} · token corrupto → ${badToken.status}`);
|
||||
if (!ok.ok) {
|
||||
console.log(" ⚠️ El token de .env NO autenticó contra /api/Products. Revisar antes de seguir.");
|
||||
}
|
||||
return ok.ok ? "ok" : "fail";
|
||||
}
|
||||
|
||||
async function phaseInventory(): Promise<Map<string, Probe>> {
|
||||
console.log("\n── Fase 2 · Inventario de recursos (GET {recurso}?$top=1)");
|
||||
const results = new Map<string, Probe>();
|
||||
for (const res of [...PRIORITY_RESOURCES, ...SECONDARY_RESOURCES]) {
|
||||
let p = await probeGet(`/api/${res}?$top=1`);
|
||||
let note = "";
|
||||
// Algunos endpoints podrían rechazar $top — reintenta plano solo para prioritarios.
|
||||
if (p.status === 400 && PRIORITY_RESOURCES.includes(res)) {
|
||||
const plain = await probeGet(`/api/${res}`);
|
||||
if (plain.ok) { p = plain; note = "existe pero rechaza $top=1 (400)"; }
|
||||
else note = "400 con y sin $top";
|
||||
}
|
||||
if (p.status === 404) note ||= "no existe con este nombre";
|
||||
if (p.status === 401 || p.status === 403) note ||= "sin permiso para este token";
|
||||
if (p.ok) note ||= `responde ${p.bodyShape}`;
|
||||
results.set(res, p);
|
||||
report.inventory.push({
|
||||
resource: res, path: p.path, status: p.status,
|
||||
bodyShape: p.bodyShape, rowCount: p.rowCount, note,
|
||||
});
|
||||
Object.assign(report.headersObserved, p.headers);
|
||||
console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res.padEnd(15)} → ${p.status}${note ? ` (${note})` : ""}`);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function phaseShapes(inventory: Map<string, Probe>): Promise<Map<string, unknown[]>> {
|
||||
console.log("\n── Fase 3 · Inventario de campos ($top=5, solo estructura)");
|
||||
const rowsByResource = new Map<string, unknown[]>();
|
||||
const targets = [...PRIORITY_RESOURCES, "Companies", "CreditNotes", "Activities"]
|
||||
.filter((r) => inventory.get(r)?.ok);
|
||||
for (const res of targets) {
|
||||
const p = await probeGet(`/api/${res}?$top=5`);
|
||||
const rows = p.rows ?? [];
|
||||
rowsByResource.set(res, rows);
|
||||
report.shapes[res] = {
|
||||
rowsAnalyzed: Math.min(rows.length, 5),
|
||||
totalCount: p.count,
|
||||
fields: analyzeRows(rows),
|
||||
};
|
||||
console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res.padEnd(15)} → ${rows.length} filas analizadas, ${report.shapes[res].fields.length} campos`);
|
||||
}
|
||||
return rowsByResource;
|
||||
}
|
||||
|
||||
async function phaseOData(rowsByResource: Map<string, unknown[]>): Promise<void> {
|
||||
console.log("\n── Fase 4 · Mecánica OData");
|
||||
// Elige el mejor recurso disponible para las pruebas.
|
||||
const resource = ["Invoices", "Products", "Clients", "Customers"].find((r) => rowsByResource.has(r));
|
||||
if (!resource) { report.odata = { skipped: "ningún recurso disponible" }; return; }
|
||||
const fields = report.shapes[resource]?.fields ?? [];
|
||||
const idField = fields.find((f) => /^id$/i.test(f.name))?.name ?? fields.find((f) => f.formats.includes("guid/uuid"))?.name;
|
||||
const dateField = fields.find((f) => f.formats.some((x) => x.startsWith("datetime")))?.name;
|
||||
const numField = fields.find((f) => /integer|decimal/.test(f.formats.join()) && !/id/i.test(f.name))?.name;
|
||||
const enumField = fields.find((f) => f.enumValues?.length);
|
||||
|
||||
const odata: Record<string, unknown> = { resourceUsed: resource, idField, dateField, numField };
|
||||
|
||||
// $top / $skip coherentes
|
||||
const a = await probeGet(`/api/${resource}?$top=2`);
|
||||
const b = await probeGet(`/api/${resource}?$top=1&$skip=1`);
|
||||
if (idField && a.rows?.length === 2 && b.rows?.length === 1) {
|
||||
const id = (r: unknown) => (r as Record<string, unknown>)[idField];
|
||||
odata.topSkip = { works: id(a.rows[1]) === id(b.rows[0]), statuses: [a.status, b.status] };
|
||||
} else {
|
||||
odata.topSkip = { works: null, statuses: [a.status, b.status], note: "sin filas suficientes para comparar" };
|
||||
}
|
||||
|
||||
// $orderby
|
||||
if (dateField) {
|
||||
const o = await probeGet(`/api/${resource}?$top=3&$orderby=${encodeURIComponent(`${dateField} asc`)}`);
|
||||
let sorted: boolean | null = null;
|
||||
if (o.rows && o.rows.length >= 2) {
|
||||
const vals = o.rows.map((r) => String((r as Record<string, unknown>)[dateField] ?? ""));
|
||||
sorted = vals.every((v, i) => i === 0 || v >= String(vals[i - 1]));
|
||||
}
|
||||
odata.orderby = { field: dateField, status: o.status, ascendingVerified: sorted };
|
||||
}
|
||||
|
||||
// Conteo total: v3 ($inlinecount) vs v4 ($count)
|
||||
const v3 = await probeGet(`/api/${resource}?$top=1&$inlinecount=allpages`);
|
||||
const v4 = await probeGet(`/api/${resource}?$top=1&$count=true`);
|
||||
odata.countMechanism = {
|
||||
"v3 $inlinecount=allpages": { status: v3.status, countReturned: v3.count !== null, totalCount: v3.count },
|
||||
"v4 $count=true": { status: v4.status, countReturned: v4.count !== null, totalCount: v4.count },
|
||||
};
|
||||
|
||||
// $filter numérico
|
||||
if (numField) {
|
||||
const f = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${numField} ge 0`)}`);
|
||||
odata.filterNumeric = { expr: `${numField} ge 0`, status: f.status, rows: f.rowCount };
|
||||
}
|
||||
|
||||
// $filter por enum observado (código de proceso, no dato personal)
|
||||
if (enumField?.enumValues?.[0] !== undefined) {
|
||||
const isNum = /^\d+$/.test(enumField.enumValues[0]);
|
||||
const lit = isNum ? enumField.enumValues[0] : `'${enumField.enumValues[0]}'`;
|
||||
const f = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${enumField.name} eq ${lit}`)}`);
|
||||
odata.filterEnum = { expr: `${enumField.name} eq ${lit}`, status: f.status, rows: f.rowCount };
|
||||
}
|
||||
|
||||
// $filter por fecha: sintaxis v3 (datetime'...') vs v4 (literal ISO)
|
||||
if (dateField) {
|
||||
const fv3 = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${dateField} ge datetime'2020-01-01T00:00:00'`)}`);
|
||||
let fv4: Probe | null = null;
|
||||
if (!fv3.ok) fv4 = await probeGet(`/api/${resource}?$top=1&$filter=${encodeURIComponent(`${dateField} ge 2020-01-01T00:00:00Z`)}`);
|
||||
odata.filterDate = {
|
||||
"v3 datetime'...'": fv3.status,
|
||||
...(fv4 ? { "v4 ISO literal": fv4.status } : {}),
|
||||
verdict: fv3.ok ? "sintaxis OData v3" : fv4?.ok ? "sintaxis OData v4" : "ninguna funcionó",
|
||||
};
|
||||
}
|
||||
|
||||
// $select
|
||||
if (idField) {
|
||||
const s = await probeGet(`/api/${resource}?$top=1&$select=${idField}`);
|
||||
odata.select = { status: s.status, works: s.ok };
|
||||
// Página default (barata: solo IDs) — tamaño de página y nextLink
|
||||
if (s.ok) {
|
||||
const d = await probeGet(`/api/${resource}?$select=${idField}`);
|
||||
odata.defaultPage = { rowsReturned: d.rowCount, nextLinkPresent: d.nextLink, totalCount: d.count, status: d.status };
|
||||
}
|
||||
}
|
||||
|
||||
report.odata = odata;
|
||||
console.log(` recurso de prueba: ${resource} · resultados en reporte`);
|
||||
}
|
||||
|
||||
async function phaseById(rowsByResource: Map<string, unknown[]>): Promise<void> {
|
||||
console.log("\n── Fase 5 · GET por ID");
|
||||
const resource = ["Invoices", "Clients", "Customers", "Products"].find((r) => rowsByResource.has(r) && (rowsByResource.get(r)?.length ?? 0) > 0);
|
||||
if (!resource) { report.byId = { skipped: "sin filas para tomar un ID" }; return; }
|
||||
const fields = report.shapes[resource]?.fields ?? [];
|
||||
const idField = fields.find((f) => /^id$/i.test(f.name))?.name;
|
||||
if (!idField) { report.byId = { skipped: "sin campo ID identificable" }; return; }
|
||||
const firstId = String((rowsByResource.get(resource)![0] as Record<string, unknown>)[idField] ?? "");
|
||||
if (!firstId) { report.byId = { skipped: "ID vacío" }; return; }
|
||||
|
||||
const odataStyle = await probeGet(`/api/${resource}(guid'${firstId}')`);
|
||||
let restStyle: Probe | null = null;
|
||||
if (!odataStyle.ok) restStyle = await probeGet(`/api/${resource}/${firstId}`);
|
||||
report.byId = {
|
||||
resource,
|
||||
"odata (guid'...')": odataStyle.status,
|
||||
...(restStyle ? { "rest (/{id})": restStyle.status } : {}),
|
||||
verdict: odataStyle.ok ? "estilo OData key" : restStyle?.ok ? "estilo REST /{id}" : "ninguno funcionó",
|
||||
// ¿El detalle trae más campos que la lista? (p.ej. Lines embebidas)
|
||||
detailFieldCount: odataStyle.ok || restStyle?.ok
|
||||
? analyzeRows((odataStyle.ok ? odataStyle : restStyle!).rows ?? []).length
|
||||
: null,
|
||||
listFieldCount: fields.length,
|
||||
};
|
||||
if (odataStyle.ok || restStyle?.ok) {
|
||||
const detail = (odataStyle.ok ? odataStyle : restStyle!).rows ?? [];
|
||||
report.shapes[`${resource}(detalle por ID)`] = {
|
||||
rowsAnalyzed: detail.length,
|
||||
totalCount: null,
|
||||
fields: analyzeRows(detail),
|
||||
};
|
||||
}
|
||||
console.log(` ${resource} por ID → OData:${odataStyle.status}${restStyle ? ` / REST:${restStyle.status}` : ""}`);
|
||||
}
|
||||
|
||||
function phaseBalanceVerdict(): void {
|
||||
const inv = report.shapes["Invoices"] ?? report.shapes["Invoices(detalle por ID)"];
|
||||
if (!inv) { report.balanceQuestion = { verdict: "SIN VALIDAR — Invoices no accesible" }; return; }
|
||||
const all = [
|
||||
...(report.shapes["Invoices"]?.fields ?? []),
|
||||
...(report.shapes["Invoices(detalle por ID)"]?.fields ?? []),
|
||||
];
|
||||
const balanceish = [...new Set(all.filter((f) => /balance|saldo|due|paid|pending|remain|credit|debt|payment/i.test(f.name)).map((f) => f.name))];
|
||||
report.balanceQuestion = {
|
||||
fieldsMatching: balanceish,
|
||||
verdict: balanceish.length > 0
|
||||
? "REVISAR nombres arriba — hay candidatos a saldo por factura (Plan A probable)"
|
||||
: "Sin campo de saldo visible en Invoices (apunta a Plan B: total − pagos)",
|
||||
};
|
||||
}
|
||||
|
||||
function phaseMultiCompany(inventory: Map<string, Probe>): void {
|
||||
const companies = inventory.get("Companies");
|
||||
const companyFields: string[] = [];
|
||||
for (const [res, shape] of Object.entries(report.shapes)) {
|
||||
for (const f of shape.fields) {
|
||||
if (/company|empresa/i.test(f.name)) companyFields.push(`${res}.${f.name}`);
|
||||
}
|
||||
}
|
||||
report.multiCompany = {
|
||||
companiesEndpoint: companies ? { status: companies.status, rowCount: companies.rowCount } : "no sondeado",
|
||||
companyLikeFields: companyFields,
|
||||
note: "1 token = 1 usuario BIND (kickoff #22). Si Companies no existe o regresa 1 fila, el token está acotado a la empresa del usuario (Balam).",
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Fases de ronda 2 (sondeos dirigidos) ─────────────────────────────
|
||||
|
||||
async function phasePaymentsHunt(): Promise<void> {
|
||||
console.log("\n── Ronda 2 · Búsqueda del recurso de pagos");
|
||||
const candidates = [
|
||||
"Payment", "ClientPayments", "CustomerPayments", "Incomes", "Income",
|
||||
"Deposits", "Collections", "PaymentComplements", "Complements", "CashReceipts",
|
||||
"AccountsReceivable", "Receivables",
|
||||
];
|
||||
const found: Record<string, number> = {};
|
||||
for (const c of candidates) {
|
||||
const p = await probeGet(`/api/${c}?$top=1`);
|
||||
found[c] = p.status;
|
||||
console.log(` [${String(used).padStart(3)}/${BUDGET}] ${c.padEnd(20)} → ${p.status}`);
|
||||
if (p.ok && p.rows) {
|
||||
report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) };
|
||||
}
|
||||
}
|
||||
// Sub-recurso bajo factura: /api/Invoices/{id}/Payments
|
||||
const inv = await probeGet("/api/Invoices?$top=1");
|
||||
const invId = inv.rows?.[0] ? String((inv.rows[0] as Record<string, unknown>)["ID"] ?? "") : "";
|
||||
const subProbes: Record<string, number> = {};
|
||||
if (invId) {
|
||||
for (const sub of ["Payments", "payments", "CreditNotes"]) {
|
||||
const p = await probeGet(`/api/Invoices/${invId}/${sub}`);
|
||||
subProbes[`Invoices/{id}/${sub}`] = p.status;
|
||||
console.log(` [${String(used).padStart(3)}/${BUDGET}] Invoices/{id}/${sub.padEnd(12)} → ${p.status}`);
|
||||
if (p.ok && p.rows?.length) {
|
||||
report.shapes[`Invoices/{id}/${sub}`] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) };
|
||||
}
|
||||
}
|
||||
}
|
||||
report.paymentsHunt = {
|
||||
collectionCandidates: found,
|
||||
subResources: subProbes,
|
||||
invoiceEmbeddedField: "Invoices.Payments es numérico (acumulado pagado) — ver shapes",
|
||||
};
|
||||
}
|
||||
|
||||
async function phaseStatusSemantics(): Promise<void> {
|
||||
console.log("\n── Ronda 2 · Semántica de estatus");
|
||||
// Quotes: la lista trae Status(int) + StatusText(string) en la misma fila — mapeo barato.
|
||||
const quoteMap: Record<string, string> = {};
|
||||
for (const code of [0, 1, 2, 3, 4, 5]) {
|
||||
const p = await probeGet(`/api/Quotes?$top=1&$filter=${encodeURIComponent(`Status eq ${code}`)}`);
|
||||
if (p.ok && p.rows?.length) {
|
||||
const r = p.rows[0] as Record<string, unknown>;
|
||||
quoteMap[String(code)] = String(r["StatusText"] ?? "(sin StatusText)");
|
||||
} else if (!p.ok) {
|
||||
quoteMap[String(code)] = `error ${p.status}`;
|
||||
}
|
||||
}
|
||||
console.log(` Quotes.Status → ${JSON.stringify(quoteMap)}`);
|
||||
|
||||
// Invoices: lista trae Status(int); el label vive en el detalle (Status string + StatusCode int).
|
||||
const invoiceMap: Record<string, string> = {};
|
||||
for (const code of [0, 1, 2, 3, 4, 5]) {
|
||||
const list = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent(`Status eq ${code}`)}`);
|
||||
if (!list.ok || !list.rows?.length) continue;
|
||||
const id = String((list.rows[0] as Record<string, unknown>)["ID"] ?? "");
|
||||
if (!id) continue;
|
||||
const det = await probeGet(`/api/Invoices/${id}`);
|
||||
if (det.ok && det.rows?.length) {
|
||||
const d = det.rows[0] as Record<string, unknown>;
|
||||
invoiceMap[String(code)] = `${String(d["Status"] ?? "?")} (StatusCode=${String(d["StatusCode"] ?? "?")})`;
|
||||
}
|
||||
}
|
||||
console.log(` Invoices.Status → ${JSON.stringify(invoiceMap)}`);
|
||||
report.statusSemantics = {
|
||||
quotesStatusToText: quoteMap,
|
||||
invoicesStatusToLabel: invoiceMap,
|
||||
note: "Labels vienen del propio API (campo StatusText / Status del detalle); son códigos de proceso, no datos personales.",
|
||||
};
|
||||
}
|
||||
|
||||
async function phasePrefactura(): Promise<void> {
|
||||
console.log("\n── Ronda 2 · ¿Prefacturas visibles? (UUID null / IsFiscalInvoice false)");
|
||||
const uuidNull = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent("UUID eq null")}`);
|
||||
const notFiscal = await probeGet(`/api/Invoices?$top=1&$filter=${encodeURIComponent("IsFiscalInvoice eq false")}`);
|
||||
report.prefactura = {
|
||||
"filter UUID eq null": { status: uuidNull.status, rows: uuidNull.rowCount },
|
||||
"filter IsFiscalInvoice eq false": { status: notFiscal.status, rows: notFiscal.rowCount },
|
||||
interpretation:
|
||||
(uuidNull.rowCount ?? 0) > 0 || (notFiscal.rowCount ?? 0) > 0
|
||||
? "Hay documentos sin timbrar visibles en /api/Invoices — prefactura distinguible vía UUID/IsFiscalInvoice"
|
||||
: "Con los filtros probados no aparecieron prefacturas — posible que /api/Invoices solo exponga CFDI timbrados (validar en UI con Arturo)",
|
||||
};
|
||||
console.log(` UUID null → ${uuidNull.status}/${uuidNull.rowCount} filas · IsFiscalInvoice false → ${notFiscal.status}/${notFiscal.rowCount} filas`);
|
||||
}
|
||||
|
||||
async function phaseDetailShapes(): Promise<void> {
|
||||
console.log("\n── Ronda 2 · Detalle por ID de Clients y Quotes");
|
||||
for (const res of ["Clients", "Quotes"]) {
|
||||
const list = await probeGet(`/api/${res}?$top=1`);
|
||||
const id = list.rows?.[0] ? String((list.rows[0] as Record<string, unknown>)["ID"] ?? "") : "";
|
||||
if (!id) continue;
|
||||
const det = await probeGet(`/api/${res}/${id}`);
|
||||
console.log(` [${String(used).padStart(3)}/${BUDGET}] ${res}/{id} → ${det.status} (${det.ok ? analyzeRows(det.rows ?? []).length : 0} campos)`);
|
||||
if (det.ok && det.rows?.length) {
|
||||
report.shapes[`${res}(detalle por ID)`] = {
|
||||
rowsAnalyzed: det.rows.length,
|
||||
totalCount: null,
|
||||
fields: analyzeRows(det.rows),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function phasePagination(): Promise<void> {
|
||||
console.log("\n── Ronda 2 · Paginación");
|
||||
const plain = await probeGet("/api/Quotes"); // colección chica conocida; mide page size default
|
||||
const top101 = await probeGet("/api/Quotes?$top=101");
|
||||
report.pagination = {
|
||||
"GET sin $top (Quotes)": { rows: plain.rowCount, nextLink: plain.nextLink, count: plain.count, status: plain.status },
|
||||
"GET $top=101 (Quotes)": { rows: top101.rowCount, nextLink: top101.nextLink, status: top101.status },
|
||||
note: "Si rows < total esperado y no hay nextLink, la paginación es por $top/$skip manual.",
|
||||
};
|
||||
console.log(` sin $top → ${plain.rowCount} filas (nextLink=${plain.nextLink}) · $top=101 → ${top101.rowCount} filas`);
|
||||
}
|
||||
|
||||
async function phaseSeriesHunt(): Promise<void> {
|
||||
console.log("\n── Ronda 2 · Series de facturación");
|
||||
const out: Record<string, number> = {};
|
||||
for (const c of ["InvoiceSeries", "Folios", "DocumentSeries"]) {
|
||||
const p = await probeGet(`/api/${c}?$top=1`);
|
||||
out[c] = p.status;
|
||||
if (p.ok && p.rows?.length) {
|
||||
report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) };
|
||||
}
|
||||
}
|
||||
report.seriesHunt = out;
|
||||
console.log(` ${JSON.stringify(out)}`);
|
||||
}
|
||||
|
||||
/** Carga el reporte previo y purga enumValues de campos tipo monto (fuga corregida). */
|
||||
function loadPreviousReport(): boolean {
|
||||
try {
|
||||
const prev = JSON.parse(readFileSync(join(OUT_DIR, "report.json"), "utf8")) as typeof report;
|
||||
Object.assign(report, prev);
|
||||
for (const shape of Object.values(report.shapes)) {
|
||||
for (const f of shape.fields) {
|
||||
if (f.enumValues && ENUM_EXCLUDE.test(f.name)) delete f.enumValues;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Fases de ronda 3 (cabos sueltos) ───────────────────────────────────────
|
||||
|
||||
async function phaseCaps(): Promise<void> {
|
||||
console.log("\n── Ronda 3 · Límites de $top y tamaño de colecciones");
|
||||
const top100 = await probeGet("/api/Quotes?$top=100");
|
||||
const skip100 = await probeGet("/api/Invoices?$top=1&$skip=100");
|
||||
const skip1000 = await probeGet("/api/Invoices?$top=1&$skip=1000");
|
||||
report.pagination = {
|
||||
...(report.pagination as Record<string, unknown>),
|
||||
"GET $top=100 (Quotes)": { status: top100.status, rows: top100.rowCount },
|
||||
topCapVerdict: top100.ok ? "$top acepta hasta 100; 101 → 500" : `$top=100 también falla (${top100.status})`,
|
||||
invoicesSizeBracket: {
|
||||
"skip=100 devuelve fila": (skip100.rowCount ?? 0) > 0,
|
||||
"skip=1000 devuelve fila": (skip1000.rowCount ?? 0) > 0,
|
||||
note: "brackets aproximados del total de facturas históricas, sin descargar la colección",
|
||||
},
|
||||
};
|
||||
console.log(` $top=100 → ${top100.status} · skip100 → ${skip100.rowCount} · skip1000 → ${skip1000.rowCount}`);
|
||||
}
|
||||
|
||||
async function phaseCfdiLiterals(): Promise<void> {
|
||||
console.log("\n── Ronda 3 · Literales CFDI (PPD/PUE, uso CFDI) — sanitizador corregido");
|
||||
const list = await probeGet("/api/Invoices?$top=2");
|
||||
const rows = list.rows ?? [];
|
||||
const detailRows: unknown[] = [];
|
||||
for (const r of rows) {
|
||||
const id = String((r as Record<string, unknown>)["ID"] ?? "");
|
||||
if (!id) continue;
|
||||
const det = await probeGet(`/api/Invoices/${id}`);
|
||||
if (det.ok && det.rows) detailRows.push(...det.rows);
|
||||
}
|
||||
if (detailRows.length) {
|
||||
report.shapes["Invoices(detalle por ID)"] = {
|
||||
rowsAnalyzed: detailRows.length,
|
||||
totalCount: null,
|
||||
fields: analyzeRows(detailRows),
|
||||
};
|
||||
const f = report.shapes["Invoices(detalle por ID)"].fields.find((x) => x.name === "CFDIPaymentMethod");
|
||||
console.log(` CFDIPaymentMethod literales → ${JSON.stringify(f?.enumValues ?? [])}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function phasePaymentsHuntEs(): Promise<void> {
|
||||
console.log("\n── Ronda 3 · Pagos: nombres en español y variantes finales");
|
||||
const extra: Record<string, number> = {};
|
||||
for (const c of ["Cobros", "Pagos", "InvoicePayments", "PaymentsReceived"]) {
|
||||
const p = await probeGet(`/api/${c}?$top=1`);
|
||||
extra[c] = p.status;
|
||||
if (p.ok && p.rows?.length) {
|
||||
report.shapes[c] = { rowsAnalyzed: p.rows.length, totalCount: p.count, fields: analyzeRows(p.rows) };
|
||||
}
|
||||
}
|
||||
report.paymentsHunt = { ...(report.paymentsHunt as Record<string, unknown>), spanishAndFinal: extra };
|
||||
console.log(` ${JSON.stringify(extra)}`);
|
||||
}
|
||||
|
||||
async function phaseDocEndpoints(): Promise<void> {
|
||||
console.log("\n── Ronda 3 · ¿Descarga de PDF/XML del CFDI? (solo estatus; el contenido se descarta)");
|
||||
const list = await probeGet("/api/Invoices?$top=1");
|
||||
const id = list.rows?.[0] ? String((list.rows[0] as Record<string, unknown>)["ID"] ?? "") : "";
|
||||
const out: Record<string, unknown> = {};
|
||||
if (id) {
|
||||
for (const sub of ["pdf", "xml", "PDF", "cfdi"]) {
|
||||
const p = await probeGet(`/api/Invoices/${id}/${sub}`);
|
||||
out[`Invoices/{id}/${sub}`] = { status: p.status, contentType: p.contentType };
|
||||
if (p.ok) break; // con uno confirmado basta
|
||||
}
|
||||
}
|
||||
(report as Record<string, unknown>)["documentDownload"] = out;
|
||||
console.log(` ${JSON.stringify(out)}`);
|
||||
}
|
||||
|
||||
// ─── Ronda 4: verificación aritmética del saldo (en memoria, sin persistir montos) ───
|
||||
|
||||
async function phaseBalanceArithmetic(): Promise<void> {
|
||||
console.log("\n── Ronda 4 · Verificación del saldo: ¿Payments acumula lo pagado? (aritmética en memoria)");
|
||||
const paid = await probeGet(`/api/Invoices?$top=5&$filter=${encodeURIComponent("Status eq 1")}`);
|
||||
const active = await probeGet(`/api/Invoices?$top=5&$filter=${encodeURIComponent("Status eq 0")}`);
|
||||
const near = (a: number, b: number) => Math.abs(a - b) < 0.01;
|
||||
const summarize = (rows: unknown[]) =>
|
||||
rows.map((r) => {
|
||||
const o = r as Record<string, unknown>;
|
||||
const total = Number(o["Total"] ?? NaN);
|
||||
const pay = Number(o["Payments"] ?? NaN);
|
||||
const cn = Number(o["CreditNotes"] ?? 0);
|
||||
return { settled: near(pay + cn, total), residualPositive: total - pay - cn > 0.01 };
|
||||
});
|
||||
const paidChecks = summarize(paid.rows ?? []);
|
||||
const activeChecks = summarize(active.rows ?? []);
|
||||
report.balanceQuestion = {
|
||||
...(report.balanceQuestion as Record<string, unknown>),
|
||||
arithmetic: {
|
||||
paidSample: { n: paidChecks.length, allSettled: paidChecks.every((c) => c.settled) },
|
||||
activeSample: { n: activeChecks.length, allWithResidual: activeChecks.every((c) => c.residualPositive) },
|
||||
formula: "SaldoPorFactura = Total − Payments − CreditNotes (campos de la MISMA fila de /api/Invoices)",
|
||||
},
|
||||
};
|
||||
console.log(
|
||||
` pagadas: ${paidChecks.length} muestras, todas saldadas=${paidChecks.every((c) => c.settled)} · activas: ${activeChecks.length} muestras, todas con residual=${activeChecks.every((c) => c.residualPositive)}`,
|
||||
);
|
||||
|
||||
// XML del CFDI (quedó sin probar en ronda 3 por el break temprano)
|
||||
const id = paid.rows?.[0] ? String((paid.rows[0] as Record<string, unknown>)["ID"] ?? "") : "";
|
||||
if (id) {
|
||||
const xml = await probeGet(`/api/Invoices/${id}/xml`);
|
||||
const doc = ((report as Record<string, unknown>)["documentDownload"] ?? {}) as Record<string, unknown>;
|
||||
doc["Invoices/{id}/xml"] = { status: xml.status, contentType: xml.contentType };
|
||||
(report as Record<string, unknown>)["documentDownload"] = doc;
|
||||
console.log(` xml → ${xml.status} (${xml.contentType})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const round2 = process.argv.includes("--round2");
|
||||
const round3 = process.argv.includes("--round3");
|
||||
const round4 = process.argv.includes("--round4");
|
||||
console.log(`BIND API · validación técnica SOLO LECTURA${round2 ? " · RONDA 2" : round3 ? " · RONDA 3" : round4 ? " · RONDA 4" : ""}`);
|
||||
console.log(`Base: ${BASE} · presupuesto: ${BUDGET} peticiones`);
|
||||
if (!TOKEN) {
|
||||
console.error("❌ No hay BIND_API_TOKEN en bind-api-sandbox/.env — abortando.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log("Token cargado desde .env (no se imprime).");
|
||||
if (/localhost|127\.0\.0\.1/.test(BASE)) {
|
||||
console.log("⚠️ Base URL apunta al mock local; esto NO valida producción.");
|
||||
}
|
||||
|
||||
if (round2 || round3 || round4) {
|
||||
if (!loadPreviousReport()) {
|
||||
console.error("❌ --round2/--round3/--round4 requieren validation-output/report.json previo.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
used = report.meta.requestsUsed; // presupuesto acumulado entre rondas
|
||||
if (round2) {
|
||||
await phasePaymentsHunt();
|
||||
await phaseStatusSemantics();
|
||||
await phasePrefactura();
|
||||
await phaseDetailShapes();
|
||||
await phasePagination();
|
||||
await phaseSeriesHunt();
|
||||
} else if (round3) {
|
||||
await phaseCaps();
|
||||
await phaseCfdiLiterals();
|
||||
await phasePaymentsHuntEs();
|
||||
await phaseDocEndpoints();
|
||||
} else {
|
||||
await phaseBalanceArithmetic();
|
||||
}
|
||||
phaseBalanceVerdict();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = await phaseAuth();
|
||||
if (auth !== "ok") {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const inventory = await phaseInventory();
|
||||
const rowsByResource = await phaseShapes(inventory);
|
||||
await phaseOData(rowsByResource);
|
||||
await phaseById(rowsByResource);
|
||||
phaseBalanceVerdict();
|
||||
phaseMultiCompany(inventory);
|
||||
finish();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
report.meta.requestsUsed = used;
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
const serialized = scrub(JSON.stringify(report, null, 2));
|
||||
writeFileSync(join(OUT_DIR, "report.json"), serialized, "utf8");
|
||||
console.log(`\n✔ Reporte sanitizado escrito en validation-output/report.json`);
|
||||
console.log(`✔ Peticiones usadas: ${used}/${BUDGET}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error fatal:", scrub(String(err?.message ?? err)));
|
||||
finish();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user