diff --git a/CLAUDE.md b/CLAUDE.md index 96176ce..a483dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de - **Proxy `/api/sl/:recurso`**: el frontend arma `$select/$filter/...`; el backend valida y ejecuta solo recursos registrados (`apps/api/src/sl/sl-resources.ts`), con permiso de menú y cabeceras `B1S-*` de lista blanca. Nunca registrar `EmployeesInfo` (tiene `U_WEB_PASS`). `InventoryRequests` no existe: es `InventoryTransferRequests`. - **Permisos de menú** en SQLite (`SQLITE_PATH`), patrones `*`, `/ventas/*`, `/ventas/ordenes`; `ADMIN_WEB_USERS` garantiza el acceso total; no se puede quitar el último admin. - **Listas de documentos:** más recientes primero (`DocEntry desc`), 50 por página, **Estado = Abierto por defecto** (como el mini-erp; cambiarlo es un `defaultValue`), filtro de fecha **siempre rango [desde – hasta]**, multi-selección con **chips**, búsqueda numérica por igualdad. **Sin filtro base en las demás listas; las de `Invoices` filtran por subtipo exacto:** cuatro pantallas comparten el recurso y se separan por `DocumentSubType` (enum `BoDocumentSubType`: `bod_None` 0, `bod_InvoiceExempt` 1, `bod_DebitMemo` 2, `bod_Bill` 3, `bod_ExemptBill` 4, `bod_PurchaseDebitMemo` 5, `bod_ExportInvoice` 6…) y por `ReserveInvoice` (`tYES` = de reserva): Facturas (`tNO` y `eq bod_None`), Boletas (`tNO` y `eq bod_Bill`), Facturas de reserva (`tYES` y `eq bod_None`) y Boletas de reserva (`tYES` y `eq bod_Bill`); `baseFilter` para la lista y `fixed` (subtipo, y `ReserveInvoice` en las reservas) al crear. Nunca `ne`: los demás subtipos (factura exenta, nota de débito, boleta exenta, exportación) no se mezclan y tendrán su propia pantalla si se necesitan. `baseFilter` de la pantalla se aplica también a la navegación primero/anterior/siguiente/último. El PDF y los reportes se migran después vía API Gateway (otro servicio, con su propia cola y sesión). -- Reportes/PDF **no** se generan en la base; el botón PDF queda pendiente hasta migrar el API Gateway. +- **Reportes Gateway** (Crystal Reports por el API Gateway de SAP B1, `apps/api/src/gateway/` y `apps/web/.../features/reports/`): menú «Reportes Gateway» con «Reportes» (lista agrupada por categoría y formulario de parámetros con el PDF en pantalla) y «Matrícula de reportes» (solo administradores: de los reportes que el gateway autoriza se eligen los que se usan, con título, categoría y cómo se pide cada parámetro). Servicio aparte del Service Layer: su sesión y su cola propias (`APIGATEWAY_*` en el `.env`; los datos salieron del `.env` del mini-erp y el certificado autofirmado va en `certs/apigateway.pem`). La matrícula vive en SQLite (`gateway_reports`) y arma el cuerpo de `ExportPDFData` (`[{name, type, value:[[…]]}]`: un valor `[[v]]`, varios `[[a],[b]]`, rango `[[desde,hasta]]`); los parámetros se piden con los mismos controles y ventanas de selección de los formularios (texto, número, fecha, lista fija, ventana de selección con un catálogo de listas: socios, artículos, almacenes, documentos por `DocEntry`…). Verificado en la base HANA: los reportes a medida (`DLN20015`, `RCRI0031`) generan PDF con valores discretos y fechas `yyyy-MM-dd`; los estándar de SAP (`RCRI0001`, `RCRI0002`, con títulos y rangos) devolvieron `(---)` con los formatos probados, el formato de rango/varios valores sigue sin confirmar. Los títulos y separadores de un reporte (`…@Title`, `…@separator`) se marcan ocultos. Pendiente: el botón PDF de cada documento (hace falta el código del reporte de cada tipo). ## Flujo de las pantallas de documento diff --git a/apps/api/.env.example b/apps/api/.env.example index 832b524..cad3247 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -32,3 +32,16 @@ SAPB1_SL_MAX_CONCURRENCY=1 # 3) Solo pruebas locales: SAPB1_SL_VERIFY_SSL=false (rechazado si NODE_ENV=production). SAPB1_SL_CA_FILE= SAPB1_SL_VERIFY_SSL=true + +# --- SAP Business One API Gateway (reportes Crystal): opcional, sin URL el módulo queda sin configurar --- +APIGATEWAY_URL= +APIGATEWAY_USER= +APIGATEWAY_PASSWORD= +APIGATEWAY_COMPANYDB= +APIGATEWAY_DBINSTANCE= +# Espera máxima (segundos) de cada llamada y reportes que se generan a la vez +APIGATEWAY_TIMEOUT=60 +APIGATEWAY_MAX_CONCURRENCY=2 +# Certificado del gateway (PEM, relativo a apps/api) si es autofirmado +APIGATEWAY_CA_FILE= +APIGATEWAY_VERIFY_SSL=true diff --git a/apps/api/README.md b/apps/api/README.md index 375771c..2eee465 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -88,3 +88,21 @@ Tabla `audit_log` en la misma SQLite. Registra inicios de sesión (correctos, fa Pendiente de afinar: retención programada, IP real detrás del proxy de desarrollo, exportar a CSV. +## Reportes del API Gateway (Crystal Reports) + +Servicio aparte del Service Layer (`APIGATEWAY_*` en el `.env`): su propio login (`/login` con base, usuario, clave e instancia), su propia sesión (se renueva sola ante un 401) y su propia cola (`APIGATEWAY_MAX_CONCURRENCY` reportes a la vez; generar uno pesa). Sin `APIGATEWAY_URL` el módulo responde 503 y el resto de la API no se ve afectado. + +Llama a `rs/v1/LoadAuthorizedCRList` (los reportes autorizados), `rs/v1/LoadCR` (sus parámetros) y `rs/v1/ExportPDFData` (el PDF, en base64; responde 201 y, si falla, un texto de error con 200 o `(---)` si el reporte no produjo nada). + +| Endpoint | Quién | Descripción | +|---|---|---| +| `GET /api/gateway/authorized` | Administrador | Reportes que el gateway autoriza | +| `GET /api/gateway/authorized/:code/parameters` | Administrador | Parámetros de un reporte, como definiciones de matrícula | +| `GET /api/gateway/registry` | Administrador | Reportes matriculados | +| `PUT /api/gateway/registry/:code` | Administrador | Matricula o actualiza un reporte (título, categoría, habilitado y cómo se pide cada parámetro) | +| `DELETE /api/gateway/registry/:code` | Administrador | Quita un reporte de la matrícula | +| `GET /api/gateway/reports` | Permiso de menú `/reportes/ejecutar` | Reportes habilitados | +| `POST /api/gateway/reports/:code/export` `{values}` | Permiso de menú `/reportes/ejecutar` | Genera el PDF | + +La matrícula (tabla `gateway_reports` de la SQLite) es lo que arma el cuerpo de `ExportPDFData`: el usuario solo manda valores y el servidor los ordena según los parámetros matriculados (nombre y tipo de Crystal), exige los obligatorios y descarta lo que no esté matriculado. La auditoría registra `report_register`, `report_unregister` y `report_export` (solo los nombres de los parámetros, nunca los valores). + diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index a23bb26..34a6bf1 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -110,6 +110,25 @@ const devRoutes: Routes = [ }, ]; +/** Reportes del API Gateway: la lista para ejecutar con su formulario de parámetros y la matrícula (administradores). */ +const reportRoutes: Routes = [ + { + path: 'reportes/ejecutar', + canActivate: [permissionGuard], + data: { path: '/reportes/ejecutar', title: menuLabel('/reportes/ejecutar') }, + children: [ + { path: '', loadComponent: () => import('./features/reports/reports.page').then((m) => m.ReportsPage) }, + { path: ':code', loadComponent: () => import('./features/reports/report-runner.page').then((m) => m.ReportRunnerPage) }, + ], + }, + { + path: 'reportes/matricula', + canActivate: [permissionGuard], + data: { path: '/reportes/matricula', title: menuLabel('/reportes/matricula') }, + loadComponent: () => import('./features/reports/registry.page').then((m) => m.RegistryPage), + }, +]; + /** Administración: el permiso de menú `/admin/permisos` solo lo da el acceso total (`*`). */ const adminRoutes: Routes = [ { @@ -127,7 +146,7 @@ const adminRoutes: Routes = [ ]; /** Resto de pantallas del menú (maestros, etc.): aún sin construir. */ -const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/admin/permisos', '/admin/auditoria']); +const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']); const pendingRoutes: Routes = MENU.flatMap((m) => m.items) .filter((item) => !builtPaths.has(item.path)) .map( @@ -160,6 +179,7 @@ export const routes: Routes = [ ...masterRoutes, ...paymentRoutes, ...journalRoutes, + ...reportRoutes, ...adminRoutes, ...pendingRoutes, ], diff --git a/apps/web/src/app/core/menu/menu.service.spec.ts b/apps/web/src/app/core/menu/menu.service.spec.ts index 66b0cbf..07e7de3 100644 --- a/apps/web/src/app/core/menu/menu.service.spec.ts +++ b/apps/web/src/app/core/menu/menu.service.spec.ts @@ -36,7 +36,7 @@ describe('MenuService', () => { const p = menu.ensureLoaded(); ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] }); await p; - expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'socios', 'admin']); + expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'finanzas', 'reportes', 'socios', 'admin']); }); it('si falla marca el error y permite reintentar', async () => { diff --git a/apps/web/src/app/features/reports/gateway-api.ts b/apps/web/src/app/features/reports/gateway-api.ts new file mode 100644 index 0000000..f5f2c47 --- /dev/null +++ b/apps/web/src/app/features/reports/gateway-api.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import type { AuthorizedReport, GatewayParam, GatewayReport, GatewayValues } from '@basic-erp/sap-types'; +import { firstValueFrom } from 'rxjs'; +import { API_URL } from '../../core/api'; + +/** Reportes del API Gateway a través de `/api/gateway` (matrícula y ejecución). */ +@Injectable({ providedIn: 'root' }) +export class GatewayApi { + private readonly http = inject(HttpClient); + + /** Los reportes habilitados, para la lista de ejecución. */ + reports(): Promise { + return firstValueFrom(this.http.get(`${API_URL}/gateway/reports`)); + } + + /** Genera el PDF de un reporte con los valores escritos. */ + export(code: string, values: GatewayValues): Promise { + return firstValueFrom(this.http.post(`${API_URL}/gateway/reports/${encodeURIComponent(code)}/export`, { values }, { responseType: 'blob' })); + } + + // --- matrícula (administradores) ----------------------------------------------------------- + + registry(): Promise { + return firstValueFrom(this.http.get(`${API_URL}/gateway/registry`)); + } + + /** Los reportes que el API Gateway autoriza (de ellos se eligen los que se matriculan). */ + authorized(): Promise { + return firstValueFrom(this.http.get(`${API_URL}/gateway/authorized`)); + } + + /** Los parámetros de un reporte, como definiciones listas para ajustar. */ + parameters(code: string): Promise { + return firstValueFrom(this.http.get(`${API_URL}/gateway/authorized/${encodeURIComponent(code)}/parameters`)); + } + + save(report: GatewayReport): Promise { + const { code, ...body } = report; + return firstValueFrom(this.http.put(`${API_URL}/gateway/registry/${encodeURIComponent(code)}`, body)); + } + + remove(code: string): Promise { + return firstValueFrom(this.http.delete(`${API_URL}/gateway/registry/${encodeURIComponent(code)}`)); + } +} diff --git a/apps/web/src/app/features/reports/gateway-lookups.ts b/apps/web/src/app/features/reports/gateway-lookups.ts new file mode 100644 index 0000000..7b3bdb6 --- /dev/null +++ b/apps/web/src/app/features/reports/gateway-lookups.ts @@ -0,0 +1,92 @@ +import type { VhLookup } from '../../shared/value-help/value-help.types'; + +/** + * Las ventanas de selección que se pueden asignar a un parámetro de reporte (las mismas listas de valores de los + * formularios). El valor que recibe el reporte es el del campo `valueKey` de la fila elegida. + */ +export interface GatewayLookup { + id: string; + label: string; + lookup: VhLookup; +} + +const col = (key: string, title: string, width?: string, mono = false) => ({ key, title, ...(width ? { width } : {}), ...(mono ? { mono: true } : {}) }); + +const partners = (id: string, label: string, type?: 'cCustomer' | 'cSupplier'): GatewayLookup => ({ + id, + label, + lookup: { + title: label, + resource: 'BusinessPartners', + orderby: 'CardCode', + valueKey: 'CardCode', + ...(type ? { baseFilter: `CardType eq '${type}'` } : {}), + columns: [col('CardCode', 'Código', '140px', true), col('CardName', 'Nombre')], + }, +}); + +/** Un documento por su `DocEntry` (los reportes de documentos reciben ese número, como `Dockey@`). */ +const document = (resource: string, label: string): GatewayLookup => ({ + id: `doc:${resource}`, + label: `Documento: ${label}`, + lookup: { + title: label, + resource, + orderby: 'DocEntry desc', + valueKey: 'DocEntry', + columns: [{ ...col('DocNum', 'Número', '110px', true), type: 'number', align: 'left', format: (r) => String(r['DocNum'] ?? ''), searchable: true }, col('CardCode', 'Socio', '120px', true), col('CardName', 'Nombre'), { ...col('DocDate', 'Fecha', '110px'), type: 'date' }, col('DocEntry', 'DocEntry', '100px', true)], + }, +}); + +export const GATEWAY_LOOKUPS: readonly GatewayLookup[] = [ + partners('socios', 'Socios de negocio'), + partners('clientes', 'Clientes', 'cCustomer'), + partners('proveedores', 'Proveedores', 'cSupplier'), + { + id: 'articulos', + label: 'Artículos', + lookup: { title: 'Artículos', resource: 'Items', orderby: 'ItemCode', valueKey: 'ItemCode', columns: [col('ItemCode', 'Código', '150px', true), col('ItemName', 'Descripción')] }, + }, + { + id: 'almacenes', + label: 'Almacenes', + lookup: { title: 'Almacenes', resource: 'Warehouses', orderby: 'WarehouseCode', valueKey: 'WarehouseCode', columns: [col('WarehouseCode', 'Código', '110px', true), col('WarehouseName', 'Nombre')] }, + }, + { + id: 'proyectos', + label: 'Proyectos', + lookup: { title: 'Proyectos', resource: 'Projects', orderby: 'Code', valueKey: 'Code', columns: [col('Code', 'Código', '110px', true), col('Name', 'Nombre')] }, + }, + { + id: 'monedas', + label: 'Monedas', + lookup: { title: 'Monedas', resource: 'Currencies', orderby: 'Code', valueKey: 'Code', columns: [col('Code', 'Código', '90px', true), col('Name', 'Nombre')] }, + }, + { + id: 'cuentas', + label: 'Cuentas contables (código interno)', + lookup: { title: 'Plan de cuentas', resource: 'ChartOfAccounts', orderby: 'Code', valueKey: 'Code', columns: [col('Code', 'Código', '170px', true), col('Name', 'Nombre')] }, + }, + document('Quotations', 'Ofertas de venta'), + document('Orders', 'Órdenes de venta'), + document('DeliveryNotes', 'Entregas'), + document('Invoices', 'Facturas de venta'), + document('CreditNotes', 'Notas de crédito de venta'), + document('Returns', 'Devoluciones de venta'), + document('PurchaseQuotations', 'Ofertas de compra'), + document('PurchaseOrders', 'Órdenes de compra'), + document('PurchaseDeliveryNotes', 'Recepciones de compra'), + document('PurchaseInvoices', 'Facturas de proveedor'), + document('PurchaseCreditNotes', 'Notas de crédito de compra'), + document('PurchaseReturns', 'Devoluciones de compra'), + document('IncomingPayments', 'Pagos recibidos'), + document('VendorPayments', 'Pagos efectuados'), + document('InventoryGenEntries', 'Entradas de inventario'), + document('InventoryGenExits', 'Salidas de inventario'), + document('StockTransfers', 'Transferencias de stock'), +]; + +export const lookupById = (id: string | undefined): GatewayLookup | undefined => GATEWAY_LOOKUPS.find((l) => l.id === id); + +/** Categorías con que se agrupan los reportes matriculados (las de los módulos del menú). */ +export const GATEWAY_CATEGORIES: readonly string[] = ['Ventas', 'Compras', 'Inventario', 'Bancos', 'Finanzas', 'Socios de negocio', 'Otros']; diff --git a/apps/web/src/app/features/reports/registry.page.ts b/apps/web/src/app/features/reports/registry.page.ts new file mode 100644 index 0000000..57a91dc --- /dev/null +++ b/apps/web/src/app/features/reports/registry.page.ts @@ -0,0 +1,288 @@ +import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import type { AuthorizedReport, GatewayParam, GatewayParamKind, GatewayReport } from '@basic-erp/sap-types'; +import { apiErrorMessage } from '../../core/api'; +import { SelectField, TextField, TextareaField, CheckboxField } from '../../shared/form'; +import type { DetailColumn } from '../../shared/grid/grid.types'; +import { RowsTable } from '../../shared/grid/rows-table'; +import type { ListColumn, ListRow } from '../../shared/list/list.types'; +import { ValueHelp } from '../../shared/value-help/value-help'; +import { SboWindow, type StatusMessage } from '../../shared/window'; +import { GatewayApi } from './gateway-api'; +import { GATEWAY_CATEGORIES, GATEWAY_LOOKUPS } from './gateway-lookups'; + +type Row = Record; + +interface Draft { + code: string; + isNew: boolean; + title: string; + category: string; + description: string; + enabled: boolean; + params: Row[]; +} + +const YES = 'Sí'; +const NO = 'No'; +const KINDS: { value: GatewayParamKind; label: string }[] = [ + { value: 'text', label: 'Texto' }, + { value: 'number', label: 'Número' }, + { value: 'date', label: 'Fecha' }, + { value: 'select', label: 'Lista fija' }, + { value: 'lookup', label: 'Ventana de selección' }, +]; +const yesNo = [{ value: YES, label: YES }, { value: NO, label: NO }]; + +/** Una definición de parámetro como fila editable de la tabla. */ +const toRow = (p: GatewayParam): Row => ({ + name: p.name, + type: p.type, + label: p.label, + kind: p.kind, + required: p.required ? YES : NO, + range: p.range ? YES : NO, + multiple: p.multiple ? YES : NO, + hidden: p.hidden ? YES : NO, + lookup: p.lookup ?? '', + options: (p.options ?? []).map((o) => (o.value === o.label ? o.value : `${o.value}=${o.label}`)).join('; '), + defaultValue: p.defaultValue ?? '', +}); + +/** Lo contrario: la fila de la tabla como definición de parámetro (sin los campos que no aplican a su tipo). */ +export function toParam(r: Row): GatewayParam { + const text = (k: string): string => String(r[k] ?? '').trim(); + const kind = (text('kind') || 'text') as GatewayParamKind; + const options = text('options') + .split(';') + .map((o) => o.trim()) + .filter(Boolean) + .map((o) => { + const [value, ...label] = o.split('='); + return { value: value.trim(), label: (label.join('=') || value).trim() }; + }); + return { + name: text('name'), + type: text('type') || 'xsd:string', + label: text('label') || text('name'), + kind, + required: text('required') === YES, + ...(text('range') === YES ? { range: true } : {}), + ...(text('multiple') === YES ? { multiple: true } : {}), + ...(text('hidden') === YES ? { hidden: true } : {}), + ...(kind === 'lookup' && text('lookup') ? { lookup: text('lookup') } : {}), + ...(kind === 'select' && options.length ? { options } : {}), + ...(text('defaultValue') ? { defaultValue: text('defaultValue') } : {}), + }; +} + +/** + * Matrícula de los reportes del API Gateway: de los que el gateway autoriza se eligen los que se van a usar y se + * define cómo se pide cada parámetro (texto, número, fecha, lista fija o ventana de selección). Solo administradores. + */ +@Component({ + selector: 'sbo-gateway-registry-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CheckboxField, RowsTable, SboWindow, SelectField, TextField, TextareaField, ValueHelp], + template: ` + + @if (draft(); as d) { +
+
+ + + +
+
+ + +
+
+

