feat(tagumedica): reporte HTML de etiquetas de bultos (plantilla migrada de Jinja2, consulta a HANA y parámetros entrega, cantidad y papel)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 16:49:01 -05:00
1 parent 63dc9b10ca
commit 3fbfe9328e
7 files changed
+294 -1

No files matched your search

@@ -0,0 +1,73 @@
import { resolve } from 'node:path';
import { BadRequestException } from '@nestjs/common';
import { SAPDB_QUERIES } from '../sapdb/sapdb-queries.js';
import { bindNamed } from '../sapdb/sapdb-sql.js';
import { createRenderer } from './html-render.js';
import { ETIQUETAS_BULTOS, MAX_LABELS, labelsData } from './etiquetas-bultos.js';
import { parseHtmlValues } from './html-reports.service.js';
import { HTML_REPORTS } from './html-reports.js';
const ROW = { CardName: 'CLÍNICA <PLASTIC> & CIA S.A.C.', Guia: 'T001-37070', Direccion: 'AV. DOMINGO ORUE NRO. 469', Departamento: 'LIMA', Provincia: 'LIMA', Distrito: 'SURQUILLO' };
const render = createRenderer(resolve(import.meta.dirname, '../../templates')).render;
const html = (n: number, paper: 'Carta' | 'A4' = 'Carta') => render(ETIQUETAS_BULTOS.template, labelsData(ROW, n, paper));
const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
describe('etiquetas de bultos', () => {
it('está registrado con la entrega, la cantidad y el papel como parámetros', () => {
expect(HTML_REPORTS.get('etiquetas-bultos')).toBe(ETIQUETAS_BULTOS);
expect(ETIQUETAS_BULTOS.params.map((p) => [p.name, p.kind, p.required])).toEqual([['docEntry', 'lookup', true], ['cantidad', 'number', true], ['papel', 'select', true]]);
expect(ETIQUETAS_BULTOS.params[0].lookup).toBe('doc:DeliveryNotes');
// el papel por defecto es Carta
expect(parseHtmlValues(ETIQUETAS_BULTOS, { docEntry: '144153', cantidad: '6' })).toMatchObject({ papel: 'Carta' });
});
it('reparte las etiquetas de a 4 por hoja y las numera 1/6… 6/6', () => {
const d = labelsData(ROW, 6, 'Carta') as { paginas: { numero: number; total: number }[][] };
expect(d.paginas.map((p) => p.length)).toEqual([4, 2]);
expect(d.paginas.flat().map((l) => `${l.numero}/${l.total}`)).toEqual(['1/6', '2/6', '3/6', '4/6', '5/6', '6/6']);
expect((labelsData(ROW, 8, 'A4') as { paginas: unknown[] }).paginas).toHaveLength(2);
});
it('une departamento, provincia y distrito en el lugar, sin partes vacías', () => {
expect((labelsData({ ...ROW, Provincia: null }, 1, 'Carta') as { datos: { lugar: string } }).datos.lugar).toBe('LIMA - SURQUILLO');
});
it('la plantilla dibuja una etiqueta por cada una, con sus datos, y escapa el HTML del cliente', () => {
const out = html(6);
expect(count(out, /class="sticker"/g)).toBe(6);
expect(count(out, /class="pagina"/g)).toBe(2);
expect(out).toContain('1/6');
expect(out).toContain('6/6');
expect(out).toContain('T001-37070');
expect(out).toContain('LIMA - LIMA - SURQUILLO');
expect(out).toContain('CLÍNICA &lt;PLASTIC&gt; &amp; CIA S.A.C.');
expect(out).not.toContain('<PLASTIC>');
});
it('el papel fija el tamaño de la hoja y su alto útil; la fuente va incrustada', () => {
expect(html(1, 'Carta')).toMatch(/size: letter landscape/);
expect(html(1, 'Carta')).toContain('18.19cm');
expect(html(1, 'A4')).toMatch(/size: A4 landscape/);
expect(html(1, 'A4')).toContain('17.6cm');
expect(html(1)).toContain('data:font/ttf;base64,');
expect(html(1)).toContain('line-height: 1.2');
});
it('pide la guía a la consulta delivery-labels y rechaza cantidades que no valen o una entrega que no existe', async () => {
const query = vi.fn(async () => [ROW]);
const values = { docEntry: '144153', cantidad: '3', papel: 'A4' };
const data = (await ETIQUETAS_BULTOS.data(values, { query })) as { paper_size: string };
expect(query).toHaveBeenCalledWith('delivery-labels', { docEntry: 144153 });
expect(data.paper_size).toBe('A4');
for (const cantidad of ['0', '2.5', String(MAX_LABELS + 1)]) await expect(ETIQUETAS_BULTOS.data({ ...values, cantidad }, { query })).rejects.toThrow(BadRequestException);
await expect(ETIQUETAS_BULTOS.data(values, { query: async () => [] })).rejects.toThrow(/No se encontró la entrega 144153/);
});
it('la consulta delivery-labels está registrada para este menú y recibe la entrega como argumento', () => {
const q = SAPDB_QUERIES.get('delivery-labels')!;
expect(q.menu).toBe('/reportes-html/ejecutar');
const b = bindNamed(q.sql.hana!, { docEntry: 144153 });
expect(b.args).toEqual([144153]);
expect(b.sql).not.toContain(':');
});
});
@@ -0,0 +1,57 @@
import { BadRequestException } from '@nestjs/common';
import type { GatewayParam } from '@basic-erp/sap-types';
import type { SapDbRow } from '../sapdb/sapdb.types.js';
import type { HtmlReportDef } from './html-reports.types.js';
/** Papel: tamaño para `@page` y alto útil de la hoja horizontal (papel menos los márgenes superior e inferior de la plantilla). */
const PAPER = {
Carta: { size: 'letter', height: '18.19cm' },
A4: { size: 'A4', height: '17.6cm' },
} as const;
/** Tope de etiquetas por pedido (evita un documento enorme por un error de tecleo). */
export const MAX_LABELS = 500;
const PER_PAGE = 4;
/**
* Lo que ve la plantilla: los datos de la guía y las etiquetas numeradas (`1/6`…), de a 4 por hoja. El lugar une
* departamento, provincia y distrito.
*/
export function labelsData(row: SapDbRow, quantity: number, paper: keyof typeof PAPER): Record<string, unknown> {
const text = (k: string): string => (row[k] === null || row[k] === undefined ? '' : String(row[k]).trim());
const labels = Array.from({ length: quantity }, (_, i) => ({ numero: i + 1, total: quantity }));
return {
datos: {
cliente: text('CardName'),
direccion: text('Direccion'),
lugar: [text('Departamento'), text('Provincia'), text('Distrito')].filter(Boolean).join(' - '),
documento: text('Guia'),
},
paginas: Array.from({ length: Math.ceil(labels.length / PER_PAGE) }, (_, i) => labels.slice(i * PER_PAGE, (i + 1) * PER_PAGE)),
paper_size: PAPER[paper].size,
pagina_alto: PAPER[paper].height,
};
}
const param = (p: Partial<GatewayParam> & Pick<GatewayParam, 'name' | 'label' | 'kind'>): GatewayParam => ({ type: 'xsd:string', required: false, ...p });
/** Etiquetas de bultos de una guía de remisión (entrega): recibe la entrega y la cantidad de etiquetas a imprimir. */
export const ETIQUETAS_BULTOS: HtmlReportDef = {
code: 'etiquetas-bultos',
title: 'Etiquetas de bultos',
category: 'Ventas',
description: 'Etiquetas de los bultos de una guía de remisión: cliente, dirección, lugar y documento, numeradas (1/6, 2/6…), 4 por hoja.',
template: 'tagumedica/etiquetas-bultos.njk',
params: [
param({ name: 'docEntry', label: 'Entrega', kind: 'lookup', required: true, lookup: 'doc:DeliveryNotes', type: 'xsd:decimal' }),
param({ name: 'cantidad', label: 'Cantidad de etiquetas', kind: 'number', required: true, type: 'xsd:decimal' }),
param({ name: 'papel', label: 'Papel', kind: 'select', required: true, defaultValue: 'Carta', options: [{ value: 'Carta', label: 'Carta' }, { value: 'A4', label: 'A4' }] }),
],
async data(values, ctx) {
const quantity = Number(values['cantidad']);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_LABELS) throw new BadRequestException(`La cantidad de etiquetas debe ser un entero entre 1 y ${MAX_LABELS}`);
const rows = await ctx.query('delivery-labels', { docEntry: Number(values['docEntry']) });
if (!rows[0]) throw new BadRequestException(`No se encontró la entrega ${values['docEntry']}`);
return labelsData(rows[0], quantity, values['papel'] as keyof typeof PAPER);
},
};
@@ -1,7 +1,8 @@
import { ETIQUETAS_BULTOS } from './etiquetas-bultos.js';
import type { HtmlReportDef } from './html-reports.types.js';
/**
* Reportes HTML que agrega la rama de un cliente (vacío en `main`): cada uno con su plantilla Nunjucks (en la carpeta de
* plantillas del cliente), sus parámetros y la función que arma sus datos con consultas registradas de `sapdb`.
*/
export const CLIENT_HTML_REPORTS: readonly HtmlReportDef[] = [];
export const CLIENT_HTML_REPORTS: readonly HtmlReportDef[] = [ETIQUETAS_BULTOS];
+18
View File
@@ -8,6 +8,24 @@ const FLOWS = ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes'] as
* tipo de operación, despacho, motivo) no necesitan consulta propia: salen de `/api/sapdb/user-fields`.
*/
export const CLIENT_SAPDB_QUERIES: readonly SapDbQuery[] = [
{
// Datos de una guía de remisión (entrega) para sus etiquetas de bultos: cliente, dirección de entrega, lugar y documento
// (el número de la guía, o su folio si no lo tiene).
name: 'delivery-labels',
title: 'Datos de una entrega para las etiquetas de bultos',
sql: {
hana: `SELECT TOP 1 T0."CardName" AS "CardName",
CASE WHEN IFNULL(T0."NumAtCard", '') <> '' THEN T0."NumAtCard" ELSE T0."FolioPref" || '-' || T0."FolioNum" END AS "Guia",
T7."StreetS" AS "Direccion", T7."CountyS" AS "Provincia", T14."Name" AS "Departamento", T7."ZipCodeS" AS "Distrito"
FROM "ODLN" T0
LEFT JOIN "DLN12" T7 ON T0."DocEntry" = T7."DocEntry"
LEFT JOIN "OCST" T14 ON T14."Code" = T7."StateS" AND T14."Country" = T7."CountryS"
WHERE T0."DocEntry" = :docEntry`,
},
params: { docEntry: { type: 'number' } },
menu: '/reportes-html/ejecutar',
maxRows: 1,
},
{
// Stock de un lote por número, en cada artículo y almacén (un mismo número de lote puede repetirse en varios artículos).
name: 'lot-stock',