feat: botón PDF de los documentos con los reportes del Gateway (formato por defecto y otros formatos, asociados en la matrícula)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-19 18:42:07 -05:00
1 parent 19c4a9013c
commit 0f43e6cac7
14 files changed
+439 -28

No files matched your search

+1 -1
View File
@@ -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 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).
- **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. **Botón PDF de los documentos** (`reports/document-pdf.ts`, en la barra del formulario): cada reporte matriculado puede asociarse a pantallas de documento (`documents`, rutas de menú) con el parámetro que recibe el `DocEntry` (`documentParam`; los demás parámetros toman su valor por defecto) y marcarse «por defecto» (`defaultFor`, uno por pantalla, como en SAP: marcarlo quita la marca al anterior). «PDF» imprime el formato por defecto (o el primero) en una ventana con descarga; si hay más, el menú «Otros formatos» los lista. Sin formatos, sin permiso o sin API Gateway el botón no aparece. API: `GET /api/gateway/documents?screen=` y `POST /api/gateway/documents/export {screen, code, docEntry}`; basta el permiso de menú del documento (no el de «Reportes»). Aún no hay botón en pagos, asientos ni maestros.
## Flujo de las pantallas de documento
+2
View File
@@ -103,6 +103,8 @@ Llama a `rs/v1/LoadAuthorizedCRList` (los reportes autorizados), `rs/v1/LoadCR`
| `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 |
| `GET /api/gateway/documents?screen=/ventas/entrega` | Permiso de menú de esa pantalla | Formatos PDF asociados a un documento (el por defecto primero) |
| `POST /api/gateway/documents/export` `{screen, code, docEntry}` | Permiso de menú de esa pantalla | PDF del documento: el `DocEntry` va en el parámetro asociado en la matrícula |
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).
@@ -23,6 +23,18 @@ export class GatewayReportEntity {
@Column({ type: 'text' })
params: string;
/** JSON con las rutas de las pantallas de documento donde se ofrece (botón PDF). */
@Column({ type: 'text', nullable: true })
documents: string | null;
/** JSON con las pantallas donde es el formato por defecto. */
@Column({ type: 'text', nullable: true })
defaultFor: string | null;
/** Parámetro que recibe el `DocEntry` del documento. */
@Column({ type: 'varchar', nullable: true })
documentParam: string | null;
@Column({ type: 'varchar', nullable: true })
updatedBy: string | null;
+20 -1
View File
@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Req, StreamableFile, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query, Req, StreamableFile, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import type { AuthenticatedRequest } from '../auth/auth.types.js';
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
@@ -7,6 +7,7 @@ import { GatewayService } from './gateway.service.js';
const codeSchema = z.string().regex(/^[A-Za-z0-9_.-]{1,60}$/, 'Código de reporte inválido');
const text = (max: number) => z.string().trim().max(max);
const screenSchema = z.string().regex(/^\/[a-z0-9\-/]{1,80}$/, 'Pantalla inválida');
const paramSchema = z.object({
name: text(200).min(1),
@@ -29,12 +30,18 @@ const reportSchema = z.object({
description: text(500).optional(),
enabled: z.boolean(),
params: z.array(paramSchema).max(60),
documents: z.array(screenSchema).max(60).optional(),
documentParam: text(200).optional(),
defaultFor: z.array(screenSchema).max(60).optional(),
});
type ReportDto = z.infer<typeof reportSchema>;
const exportSchema = z.object({ values: z.record(z.string(), z.union([z.string().max(500), z.array(z.string().max(500)).max(200)])) });
type ExportDto = z.infer<typeof exportSchema>;
const documentExportSchema = z.object({ screen: screenSchema, code: codeSchema, docEntry: z.number().int().positive() });
type DocumentExportDto = z.infer<typeof documentExportSchema>;
/** Reportes del API Gateway: matrícula (administradores) y ejecución (permiso de menú «Reportes»). */
@Controller('gateway')
export class GatewayController {
@@ -84,6 +91,18 @@ export class GatewayController {
return this.gateway.enabled(req.user);
}
@Get('documents')
forDocument(@Req() req: AuthenticatedRequest, @Query('screen', new ZodValidationPipe(screenSchema)) screen: string) {
return this.gateway.forDocument(req.user, screen);
}
@Post('documents/export')
@HttpCode(200)
async exportDocument(@Req() req: AuthenticatedRequest, @Body(new ZodValidationPipe(documentExportSchema)) body: DocumentExportDto) {
const pdf = await this.gateway.exportDocument(req.user, body.screen, body.code, body.docEntry, req.ip ?? '');
return new StreamableFile(pdf, { type: 'application/pdf', disposition: `inline; filename="${body.code}-${body.docEntry}.pdf"` });
}
@Post('reports/:code/export')
@HttpCode(200)
async export(
@@ -51,6 +51,7 @@ describe('GatewayService', () => {
findOneBy: async ({ code }: { code: string }) => rows.get(code) ?? null,
save: async (e: GatewayReportEntity) => void rows.set(e.code, e),
remove: async (e: GatewayReportEntity) => void rows.delete(e.code),
update: async (code: string, patch: Partial<GatewayReportEntity>) => void Object.assign(rows.get(code)!, patch),
};
service = new GatewayService(
{ call } as unknown as GatewayClientService,
@@ -123,4 +124,51 @@ describe('GatewayService', () => {
await expect(service.export(user, 'DLN20015', { 'Dockey@': '1' }, '10.0.0.5')).rejects.toThrow(/no devolvió un PDF: no es pdf/);
expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'report_export', outcome: 'error' }));
});
describe('botón PDF de los documentos', () => {
const doc = (code: string, extra: object = {}) =>
service.save(code, { title: `Formato ${code}`, category: 'ventas', enabled: true, params, documents: ['/ventas/entrega'], documentParam: 'Dockey@', ...extra }, user, '10.0.0.5');
beforeEach(() => {
call.mockImplementation(async (_m: string, path: string) => {
if (path.endsWith('LoadAuthorizedCRList')) return { status: 200, text: JSON.stringify({ resultSet: ['DLN20015', 'DLN20016'].map((code) => ({ code, name: code, root_name: '', root_guid: '' })) }) };
return { status: 200, text: PDF.toString('base64') };
});
});
it('exige un parámetro del documento que exista y una pantalla del menú', async () => {
await expect(doc('DLN20015', { documentParam: 'NoExiste' })).rejects.toThrow(BadRequestException);
await expect(doc('DLN20015', { documents: ['/no/existe'] })).rejects.toThrow(BadRequestException);
});
it('un solo formato por defecto por documento: el que se marca quita la marca al anterior', async () => {
await doc('DLN20015', { defaultFor: ['/ventas/entrega'] });
await doc('DLN20016', { defaultFor: ['/ventas/entrega'] });
expect(await service.forDocument(user, '/ventas/entrega')).toEqual([
{ code: 'DLN20016', title: 'Formato DLN20016', isDefault: true },
{ code: 'DLN20015', title: 'Formato DLN20015', isDefault: false },
]);
});
it('lista solo los formatos de esa pantalla y de quien puede abrir el documento', async () => {
await doc('DLN20015');
expect(await service.forDocument(user, '/ventas/ordenes')).toEqual([]);
paths = ['/compras/*'];
await expect(service.forDocument(user, '/ventas/entrega')).rejects.toThrow(ForbiddenException);
});
it('imprime con el DocEntry en el parámetro del documento, sin necesitar el permiso de reportes', async () => {
await doc('DLN20015');
paths = ['/ventas/entrega'];
call.mockClear();
const pdf = await service.exportDocument(user, '/ventas/entrega', 'DLN20015', 580, '10.0.0.5');
expect(pdf.equals(PDF)).toBe(true);
expect(call).toHaveBeenCalledWith('POST', 'rs/v1/ExportPDFData', { query: { DocCode: 'DLN20015' }, body: [{ name: 'Dockey@', type: 'xsd:decimal', value: [['580']] }] });
});
it('no imprime un formato que no está asociado a esa pantalla', async () => {
await doc('DLN20015');
await expect(service.exportDocument(user, '/ventas/ordenes', 'DLN20015', 1, '10.0.0.5')).rejects.toThrow(NotFoundException);
});
});
});
+59 -3
View File
@@ -1,6 +1,6 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type AuthorizedReport, type GatewayParam, type GatewayReport, type GatewayValues, exportBody, isPathAllowed, paramsFromLoadCr } from '@basic-erp/sap-types';
import { MENU, type AuthorizedReport, type DocumentReport, type GatewayParam, type GatewayReport, type GatewayValues, exportBody, isPathAllowed, paramsFromLoadCr } from '@basic-erp/sap-types';
import { Repository } from 'typeorm';
import { AuditService } from '../audit/audit.service.js';
import type { AuthUser } from '../auth/auth.types.js';
@@ -18,8 +18,13 @@ const toReport = (e: GatewayReportEntity): GatewayReport => ({
...(e.description ? { description: e.description } : {}),
enabled: e.enabled,
params: JSON.parse(e.params) as GatewayParam[],
...(e.documents ? { documents: JSON.parse(e.documents) as string[] } : {}),
...(e.documentParam ? { documentParam: e.documentParam } : {}),
...(e.defaultFor ? { defaultFor: JSON.parse(e.defaultFor) as string[] } : {}),
});
const MENU_PATHS = new Set(MENU.flatMap((m) => m.items.map((i) => i.path)));
/** El API Gateway devuelve el PDF en base64, a veces entre comillas (un texto JSON). */
export function decodePdf(text: string): Buffer {
let body = text.trim();
@@ -89,6 +94,21 @@ export class GatewayService {
const exists = await this.repo.findOneBy({ code });
// un reporte nuevo debe ser uno de los que el API Gateway autoriza
if (!exists && !(await this.authorized()).some((r) => r.code === code)) throw new BadRequestException(`El reporte ${code} no está autorizado en el API Gateway`);
const documents = [...new Set(report.documents ?? [])];
const unknown = documents.filter((d) => !MENU_PATHS.has(d));
if (unknown.length) throw new BadRequestException(`Pantalla de documento desconocida: ${unknown.join(', ')}`);
if (documents.length && !report.params.some((x) => x.name === report.documentParam && !x.hidden)) {
throw new BadRequestException('Indique el parámetro que recibe el número del documento');
}
const defaultFor = [...new Set(report.defaultFor ?? [])].filter((d) => documents.includes(d));
// como en SAP, cada documento tiene un solo formato por defecto: el que se marca quita la marca a los demás
if (defaultFor.length) {
for (const other of await this.repo.find()) {
if (other.code === code || !other.defaultFor) continue;
const kept = (JSON.parse(other.defaultFor) as string[]).filter((s) => !defaultFor.includes(s));
await this.repo.update(other.code, { defaultFor: kept.length ? JSON.stringify(kept) : null });
}
}
await this.repo.save({
code,
title: report.title,
@@ -96,11 +116,14 @@ export class GatewayService {
description: report.description ?? null,
enabled: report.enabled,
params: JSON.stringify(report.params),
documents: documents.length ? JSON.stringify(documents) : null,
documentParam: documents.length ? report.documentParam! : null,
defaultFor: defaultFor.length ? JSON.stringify(defaultFor) : null,
updatedBy: user.webUser,
updatedAt: new Date().toISOString(),
});
await this.audit.record({ action: 'report_register', webUser: user.webUser, ip, resource: code, detail: { params: report.params.map((p) => p.name), enabled: report.enabled } });
return { code, ...report };
await this.audit.record({ action: 'report_register', webUser: user.webUser, ip, resource: code, detail: { params: report.params.map((p) => p.name), enabled: report.enabled, documents, defaultFor } });
return { code, ...report, ...(documents.length ? { documents } : {}), ...(defaultFor.length ? { defaultFor } : {}) };
}
async remove(code: string, user: AuthUser, ip: string): Promise<void> {
@@ -119,7 +142,40 @@ export class GatewayService {
/** Genera el PDF de un reporte matriculado con los valores que escribió el usuario. */
async export(user: AuthUser, code: string, values: GatewayValues, ip: string): Promise<Buffer> {
await this.assertCanExecute(user);
return this.run(user, code, values, ip);
}
// --- botón PDF de los documentos ---------------------------------------------------------------
private async assertCanOpen(user: AuthUser, screen: string): Promise<void> {
if (!isPathAllowed(await this.permissions.getPaths(user.webUser), screen)) throw new ForbiddenException('No tiene permisos sobre este documento');
}
/** Los reportes habilitados que se ofrecen en el botón PDF de una pantalla de documento. */
async forDocument(user: AuthUser, screen: string): Promise<DocumentReport[]> {
await this.assertCanOpen(user, screen);
const all = await this.repo.find({ where: { enabled: true }, order: { title: 'ASC' } });
const list = all
.filter((e) => e.documents && (JSON.parse(e.documents) as string[]).includes(screen))
.map((e) => ({ code: e.code, title: e.title, isDefault: !!e.defaultFor && (JSON.parse(e.defaultFor) as string[]).includes(screen) }));
// el formato por defecto va primero
return list.sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
}
/** PDF de un documento: el `DocEntry` va en el parámetro asociado y el resto toma su valor por defecto. Quien puede abrir el documento puede imprimirlo. */
async exportDocument(user: AuthUser, screen: string, code: string, docEntry: number, ip: string): Promise<Buffer> {
await this.assertCanOpen(user, screen);
const found = await this.repo.findOneBy({ code });
const report = found && found.enabled ? toReport(found) : null;
if (!report?.documentParam || !report.documents?.includes(screen)) throw new NotFoundException(`El reporte ${code} no está disponible para este documento`);
const values: Record<string, string> = {};
for (const p of report.params) if (p.defaultValue) values[p.name] = p.defaultValue;
values[report.documentParam] = String(docEntry);
return this.run(user, code, values, ip, found);
}
private async run(user: AuthUser, code: string, values: GatewayValues, ip: string, entity?: GatewayReportEntity | null): Promise<Buffer> {
const found = entity ?? (await this.repo.findOneBy({ code }));
if (!found || !found.enabled) throw new NotFoundException(`El reporte ${code} no está disponible`);
const { body, missing } = exportBody(toReport(found).params, values);
if (missing.length) throw new BadRequestException(`Falta indicar: ${missing.join(', ')}`);
@@ -6,6 +6,7 @@
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
<button class="tb" type="button" aria-label="Siguiente" (click)="go('next')"><sbo-icon name="next" /></button>
<button class="tb" type="button" aria-label="Último" (click)="go('last')"><sbo-icon name="last" /></button>
<sbo-document-pdf [screen]="screen.path" [docEntry]="docEntry()" />
</sbo-toolbar>
<sbo-window [title]="windowTitle()" [badge]="badge()" [status]="status()">
@@ -37,6 +37,7 @@ import {
import { SERVICE_DOC_TYPE } from './document-layouts';
import { type ComputedSource, type GroupCell, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKUP, hasCurrency, hasExpenses, hasFolio, hasPartnerData, hasSeries, headerKeysOf, keysOfFields, requiredHeaderOf } from './document-layouts';
import { type AddressSide, type AddressValues, addressFromExtension, addressFromPartner, composeAddress, toExtension } from './address-fields';
import { DocumentPdf } from '../reports/document-pdf';
import { AddressDialog } from './address-dialog';
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
@@ -75,7 +76,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AddressDialog, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -0,0 +1,88 @@
import { HttpErrorResponse, provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import type { DocumentReport } from '@basic-erp/sap-types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DocumentPdf } from './document-pdf';
import { GatewayApi } from './gateway-api';
const tick = async (fixture: ComponentFixture<unknown>) => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
function setup(formats: DocumentReport[] | Error, docEntry: number | null = 580) {
const api = {
forDocument: vi.fn(async () => {
if (formats instanceof Error) throw formats;
return formats;
}),
exportDocument: vi.fn(async () => new Blob(['%PDF'], { type: 'application/pdf' })),
};
TestBed.resetTestingModule();
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting(), { provide: GatewayApi, useValue: api }] });
const fixture = TestBed.createComponent(DocumentPdf);
fixture.componentRef.setInput('screen', '/ventas/entrega');
fixture.componentRef.setInput('docEntry', docEntry);
return { fixture, api, el: fixture.nativeElement as HTMLElement };
}
const A: DocumentReport = { code: 'DLN20015', title: 'Entrega estándar', isDefault: true };
const B: DocumentReport = { code: 'DLN20016', title: 'Entrega con precios', isDefault: false };
describe('DocumentPdf', () => {
beforeEach(() => {
URL.createObjectURL = vi.fn(() => 'blob:x');
URL.revokeObjectURL = vi.fn();
});
it('sin formatos asociados, sin permiso o sin API Gateway no muestra nada', async () => {
for (const f of [[], new Error('403')] as const) {
const { fixture, el } = setup(f as DocumentReport[] | Error);
await tick(fixture);
expect(el.querySelector('button')).toBeNull();
}
});
it('en un documento nuevo no hay botón', async () => {
const { fixture, el } = setup([A], null);
await tick(fixture);
expect(el.querySelector('button')).toBeNull();
});
it('el botón PDF imprime el formato por defecto del documento abierto', async () => {
const { fixture, api, el } = setup([A, B]);
await tick(fixture);
expect(el.textContent).toContain('Otros formatos');
(el.querySelector('button.tb') as HTMLButtonElement).click();
await tick(fixture);
expect(api.exportDocument).toHaveBeenCalledWith('/ventas/entrega', 'DLN20015', 580);
expect(el.querySelector('iframe')).not.toBeNull();
expect(el.textContent).toContain('Entrega estándar');
});
it('con un solo formato no ofrece «Otros formatos»; los demás salen en el menú', async () => {
const one = setup([A]);
await tick(one.fixture);
expect(one.el.textContent).not.toContain('Otros formatos');
const two = setup([A, B]);
await tick(two.fixture);
(two.el.querySelector('sbo-menu-button button') as HTMLButtonElement).click();
two.fixture.detectChanges();
const items = [...two.el.querySelectorAll('[role=menuitem]')].map((b) => b.textContent?.trim());
expect(items).toEqual(['Entrega con precios']);
(two.el.querySelector('[role=menuitem]') as HTMLButtonElement).click();
await tick(two.fixture);
expect(two.api.exportDocument).toHaveBeenCalledWith('/ventas/entrega', 'DLN20016', 580);
});
it('muestra el error del gateway si no devuelve un PDF', async () => {
const { fixture, api, el } = setup([A]);
api.exportDocument.mockRejectedValueOnce(new HttpErrorResponse({ status: 400, error: new Blob([JSON.stringify({ message: 'Falta indicar: Documento' })]) }));
await tick(fixture);
(el.querySelector('button.tb') as HTMLButtonElement).click();
await tick(fixture);
expect(el.querySelector('[role=alert]')?.textContent).toContain('Falta indicar');
});
});
@@ -0,0 +1,117 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, input, resource, signal } from '@angular/core';
import { DomSanitizer, type SafeResourceUrl } from '@angular/platform-browser';
import type { DocumentReport } from '@basic-erp/sap-types';
import { Icon } from '../../shared/window';
import { MenuButton, type MenuItem } from '../../shared/window/menu-button';
import { GatewayApi, blobErrorMessage } from './gateway-api';
/**
* Botón PDF de un documento: imprime con el formato por defecto de su pantalla y, si hay otros formatos asociados
* (matrícula de reportes), ofrece un menú «Otros formatos». El PDF se ve en una ventana. Sin formatos asociados
* (o sin permiso, o sin API Gateway) no se muestra nada.
*/
@Component({
selector: 'sbo-document-pdf',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [Icon, MenuButton],
template: `
@if (docEntry() !== null && formats().length) {
<span class="tb-sep"></span>
<button class="tb" type="button" [disabled]="busy()" [attr.title]="'Imprimir: ' + formats()[0].title" (click)="open(formats()[0])"><sbo-icon name="print" />PDF</button>
@if (formats().length > 1) {
<sbo-menu-button label="Otros formatos" [items]="others()" (picked)="pick($event)" />
}
}
@if (view(); as v) {
<div class="overlay">
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="v.title" (keydown.escape)="close()">
<header>
<h2>{{ v.title }}</h2>
<span class="sp"></span>
@if (v.url) {
<a class="btn" [href]="v.raw" [download]="v.name">Descargar</a>
}
<button class="btn" type="button" (click)="close()">Cerrar</button>
</header>
@if (v.error) {
<p class="err" role="alert">{{ v.error }}</p>
} @else if (v.url) {
<iframe title="Vista del PDF" [src]="v.url"></iframe>
} @else {
<p class="wait">Generando el PDF…</p>
}
</div>
</div>
}
`,
styles: `
:host { display: contents; }
.overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--overlay); }
.dlg { display: flex; flex-direction: column; width: min(1000px, 100%); height: 90vh; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; box-shadow: var(--shadow-pop); }
header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
h2 { margin: 0; font-size: 14px; font-weight: 600; }
.sp { flex: 1; }
iframe { flex: 1; width: 100%; border: 0; }
.wait, .err { margin: 16px; }
.err { color: var(--err, #b00020); }
`,
})
export class DocumentPdf {
private readonly api = inject(GatewayApi);
private readonly sanitizer = inject(DomSanitizer);
/** Ruta de menú de la pantalla del documento (`/ventas/entregas`). */
readonly screen = input.required<string>();
/** `DocEntry` del documento abierto; `null` si es nuevo. */
readonly docEntry = input.required<number | null>();
protected readonly busy = signal(false);
protected readonly view = signal<{ title: string; name: string; url?: SafeResourceUrl; raw?: string; error?: string } | null>(null);
private readonly list = resource({
params: () => this.screen(),
// sin formatos, sin permiso o sin API Gateway: el botón no aparece
loader: ({ params }) => this.api.forDocument(params).catch((): DocumentReport[] => []),
});
protected readonly formats = (): DocumentReport[] => this.list.value() ?? [];
protected readonly others = (): MenuItem[] => this.formats().slice(1).map((f) => ({ id: f.code, label: f.title }));
private raw: string | null = null;
constructor() {
inject(DestroyRef).onDestroy(() => this.revoke());
}
protected pick(code: string): void {
const f = this.formats().find((x) => x.code === code);
if (f) void this.open(f);
}
protected async open(f: DocumentReport): Promise<void> {
const entry = this.docEntry();
if (entry === null || this.busy()) return;
this.busy.set(true);
this.revoke();
this.view.set({ title: f.title, name: `${f.code}-${entry}.pdf` });
try {
const blob = await this.api.exportDocument(this.screen(), f.code, entry);
this.raw = URL.createObjectURL(blob);
this.view.update((v) => (v ? { ...v, raw: this.raw!, url: this.sanitizer.bypassSecurityTrustResourceUrl(this.raw!) } : v));
} catch (e) {
const error = await blobErrorMessage(e);
this.view.update((v) => (v ? { ...v, error } : v));
} finally {
this.busy.set(false);
}
}
protected close(): void {
this.view.set(null);
this.revoke();
}
private revoke(): void {
if (this.raw) URL.revokeObjectURL(this.raw);
this.raw = null;
}
}
@@ -1,8 +1,21 @@
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { AuthorizedReport, GatewayParam, GatewayReport, GatewayValues } from '@basic-erp/sap-types';
import type { AuthorizedReport, DocumentReport, GatewayParam, GatewayReport, GatewayValues } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../../core/api';
import { API_URL, apiErrorMessage } from '../../core/api';
/** El mensaje de un error cuyo cuerpo llegó como archivo (la respuesta esperada era un PDF). */
export async function blobErrorMessage(err: unknown): Promise<string> {
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);
}
/** Reportes del API Gateway a través de `/api/gateway` (matrícula y ejecución). */
@Injectable({ providedIn: 'root' })
@@ -19,6 +32,16 @@ export class GatewayApi {
return firstValueFrom(this.http.post(`${API_URL}/gateway/reports/${encodeURIComponent(code)}/export`, { values }, { responseType: 'blob' }));
}
/** Los formatos PDF de una pantalla de documento (el por defecto primero). */
forDocument(screen: string): Promise<DocumentReport[]> {
return firstValueFrom(this.http.get<DocumentReport[]>(`${API_URL}/gateway/documents`, { params: { screen } }));
}
/** El PDF de un documento con el formato indicado. */
exportDocument(screen: string, code: string, docEntry: number): Promise<Blob> {
return firstValueFrom(this.http.post(`${API_URL}/gateway/documents/export`, { screen, code, docEntry }, { responseType: 'blob' }));
}
// --- matrícula (administradores) -----------------------------------------------------------
registry(): Promise<GatewayReport[]> {
@@ -1,6 +1,6 @@
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 { MENU, 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';
@@ -8,6 +8,7 @@ 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 { DOCUMENT_PATHS } from '../documents/document-paths';
import { GatewayApi } from './gateway-api';
import { GATEWAY_CATEGORIES, GATEWAY_LOOKUPS } from './gateway-lookups';
@@ -21,6 +22,9 @@ interface Draft {
description: string;
enabled: boolean;
params: Row[];
documents: string[];
documentParam: string;
defaultFor: string[];
}
const YES = 'Sí';
@@ -101,6 +105,21 @@ export function toParam(r: Row): GatewayParam {
<h2>Parámetros</h2>
<p class="hint">«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: <code>valor=etiqueta; valor=etiqueta</code>.</p>
<sbo-rows-table [columns]="columns" [rows]="d.params" (rowsChange)="patch({ params: $event })" [removable]="false" emptyText="El reporte no tiene parámetros." />
<h2>Botón PDF de los documentos</h2>
<p class="hint">Marque los documentos que ofrecen este formato. El que se marca «Por defecto» es el que abre el botón «PDF» de ese documento (uno solo por documento, como en SAP); los demás salen en «Otros formatos». El número del documento (<code>DocEntry</code>) se envía en el parámetro elegido abajo; los demás parámetros toman su valor por defecto.</p>
<sbo-select-field label="Parámetro del documento" [options]="documentParamOptions(d)" [value]="d.documentParam" (valueChange)="patch({ documentParam: '' + ($event ?? '') })" />
<table class="grid docs">
<thead><tr><th>Documento</th><th class="chk">Ofrecer</th><th class="chk">Por defecto</th></tr></thead>
<tbody>
@for (s of docScreens; track s.path) {
<tr>
<td>{{ s.label }}</td>
<td class="chk"><input type="checkbox" [attr.aria-label]="'Ofrecer en ' + s.label" [checked]="d.documents.includes(s.path)" (change)="toggleDoc(s.path, $any($event.target).checked)" /></td>
<td class="chk"><input type="checkbox" [attr.aria-label]="'Por defecto en ' + s.label" [disabled]="!d.documents.includes(s.path)" [checked]="d.defaultFor.includes(s.path)" (change)="toggleDefault(s.path, $any($event.target).checked)" /></td>
</tr>
}
</tbody>
</table>
} @else {
<div class="bar">
<button type="button" class="btn main" (click)="startPick()">Matricular reporte</button>
@@ -162,6 +181,8 @@ export function toParam(r: Row): GatewayParam {
.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; }
.docs { max-width: 520px; margin-top: 6px; }
.chk { width: 90px; text-align: center; }
.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); }
@@ -171,6 +192,25 @@ export class RegistryPage {
private readonly api = inject(GatewayApi);
protected readonly title = inject(ActivatedRoute).snapshot.data['title'] as string;
/** Las pantallas de documento donde se puede ofrecer el botón PDF. */
protected readonly docScreens = DOCUMENT_PATHS.map((path) => ({ path, label: `${MENU.find((m) => m.items.some((i) => i.path === path))?.label ?? ''}: ${MENU.flatMap((m) => m.items).find((i) => i.path === path)?.label ?? path}` }));
protected documentParamOptions(d: Draft): { value: string; label: string }[] {
return d.params.filter((r) => r['hidden'] !== YES).map((r) => ({ value: String(r['name']), label: String(r['label'] || r['name']) }));
}
protected toggleDoc(path: string, on: boolean): void {
const d = this.draft();
if (!d) return;
const documents = on ? [...d.documents, path] : d.documents.filter((x) => x !== path);
this.patch({ documents, defaultFor: d.defaultFor.filter((x) => documents.includes(x)) });
}
protected toggleDefault(path: string, on: boolean): void {
const d = this.draft();
if (d) this.patch({ defaultFor: on ? [...d.defaultFor, path] : d.defaultFor.filter((x) => x !== path) });
}
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() : []));
@@ -231,7 +271,7 @@ export class RegistryPage {
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.draft.set({ code: chosen.code, isNew: true, title: chosen.name, category: 'Otros', description: '', enabled: true, params: params.map(toRow), documents: [], documentParam: '', defaultFor: [] });
this.message.set(null);
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
@@ -241,7 +281,7 @@ export class RegistryPage {
// --- 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.draft.set({ code: r.code, isNew: false, title: r.title, category: r.category, description: r.description ?? '', enabled: r.enabled, params: r.params.map(toRow), documents: r.documents ?? [], documentParam: r.documentParam ?? '', defaultFor: r.defaultFor ?? [] });
this.message.set(null);
}
@@ -262,9 +302,13 @@ export class RegistryPage {
this.message.set({ kind: 'err', text: `Elija la ventana de selección de: ${lookups.map((r) => r['label'] || r['name']).join(', ')}.` });
return;
}
if (d.documents.length && !this.documentParamOptions(d).some((o) => o.value === d.documentParam)) {
this.message.set({ kind: 'err', text: 'Elija el parámetro que recibe el número del documento.' });
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) });
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), ...(d.documents.length ? { documents: d.documents, documentParam: d.documentParam, ...(d.defaultFor.length ? { defaultFor: d.defaultFor } : {}) } : {}) });
this.draft.set(null);
this.registry.reload();
this.message.set({ kind: 'ok', text: `Reporte ${d.code} ${d.isNew ? 'matriculado' : 'actualizado'}.` });
@@ -1,28 +1,14 @@
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 { GatewayApi, blobErrorMessage } 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<string> {
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.
+14
View File
@@ -38,6 +38,20 @@ export interface GatewayReport {
description?: string;
enabled: boolean;
params: GatewayParam[];
/** Pantallas de documento (ruta del menú, `/ventas/entregas`) cuyo botón PDF ofrece este reporte. */
documents?: string[];
/** Parámetro del reporte que recibe el `DocEntry` del documento abierto (obligatorio si hay `documents`). */
documentParam?: string;
/** De esas pantallas, aquellas donde es el formato por defecto (el que abre el botón «PDF»; los demás van en el menú). */
defaultFor?: string[];
}
/** Un reporte que se ofrece en el botón PDF de un documento. */
export interface DocumentReport {
code: string;
title: string;
/** El formato por defecto de la pantalla (uno solo). */
isDefault: boolean;
}
/** Un reporte que el API Gateway autoriza (`LoadAuthorizedCRList`). */