Parámetros

+

«Tipo de control» decide cómo se pide al usuario; con «Ventana de selección» se elige la lista. «Oculto» es para títulos y separadores del reporte: no se piden ni se envían. «Opciones» de una lista fija: valor=etiqueta; valor=etiqueta.

+ + } @else { +
+ +
+ + + + + + @for (r of rows(); track r.code) { + + + + + + + + + } @empty { + + } + +
CódigoTítuloCategoríaParámetrosEstado
{{ r.code }}{{ r.title }}{{ r.category }}{{ r.params.length }}{{ r.enabled ? 'Habilitado' : 'Deshabilitado' }} + + @if (confirmRemove() === r.code) { + + + } @else { + + } +
{{ registry.isLoading() ? 'Cargando…' : 'Aún no hay reportes matriculados. Pulse «Matricular reporte».' }}
+ } + @if (draft()) { + + } + @if (draft()) { + + } +
+ @if (picking()) { + + } + `, + styles: ` + :host { display: block; } + h2 { margin: 14px 0 2px; font-size: 13px; font-weight: 600; } + .hint { margin: 0 0 6px; color: var(--quiet); font-size: 12px; } + .bar { margin-bottom: 8px; } + .grid { width: 100%; border-collapse: collapse; } + .grid th { padding: 4px 8px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; text-align: left; } + .grid td { padding: 3px 8px; border: 1px solid var(--line-soft); } + .num { text-align: right; } + .code { font-family: var(--mono, monospace); } + .acts { display: flex; gap: 6px; justify-content: flex-end; } + tr.none td { padding: 14px; text-align: center; color: var(--quiet); } + `, +}) +export class RegistryPage { + private readonly api = inject(GatewayApi); + protected readonly title = inject(ActivatedRoute).snapshot.data['title'] as string; + + protected readonly categories = GATEWAY_CATEGORIES.map((c) => ({ value: c, label: c })); + protected readonly registry = resource({ loader: () => this.api.registry() }); + protected readonly rows = computed(() => (this.registry.hasValue() ? this.registry.value() : [])); + + protected readonly draft = signal(null); + protected readonly saving = signal(false); + protected readonly confirmRemove = signal(null); + private readonly message = signal(null); + + protected readonly status = computed(() => { + if (this.registry.error()) return { kind: 'err', text: apiErrorMessage(this.registry.error()) }; + return this.message() ?? { kind: 'info', text: this.draft() ? 'Ajuste cómo se pide cada parámetro y pulse «Guardar».' : '' }; + }); + + /** Las columnas de la tabla de parámetros. */ + protected readonly columns: readonly DetailColumn[] = [ + { key: 'name', title: 'Parámetro del reporte', type: 'text', readonly: true, width: '230px' }, + { key: 'label', title: 'Etiqueta', type: 'text', width: '180px' }, + { key: 'kind', title: 'Tipo de control', type: 'select', options: KINDS, width: '150px' }, + { key: 'required', title: 'Obligatorio', type: 'select', options: yesNo, width: '95px' }, + { key: 'range', title: 'Rango', type: 'select', options: yesNo, width: '80px' }, + { key: 'multiple', title: 'Varios', type: 'select', options: yesNo, width: '80px' }, + { key: 'lookup', title: 'Ventana de selección', type: 'select', options: GATEWAY_LOOKUPS.map((l) => ({ value: l.id, label: l.label })), width: '220px' }, + { key: 'options', title: 'Opciones (lista fija)', type: 'text', width: '200px' }, + { key: 'defaultValue', title: 'Valor por defecto', type: 'text', width: '140px' }, + { key: 'hidden', title: 'Oculto', type: 'select', options: yesNo, width: '80px' }, + ]; + + // --- elegir un reporte autorizado ------------------------------------------------------------- + + protected readonly picking = signal(false); + private readonly authorized = signal([]); + protected readonly pickColumns: readonly ListColumn[] = [ + { key: 'code', title: 'Código', width: '120px', mono: true, searchable: true }, + { key: 'name', title: 'Nombre', searchable: true }, + ]; + /** Los autorizados que todavía no están matriculados. */ + protected readonly authorizedRows = computed(() => { + const done = new Set(this.rows().map((r) => r.code)); + return this.authorized().filter((a) => !done.has(a.code)).map((a) => ({ ...a })); + }); + + protected async startPick(): Promise { + this.message.set({ kind: 'info', text: 'Consultando los reportes autorizados en el API Gateway…' }); + try { + this.authorized.set(await this.api.authorized()); + this.message.set(null); + this.picking.set(true); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } + } + + protected async picked(rows: ListRow[]): Promise { + this.picking.set(false); + const chosen = rows[0] as unknown as AuthorizedReport | undefined; + if (!chosen) return; + this.message.set({ kind: 'info', text: `Cargando los parámetros de ${chosen.code}…` }); + try { + const params = await this.api.parameters(chosen.code); + this.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow) }); + this.message.set(null); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } + } + + // --- editar y guardar ---------------------------------------------------------------------------- + + protected edit(r: GatewayReport): void { + this.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow) }); + this.message.set(null); + } + + protected patch(change: Partial): void { + this.draft.update((d) => (d ? { ...d, ...change } : d)); + this.message.set(null); + } + + protected async save(): Promise { + const d = this.draft(); + if (!d || this.saving()) return; + if (!d.title.trim()) { + this.message.set({ kind: 'err', text: 'Indique el título.' }); + return; + } + const lookups = d.params.filter((r) => r['kind'] === 'lookup' && !r['lookup']); + if (lookups.length) { + this.message.set({ kind: 'err', text: `Elija la ventana de selección de: ${lookups.map((r) => r['label'] || r['name']).join(', ')}.` }); + return; + } + this.saving.set(true); + try { + await this.api.save({ code: d.code, title: d.title.trim(), category: d.category || 'Otros', ...(d.description.trim() ? { description: d.description.trim() } : {}), enabled: d.enabled, params: d.params.map(toParam) }); + this.draft.set(null); + this.registry.reload(); + this.message.set({ kind: 'ok', text: `Reporte ${d.code} ${d.isNew ? 'matriculado' : 'actualizado'}.` }); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } finally { + this.saving.set(false); + } + } + + protected async remove(code: string): Promise { + this.confirmRemove.set(null); + try { + await this.api.remove(code); + this.registry.reload(); + this.message.set({ kind: 'ok', text: `Reporte ${code} quitado de la matrícula.` }); + } catch (e) { + this.message.set({ kind: 'err', text: apiErrorMessage(e) }); + } + } +} diff --git a/apps/web/src/app/features/reports/report-param-field.spec.ts b/apps/web/src/app/features/reports/report-param-field.spec.ts new file mode 100644 index 0000000..6a5dafd --- /dev/null +++ b/apps/web/src/app/features/reports/report-param-field.spec.ts @@ -0,0 +1,96 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import type { GatewayParam } from '@basic-erp/sap-types'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { ReportParamField } from './report-param-field'; + +const param = (over: Partial = {}): GatewayParam => ({ name: 'P', type: 'xsd:string', label: 'Parámetro', kind: 'text', required: false, ...over }); + +describe('ReportParamField', () => { + let fixture: ComponentFixture; + const el = () => fixture.nativeElement as HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const cmp = () => fixture.componentInstance as any; + const labels = () => [...el().querySelectorAll('label')].map((l) => l.textContent?.trim()); + const inputs = () => [...el().querySelectorAll('input')]; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + + async function show(p: GatewayParam, value: string | string[] = '') { + fixture.componentRef.setInput('param', p); + fixture.componentRef.setInput('value', value); + await tick(); + } + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ imports: [ReportParamField], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents(); + fixture = TestBed.createComponent(ReportParamField); + }); + + it('un texto, un número y una fecha piden su control con su etiqueta', async () => { + await show(param({ label: 'Nombre' }), 'abc'); + expect(labels()).toContain('Nombre'); + expect(inputs()[0].value).toBe('abc'); + await show(param({ kind: 'number', label: 'Cantidad' }), '5'); + expect(inputs()[0].value).toBe('5'); + await show(param({ kind: 'date', label: 'Fecha' }), '2026-10-01'); + expect(el().querySelector('input[type=date]')!.value).toBe('2026-10-01'); + }); + + it('emite el valor escrito como texto; un número vacío queda vacío', async () => { + await show(param({ kind: 'number' }), ''); + const input = inputs()[0]; + input.dispatchEvent(new Event('focus')); + input.value = '12.5'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new Event('blur')); + await tick(); + expect(fixture.componentInstance.value()).toBe('12.5'); + }); + + it('una lista fija ofrece sus opciones', async () => { + await show(param({ kind: 'select', options: [{ value: '1', label: 'Uno' }, { value: '2', label: 'Dos' }] }), '2'); + const select = el().querySelector('select') as HTMLSelectElement; + expect([...select.options].map((o) => o.textContent?.trim())).toEqual(expect.arrayContaining(['Uno', 'Dos'])); + expect(select.value).toBe('2'); + }); + + it('un rango pide desde y hasta y los emite como lista', async () => { + await show(param({ kind: 'date', range: true }), ['2026-01-01', '2026-01-31']); + expect(labels().join(' ')).toContain('desde / hasta'); + const [from, to] = [...el().querySelectorAll('input[type=date]')]; + expect([from.value, to.value]).toEqual(['2026-01-01', '2026-01-31']); + cmp().setPair(1, '2026-02-28'); + expect(fixture.componentInstance.value()).toEqual(['2026-01-01', '2026-02-28']); + }); + + it('una ventana de selección de un solo valor es un campo de lista y, con varios, junta lo que se marca en chips', async () => { + await show(param({ kind: 'lookup', lookup: 'almacenes', label: 'Almacén' }), '01'); + expect(el().querySelector('sbo-lookup-field')).not.toBeNull(); + await show(param({ kind: 'lookup', lookup: 'almacenes', label: 'Almacenes', multiple: true }), ['01']); + expect([...el().querySelectorAll('.chip')].map((c) => c.textContent?.replace('×', '').trim())).toEqual(['01']); + cmp().picked([{ WarehouseCode: '02' }, { WarehouseCode: '01' }, { WarehouseCode: '03' }]); + expect(fixture.componentInstance.value()).toEqual(['01', '02', '03']); // sin repetir + cmp().remove('02'); + expect(fixture.componentInstance.value()).toEqual(['01', '03']); + }); + + it('varios valores de texto se escriben separados por comas', async () => { + await show(param({ multiple: true, label: 'Códigos' }), ['A', 'B']); + expect(inputs()[0].value).toBe('A, B'); + expect(cmp().split(' X , Y,, Z ')).toEqual(['X', 'Y', 'Z']); + }); + + it('varios valores de una lista fija se marcan con casillas', async () => { + await show(param({ kind: 'select', multiple: true, options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] }), ['a']); + const boxes = [...el().querySelectorAll('input[type=checkbox]')]; + expect(boxes.map((b) => b.checked)).toEqual([true, false]); + cmp().toggle('b'); + expect(fixture.componentInstance.value()).toEqual(['a', 'b']); + }); +}); diff --git a/apps/web/src/app/features/reports/report-param-field.ts b/apps/web/src/app/features/reports/report-param-field.ts new file mode 100644 index 0000000..dc53b4c --- /dev/null +++ b/apps/web/src/app/features/reports/report-param-field.ts @@ -0,0 +1,163 @@ +import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core'; +import type { GatewayParam } from '@basic-erp/sap-types'; +import { DateField, DateInput, LookupField, NumberField, NumberInput, SboField, SelectField, TextField, TextInput } from '../../shared/form'; +import type { ListRow } from '../../shared/list/list.types'; +import { ValueHelp } from '../../shared/value-help/value-help'; +import { lookupById } from './gateway-lookups'; + +type Value = string | readonly string[]; + +const asList = (v: Value): readonly string[] => (typeof v === 'string' ? (v === '' ? [] : [v]) : v); + +/** + * Un parámetro de un reporte, con el control que pide su tipo: texto, número, fecha, lista fija o ventana de + * selección. Un parámetro de rango pide desde y hasta, y uno que admite varios valores los junta (marcas en una + * ventana de selección, o valores separados por comas). El valor es un texto, o una lista en esos dos casos. + */ +@Component({ + selector: 'sbo-report-param-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DateField, DateInput, LookupField, NumberField, NumberInput, SboField, SelectField, TextField, TextInput, ValueHelp], + template: ` + @let p = param(); + @switch (mode()) { + @case ('single') { + @switch (p.kind) { + @case ('number') { + + } + @case ('date') { + + } + @case ('select') { + + } + @case ('lookup') { + @if (catalog(); as c) { + + } @else { + + } + } + @default { + + } + } + } + @case ('range') { + @if (p.kind === 'lookup' && catalog(); as c) { + + + } @else { + + @switch (p.kind) { + @case ('date') { + + + } + @case ('number') { + + + } + @default { + + + } + } + + } + } + @case ('multi') { + @if (p.kind === 'lookup' && catalog()) { + +
+ @for (v of list(); track v) { + {{ v }} + } @empty { + Ninguno: se usan todos + } +
+ +
+ @if (open()) { + + } + } @else if (p.kind === 'select') { + +
+ @for (o of p.options ?? []; track o.value) { + + } +
+
+ } @else { + + } + } + } + `, + styles: ` + :host { display: block; } + .chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; min-height: 24px; flex: 1; } + .chip { display: inline-flex; align-items: center; gap: 4px; padding: 0 4px 0 6px; background: var(--btn); border: 1px solid var(--btn-b); border-radius: var(--radius-pill); font-size: 12px; } + .chip button { padding: 0 3px; background: none; border: none; cursor: pointer; color: var(--quiet); } + .chip button:hover { color: var(--err); } + .none { color: var(--quiet); font-size: 12px; } + .checks { display: flex; flex-wrap: wrap; gap: 4px 14px; } + `, +}) +export class ReportParamField { + readonly param = input.required(); + readonly value = model(''); + + protected readonly open = signal(false); + protected readonly mode = computed<'single' | 'range' | 'multi'>(() => (this.param().range ? 'range' : this.param().multiple ? 'multi' : 'single')); + protected readonly catalog = computed(() => lookupById(this.param().lookup)); + protected readonly single = computed(() => asList(this.value())[0] ?? ''); + protected readonly list = computed(() => asList(this.value())); + /** Desde y hasta de un rango. */ + protected readonly pair = computed(() => [asList(this.value())[0] ?? '', asList(this.value())[1] ?? '']); + + protected num(v: string): number | null { + return v.trim() === '' || Number.isNaN(Number(v)) ? null : Number(v); + } + + protected setPair(i: 0 | 1, v: string): void { + const next: [string, string] = [...this.pair()]; + next[i] = v; + this.value.set(next); + } + + protected split(text: string): string[] { + return text.split(',').map((x) => x.trim()).filter((x) => x !== ''); + } + + protected remove(v: string): void { + this.value.set(this.list().filter((x) => x !== v)); + } + + protected toggle(v: string): void { + this.value.set(this.list().includes(v) ? this.list().filter((x) => x !== v) : [...this.list(), v]); + } + + /** Lo que se marcó en la ventana se suma a lo que ya había (sin repetir). */ + protected picked(rows: ListRow[]): void { + const key = this.catalog()?.lookup.valueKey ?? this.catalog()?.lookup.columns[0]?.key ?? ''; + const added = rows.map((r) => String(r[key] ?? '')).filter((x) => x !== ''); + this.value.set([...new Set([...this.list(), ...added])]); + this.open.set(false); + } +} diff --git a/apps/web/src/app/features/reports/report-runner.page.ts b/apps/web/src/app/features/reports/report-runner.page.ts new file mode 100644 index 0000000..09714be --- /dev/null +++ b/apps/web/src/app/features/reports/report-runner.page.ts @@ -0,0 +1,147 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, resource, signal } from '@angular/core'; +import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { type GatewayParam, type GatewayReport, type GatewayValues, exportBody } from '@basic-erp/sap-types'; +import { apiErrorMessage } from '../../core/api'; +import { SboWindow, type StatusMessage } from '../../shared/window'; +import { GatewayApi } from './gateway-api'; +import { ReportParamField } from './report-param-field'; + +type Value = string | readonly string[]; + +/** El mensaje de un error cuyo cuerpo llegó como archivo (la respuesta esperada era un PDF). */ +async function blobErrorMessage(err: unknown): Promise { + if (err instanceof HttpErrorResponse && err.error instanceof Blob) { + try { + const body = JSON.parse(await err.error.text()) as { message?: string }; + if (body.message) return body.message; + } catch { + /* se usa el mensaje general */ + } + } + return apiErrorMessage(err); +} + +/** + * Formulario de parámetros de un reporte matriculado y su PDF. Cada parámetro tiene el control que pidió su matrícula + * (texto, número, fecha, lista fija o ventana de selección); al generar, el PDF se ve en la misma pantalla. + */ +@Component({ + selector: 'sbo-report-runner-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReportParamField, RouterLink, SboWindow], + template: ` + + @if (report(); as r) { + @if (r.description) { +

{{ r.description }}

+ } + @if (visible().length) { +
+
+ @for (p of left(); track p.name) { + + } +
+
+ @for (p of right(); track p.name) { + + } +
+
+ } @else { +

Este reporte no pide parámetros.

+ } + @if (pdfUrl(); as url) { + + } + } + + @if (download(); as d) { + Descargar + } + Volver a la lista +
+ `, + styles: ` + :host { display: block; } + .desc { margin: 0 0 8px; color: var(--quiet); } + .pdf { display: block; width: 100%; height: 70vh; margin-top: 12px; border: 1px solid var(--line); background: var(--field); } + `, +}) +export class ReportRunnerPage { + private readonly api = inject(GatewayApi); + private readonly route = inject(ActivatedRoute); + private readonly sanitizer = inject(DomSanitizer); + + protected readonly title = this.route.snapshot.data['title'] as string; + protected readonly code = this.route.snapshot.paramMap.get('code') ?? ''; + + private readonly reports = resource({ loader: () => this.api.reports() }); + protected readonly report = computed(() => (this.reports.hasValue() ? this.reports.value().find((r) => r.code === this.code) : undefined)); + /** Los parámetros que se piden (los títulos y separadores del reporte no). */ + protected readonly visible = computed(() => (this.report()?.params ?? []).filter((p) => !p.hidden)); + protected readonly left = computed(() => this.visible().filter((_, i) => i % 2 === 0)); + protected readonly right = computed(() => this.visible().filter((_, i) => i % 2 === 1)); + + private readonly values = signal>({}); + protected readonly running = signal(false); + protected readonly pdfUrl = signal(null); + protected readonly download = signal<{ url: string; name: string } | null>(null); + private readonly message = signal(null); + + constructor() { + inject(DestroyRef).onDestroy(() => this.revoke()); + } + + protected readonly status = computed(() => { + if (this.reports.isLoading()) return { kind: 'info', text: 'Cargando…' }; + if (this.reports.error()) return { kind: 'err', text: apiErrorMessage(this.reports.error()) }; + const message = this.message(); + if (message) return message; + if (!this.report()) return { kind: 'err', text: `El reporte ${this.code} no está matriculado o no está habilitado.` }; + return { kind: 'info', text: this.visible().some((p) => p.required) ? 'Indique los parámetros obligatorios (*) y pulse «Generar PDF».' : 'Pulse «Generar PDF».' }; + }); + + /** El valor de un parámetro: lo que escribió el usuario o su valor por defecto. */ + protected valueOf(p: GatewayParam): Value { + return this.values()[p.name] ?? p.defaultValue ?? ''; + } + + protected set(p: GatewayParam, v: Value): void { + this.values.update((m) => ({ ...m, [p.name]: v })); + this.message.set(null); + } + + private revoke(): void { + const d = this.download(); + if (d) URL.revokeObjectURL(d.url); + } + + protected async run(): Promise { + const r = this.report(); + if (!r || this.running()) return; + const values: Record = {}; + for (const p of r.params) values[p.name] = this.valueOf(p); + const { missing } = exportBody(r.params, values as GatewayValues); + if (missing.length) { + this.message.set({ kind: 'err', text: `Falta indicar: ${missing.join(', ')}.` }); + return; + } + this.running.set(true); + this.message.set({ kind: 'info', text: 'Generando el reporte…' }); + try { + const blob = await this.api.export(r.code, values as GatewayValues); + this.revoke(); + const url = URL.createObjectURL(blob); + this.download.set({ url, name: `${r.code}.pdf` }); + this.pdfUrl.set(this.sanitizer.bypassSecurityTrustResourceUrl(url)); + this.message.set({ kind: 'ok', text: 'Reporte generado.' }); + } catch (e) { + this.message.set({ kind: 'err', text: await blobErrorMessage(e) }); + } finally { + this.running.set(false); + } + } +} diff --git a/apps/web/src/app/features/reports/reports.page.ts b/apps/web/src/app/features/reports/reports.page.ts new file mode 100644 index 0000000..8a7dcd2 --- /dev/null +++ b/apps/web/src/app/features/reports/reports.page.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import type { GatewayReport } from '@basic-erp/sap-types'; +import { apiErrorMessage } from '../../core/api'; +import { SboWindow, type StatusMessage } from '../../shared/window'; +import { GatewayApi } from './gateway-api'; + +/** Lista de los reportes matriculados, agrupados por categoría y con búsqueda; elegir uno abre su formulario de parámetros. */ +@Component({ + selector: 'sbo-reports-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SboWindow], + template: ` + +
+ +
+ @for (g of groups(); track g.category) { +

{{ g.category }} {{ g.reports.length }}

+
    + @for (r of g.reports; track r.code) { +
  • + +
  • + } +
+ } @empty { +

{{ reports.isLoading() ? 'Cargando reportes…' : query() ? 'Ningún reporte coincide con la búsqueda.' : 'No hay reportes matriculados. Un administrador los matricula en «Matrícula de reportes».' }}

+ } +
+ `, + styles: ` + :host { display: block; } + .bar { margin-bottom: 8px; } + .search { width: min(360px, 100%); height: 24px; padding: 1px 6px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); } + h2 { margin: 12px 0 4px; font-size: 13px; font-weight: 600; } + h2 small { color: var(--quiet); font-weight: 400; } + .list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); } + .list li + li { border-top: 1px solid var(--line-soft); } + .list button { display: flex; gap: 10px; align-items: baseline; width: 100%; padding: 4px 8px; background: none; border: none; text-align: left; cursor: pointer; } + .list button:hover { background: var(--btn-h); } + .code { font-family: var(--mono, monospace); color: var(--quiet); font-size: 12px; } + .desc { color: var(--quiet); font-size: 12px; } + .none { color: var(--quiet); } + `, +}) +export class ReportsPage { + private readonly api = inject(GatewayApi); + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + + protected readonly title = this.route.snapshot.data['title'] as string; + protected readonly reports = resource({ loader: () => this.api.reports() }); + protected readonly query = signal(''); + + protected readonly groups = computed(() => { + const needle = this.query().trim().toLowerCase(); + const all = (this.reports.hasValue() ? this.reports.value() : []).filter((r) => !needle || r.title.toLowerCase().includes(needle) || r.code.toLowerCase().includes(needle)); + const by = new Map(); + for (const r of all) by.set(r.category, [...(by.get(r.category) ?? []), r]); + return [...by.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([category, reports]) => ({ category, reports })); + }); + + protected readonly status = computed(() => { + if (this.reports.isLoading()) return { kind: 'info', text: 'Cargando…' }; + if (this.reports.error()) return { kind: 'err', text: apiErrorMessage(this.reports.error()) }; + const n = this.reports.hasValue() ? this.reports.value().length : 0; + return { kind: 'info', text: n ? 'Elija un reporte para indicar sus parámetros.' : '' }; + }); + + protected open(r: GatewayReport): void { + void this.router.navigate([r.code], { relativeTo: this.route }); + } +} diff --git a/apps/web/src/app/features/reports/reports.spec.ts b/apps/web/src/app/features/reports/reports.spec.ts new file mode 100644 index 0000000..70e04c6 --- /dev/null +++ b/apps/web/src/app/features/reports/reports.spec.ts @@ -0,0 +1,203 @@ +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; +import type { GatewayParam, GatewayReport } from '@basic-erp/sap-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { GatewayApi } from './gateway-api'; +import { RegistryPage, toParam } from './registry.page'; +import { ReportRunnerPage } from './report-runner.page'; +import { ReportsPage } from './reports.page'; + +const params: GatewayParam[] = [ + { name: 'Dockey@', type: 'xsd:decimal', label: 'Documento', kind: 'lookup', lookup: 'doc:DeliveryNotes', required: true }, + { name: 'CANTIDAD', type: 'xsd:decimal', label: 'Cantidad', kind: 'number', required: false, defaultValue: '1' }, + { name: 'Title@Title', type: 'xsd:string', label: 'Título', kind: 'text', required: false, hidden: true }, +]; +const REPORTS: GatewayReport[] = [ + { code: 'DLN20015', title: 'Impresión de etiquetas', category: 'Inventario', description: 'Etiquetas de una entrega', enabled: true, params }, + { code: 'RCRI0031', title: 'Balance de comprobación', category: 'Finanzas', enabled: true, params: [] }, +]; + +const tick = async (fixture: ComponentFixture) => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); +}; + +function setup(api: Partial>, route: Record = {}) { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + { provide: GatewayApi, useValue: api }, + { provide: ActivatedRoute, useValue: { snapshot: { data: { title: 'Reportes' }, paramMap: convertToParamMap(route) } } }, + ], + }); + return vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); +} + +describe('ReportsPage', () => { + it('agrupa los reportes por categoría, los busca y abre el elegido', async () => { + const navigate = setup({ reports: async () => REPORTS }); + const fixture = TestBed.createComponent(ReportsPage); + await tick(fixture); + const el = fixture.nativeElement as HTMLElement; + expect([...el.querySelectorAll('h2')].map((h) => h.textContent?.replace(/\d+/, '').trim())).toEqual(['Finanzas', 'Inventario']); + expect(el.textContent).toContain('Etiquetas de una entrega'); + // búsqueda + const search = el.querySelector('input[type=search]')!; + search.value = 'etiqueta'; + search.dispatchEvent(new Event('input')); + await tick(fixture); + expect([...el.querySelectorAll('.list button .name')].map((n) => n.textContent)).toEqual(['Impresión de etiquetas']); + (el.querySelector('.list button') as HTMLElement).click(); + expect(navigate).toHaveBeenCalledWith(['DLN20015'], expect.anything()); + }); + + it('sin reportes dice dónde se matriculan', async () => { + setup({ reports: async () => [] }); + const fixture = TestBed.createComponent(ReportsPage); + await tick(fixture); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Matrícula de reportes'); + }); +}); + +describe('ReportRunnerPage', () => { + let fixture: ComponentFixture; + let exportFn: ReturnType; + const el = () => fixture.nativeElement as HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const page = () => fixture.componentInstance as any; + const status = () => el().querySelector('.status')!.textContent; + const generate = () => (el().querySelector('.actions .btn.main') as HTMLButtonElement).click(); + + beforeEach(async () => { + exportFn = vi.fn(async () => new Blob(['%PDF-1.4'], { type: 'application/pdf' })); + setup({ reports: async () => REPORTS, export: exportFn }, { code: 'DLN20015' }); + URL.createObjectURL = vi.fn(() => 'blob:pdf'); + URL.revokeObjectURL = vi.fn(); + fixture = TestBed.createComponent(ReportRunnerPage); + await tick(fixture); + }); + + it('pide los parámetros del reporte, sin los títulos, con su valor por defecto', () => { + expect(el().textContent).toContain('Impresión de etiquetas'); + const labels = [...el().querySelectorAll('label')].map((l) => l.textContent?.trim()); + expect(labels).toEqual(expect.arrayContaining(['Documento', 'Cantidad'])); + expect(labels).not.toContain('Título'); + expect(page().valueOf(params[1])).toBe('1'); + }); + + it('un obligatorio sin valor no llega a la API', async () => { + generate(); + await tick(fixture); + expect(exportFn).not.toHaveBeenCalled(); + expect(status()).toContain('Falta indicar: Documento'); + }); + + it('genera el PDF con los valores escritos y lo muestra con su enlace de descarga', async () => { + page().set(params[0], '137138'); + await tick(fixture); + generate(); + await tick(fixture); + expect(exportFn).toHaveBeenCalledWith('DLN20015', { 'Dockey@': '137138', CANTIDAD: '1', 'Title@Title': '' }); + expect(status()).toContain('Reporte generado'); + expect(el().querySelector('iframe')).not.toBeNull(); + expect(el().querySelector('a[download]')!.getAttribute('download')).toBe('DLN20015.pdf'); + }); + + it('muestra el mensaje de error de la API', async () => { + exportFn.mockRejectedValueOnce(new HttpErrorResponse({ status: 400, error: new Blob([JSON.stringify({ message: 'El reporte no devolvió un PDF: (---)' })]) })); + page().set(params[0], '1'); + generate(); + await tick(fixture); + expect(status()).toContain('(---)'); + }); +}); + +describe('ReportRunnerPage: reporte que no está matriculado', () => { + it('lo avisa', async () => { + setup({ reports: async () => REPORTS }, { code: 'NOEXISTE' }); + const fixture = TestBed.createComponent(ReportRunnerPage); + await tick(fixture); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('NOEXISTE no está matriculado'); + }); +}); + +describe('RegistryPage', () => { + let save: ReturnType; + let fixture: ComponentFixture; + const el = () => fixture.nativeElement as HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const page = () => fixture.componentInstance as any; + + beforeEach(async () => { + save = vi.fn(async (r: GatewayReport) => r); + setup({ + registry: async () => REPORTS, + authorized: async () => [{ code: 'DLN20015', name: 'Etiquetas', root_name: '#1#4', root_guid: '44' }, { code: 'RCRI0099', name: 'Nuevo', root_name: '#1#4', root_guid: '44' }], + parameters: async () => [{ name: 'NIVEL', type: 'xsd:decimal', label: 'Nivel', kind: 'number', required: true }], + save, + remove: vi.fn(async () => undefined), + }); + fixture = TestBed.createComponent(RegistryPage); + await tick(fixture); + }); + + it('lista los reportes matriculados con su categoría y la cantidad de parámetros', () => { + const rows = [...el().querySelectorAll('tbody tr')].map((r) => r.textContent?.replace(/\s+/g, ' ').trim()); + expect(rows[0]).toContain('DLN20015'); + expect(rows[0]).toContain('Inventario'); + expect(el().textContent).toContain('Balance de comprobación'); + }); + + it('al matricular ofrece solo los autorizados que aún no están, y trae sus parámetros para ajustarlos', async () => { + await page().startPick(); + expect(page().authorizedRows().map((r: { code: string }) => r.code)).toEqual(['RCRI0099']); + await page().picked([{ code: 'RCRI0099', name: 'Nuevo reporte' }]); + await tick(fixture); + expect(page().draft()).toMatchObject({ code: 'RCRI0099', isNew: true, title: 'Nuevo reporte', category: 'Otros', enabled: true }); + expect(page().draft().params[0]).toMatchObject({ name: 'NIVEL', kind: 'number', required: 'Sí' }); + expect(el().textContent).toContain('Parámetros'); + }); + + it('guarda lo ajustado: etiquetas, tipo de control, ventana de selección y valor por defecto', async () => { + page().edit(REPORTS[0]); + page().patch({ title: 'Etiquetas de entrega', category: 'Inventario' }); + page().patch({ params: page().draft().params.map((r: Record) => (r['name'] === 'CANTIDAD' ? { ...r, label: 'Copias', defaultValue: '2' } : r)) }); + await page().save(); + const sent = save.mock.calls[0][0] as GatewayReport; + expect(sent).toMatchObject({ code: 'DLN20015', title: 'Etiquetas de entrega', category: 'Inventario', enabled: true }); + expect(sent.params.find((p) => p.name === 'CANTIDAD')).toMatchObject({ label: 'Copias', defaultValue: '2', kind: 'number' }); + expect(sent.params.find((p) => p.name === 'Dockey@')).toMatchObject({ kind: 'lookup', lookup: 'doc:DeliveryNotes', required: true }); + expect(sent.params.find((p) => p.name === 'Title@Title')).toMatchObject({ hidden: true }); + }); + + it('exige la ventana de selección de un parámetro de ese tipo', async () => { + page().edit(REPORTS[0]); + page().patch({ params: page().draft().params.map((r: Record) => (r['name'] === 'Dockey@' ? { ...r, lookup: '' } : r)) }); + await page().save(); + await tick(fixture); + expect(save).not.toHaveBeenCalled(); + expect(el().querySelector('.status')!.textContent).toContain('Elija la ventana de selección de: Documento'); + }); +}); + +describe('toParam', () => { + it('convierte la fila de la tabla en una definición sin los campos que no aplican', () => { + expect(toParam({ name: 'P', type: 'xsd:string', label: ' Estado ', kind: 'select', required: 'No', range: 'No', multiple: 'Sí', hidden: 'No', lookup: 'almacenes', options: 'A=Abierto; C=Cerrado; X', defaultValue: '' })).toEqual({ + name: 'P', + type: 'xsd:string', + label: 'Estado', + kind: 'select', + required: false, + multiple: true, + options: [{ value: 'A', label: 'Abierto' }, { value: 'C', label: 'Cerrado' }, { value: 'X', label: 'X' }], + }); + expect(toParam({ name: 'Q', kind: 'lookup', lookup: 'articulos', required: 'Sí', options: 'ignorada' })).toMatchObject({ lookup: 'articulos', required: true }); + expect(toParam({ name: 'Q', kind: 'lookup', lookup: 'articulos' })).not.toHaveProperty('options'); + }); +}); diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index 8c5e8c8..71c275d 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -72,6 +72,15 @@ export const MENU: readonly MenuModule[] = [ { path: '/finanzas/asientos', label: 'Asientos contables' }, ], }, + { + id: 'reportes', + label: 'Reportes Gateway', + items: [ + { path: '/reportes/ejecutar', label: 'Reportes' }, + // matricular reportes es de administradores (la API lo exige) + { path: '/reportes/matricula', label: 'Matrícula de reportes' }, + ], + }, { id: 'socios', label: 'Socios de negocio',