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:
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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(', ')}`);
|
||||
|
||||
Reference in new issue
Block a user