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

@@ -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(', ')}`);