feat(api): módulo del API Gateway (reportes Crystal): sesión y cola propias, matrícula de reportes y ejecución con PDF

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 17:46:09 -05:00
1 parent 74743503f0
commit 2a29c63e7d
13 files changed
+774 -2

No files matched your search

+4 -1
View File
@@ -8,7 +8,10 @@ export type AuditAction =
| 'sl_action'
| 'sl_denied'
| 'permissions_set'
| 'permissions_remove';
| 'permissions_remove'
| 'report_export'
| 'report_register'
| 'report_unregister';
export type AuditOutcome = 'ok' | 'error' | 'denied';
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { type GatewayParam, exportBody, paramsFromLoadCr } from './gateway.js';
describe('paramsFromLoadCr', () => {
it('convierte lo que describe LoadCR en definiciones de matrícula', () => {
const params = paramsFromLoadCr([
{ name: 'PWZRunDate', type: 'xsd:date', description: 'Fecha de ejecución de pago:', isOptionalPrompt: 'true', valueRangeKind: 'Range', allowMultiValue: 'false', defaultValues: [] },
{ name: 'Dockey@', type: 'xsd:decimal', description: 'Especificar Dockey@:', isOptionalPrompt: 'false', valueRangeKind: 'Discrete', allowMultiValue: 'false' },
{ name: 'BP@From OCRD', type: 'xsd:string', description: 'Socios de negocios', isOptionalPrompt: 'true', allowMultiValue: 'true', defaultValues: ['C1'] },
{ type: 'xsd:string' },
]);
expect(params).toEqual([
{ name: 'PWZRunDate', type: 'xsd:date', label: 'Fecha de ejecución de pago', kind: 'date', required: false, range: true },
{ name: 'Dockey@', type: 'xsd:decimal', label: 'Dockey@', kind: 'number', required: true },
{ name: 'BP@From OCRD', type: 'xsd:string', label: 'Socios de negocios', kind: 'text', required: false, multiple: true, defaultValue: 'C1' },
]);
});
});
describe('exportBody', () => {
const p = (over: Partial<GatewayParam>): GatewayParam => ({ name: 'P', type: 'xsd:string', label: 'Parámetro', kind: 'text', required: false, ...over });
it('un valor va como lista de listas, con el nombre y el tipo del parámetro', () => {
expect(exportBody([p({ name: 'Dockey@', type: 'xsd:decimal', required: true })], { 'Dockey@': ' 137138 ' }).body).toEqual([{ name: 'Dockey@', type: 'xsd:decimal', value: [['137138']] }]);
});
it('varios valores van uno por lista y un rango, desde y hasta en una sola', () => {
expect(exportBody([p({ multiple: true })], { P: ['A', ' ', 'B'] }).body[0].value).toEqual([['A'], ['B']]);
expect(exportBody([p({ range: true })], { P: ['2026-01-01', '2026-01-31'] }).body[0].value).toEqual([['2026-01-01', '2026-01-31']]);
expect(exportBody([p({ range: true })], { P: ['2026-01-01', ''] }).body[0].value).toEqual([['2026-01-01', '2026-01-01']]); // sin «hasta», el mismo día
});
it('un opcional vacío no se envía y un obligatorio vacío se reporta', () => {
const r = exportBody([p({ name: 'A', label: 'Cliente', required: true }), p({ name: 'B' })], { A: '', B: '' });
expect(r.body).toEqual([]);
expect(r.missing).toEqual(['Cliente']);
});
it('ignora valores de parámetros que no están matriculados', () => {
expect(exportBody([p({ name: 'A' })], { A: '1', Otro: '2' }).body.map((b) => b.name)).toEqual(['A']);
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Reportes del API Gateway de SAP B1 (Crystal Reports): cómo se matriculan, qué parámetros piden y cómo se arma lo
* que se envía a `ExportPDFData`. Lo comparten la API y la web.
*/
export type GatewayParamKind = 'text' | 'number' | 'date' | 'select' | 'lookup';
/** Un parámetro de un reporte matriculado. */
export interface GatewayParam {
/** Nombre del parámetro en Crystal, con su sufijo (`Item@Select * from OITM`, `Dockey@`). */
name: string;
/** Tipo de Crystal (`xsd:string`, `xsd:decimal`, `xsd:date`…); se envía tal cual. */
type: string;
label: string;
/** Cómo se pide al usuario: texto, número, fecha, lista fija o ventana de selección. */
kind: GatewayParamKind;
required: boolean;
/** Se piden dos valores, desde y hasta. */
range?: boolean;
/** Se admiten varios valores. */
multiple?: boolean;
defaultValue?: string;
placeholder?: string;
/** Opciones de una lista fija (`kind = select`). */
options?: { value: string; label: string }[];
/** Ventana de selección (`kind = lookup`): identificador de su catálogo en la web (`socios`, `articulos`, `doc:Invoices`…). */
lookup?: string;
}
/** Un reporte matriculado: el que los usuarios ven en la lista para ejecutar. */
export interface GatewayReport {
/** Código del reporte en el API Gateway (`RCRI0001`, `DLN20015`). */
code: string;
title: string;
category: string;
description?: string;
enabled: boolean;
params: GatewayParam[];
}
/** Un reporte que el API Gateway autoriza (`LoadAuthorizedCRList`). */
export interface AuthorizedReport {
code: string;
name: string;
/** Carpeta de Crystal (`#1000004#4`). */
root_name: string;
root_guid: string;
}
/** Lo que el usuario escribió: un texto, o una lista si el parámetro es de rango o admite varios valores. */
export type GatewayValues = Readonly<Record<string, string | readonly string[] | undefined>>;
/** Un parámetro tal como lo describe `LoadCR`. */
interface CrystalParameter {
name?: string;
type?: string;
description?: string;
isOptionalPrompt?: string;
valueRangeKind?: string;
allowMultiValue?: string;
defaultValues?: unknown[];
}
const NUMERIC = /(decimal|int|double|float|number|currency)/i;
/** Los parámetros de un reporte (respuesta de `LoadCR`) como definiciones de matrícula, para completarlas y ajustarlas. */
export function paramsFromLoadCr(resultSet: readonly CrystalParameter[]): GatewayParam[] {
return resultSet
.filter((p): p is CrystalParameter & { name: string } => typeof p.name === 'string' && p.name !== '')
.map((p) => {
const type = p.type ?? 'xsd:string';
const first = p.defaultValues?.[0];
return {
name: p.name,
type,
label: (p.description ?? p.name).replace(/[:\s]+$/, '').replace(/^Especificar\s+/i, '') || p.name,
kind: /date/i.test(type) ? 'date' : NUMERIC.test(type) ? 'number' : 'text',
required: p.isOptionalPrompt !== 'true',
...(p.valueRangeKind === 'Range' ? { range: true } : {}),
...(p.allowMultiValue === 'true' ? { multiple: true } : {}),
...(first !== undefined && first !== null && first !== '' ? { defaultValue: String(first) } : {}),
};
});
}
const isBlank = (v: string | readonly string[] | undefined): boolean => v === undefined || (typeof v === 'string' ? v.trim() === '' : v.every((x) => x.trim() === ''));
/**
* Cuerpo de `ExportPDFData`: por cada parámetro matriculado `{ name, type, value }`, con el valor como lista de listas
* (`[["137138"]]` un valor, `[["a"],["b"]]` varios, `[["desde","hasta"]]` un rango). Un parámetro opcional sin valor no se
* envía. Devuelve los parámetros obligatorios que faltan por separado.
*/
export function exportBody(params: readonly GatewayParam[], values: GatewayValues): { body: { name: string; type: string; value: string[][] }[]; missing: string[] } {
const body: { name: string; type: string; value: string[][] }[] = [];
const missing: string[] = [];
for (const p of params) {
const raw = values[p.name];
if (isBlank(raw)) {
if (p.required) missing.push(p.label);
continue;
}
const list = (typeof raw === 'string' ? [raw] : [...(raw as readonly string[])]).map((x) => x.trim());
let value: string[][];
if (p.range) value = [[list[0] ?? '', list[1] || list[0] || '']];
else if (p.multiple) value = list.filter((x) => x !== '').map((x) => [x]);
else value = [[list[0]]];
body.push({ name: p.name, type: p.type, value });
}
return { body, missing };
}
+1
View File
@@ -4,3 +4,4 @@ export * from './company.js';
export * from './permissions.js';
export * from './sl.js';
export * from './menu.js';
export * from './gateway.js';