From 3fbfe9328e9e449ecccba359bb8265ceb3e88380 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 20 Oct 2026 16:49:01 -0500 Subject: [PATCH] =?UTF-8?q?feat(tagumedica):=20reporte=20HTML=20de=20etiqu?= =?UTF-8?q?etas=20de=20bultos=20(plantilla=20migrada=20de=20Jinja2,=20cons?= =?UTF-8?q?ulta=20a=20HANA=20y=20par=C3=A1metros=20entrega,=20cantidad=20y?= =?UTF-8?q?=20papel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 + .../src/htmlreports/etiquetas-bultos.spec.ts | 73 +++++++++ apps/api/src/htmlreports/etiquetas-bultos.ts | 57 +++++++ .../htmlreports/html-reports.extensions.ts | 3 +- apps/api/src/sapdb/sapdb.extensions.ts | 18 +++ .../templates/tagumedica/etiquetas-bultos.njk | 140 ++++++++++++++++++ .../tagumedica/fonts/Orbitron-Bold.ttf | Bin 0 -> 24536 bytes 7 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/htmlreports/etiquetas-bultos.spec.ts create mode 100644 apps/api/src/htmlreports/etiquetas-bultos.ts create mode 100644 apps/api/templates/tagumedica/etiquetas-bultos.njk create mode 100644 apps/api/templates/tagumedica/fonts/Orbitron-Bold.ttf diff --git a/CLAUDE.md b/CLAUDE.md index 47fc555..9aefd18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,10 @@ Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): e Menú **Reportes HTML** (`/reportes-html/ejecutar`): reportes que cada cliente define como plantillas **Nunjucks** (la sintaxis de Jinja2: `{{ x }}`, `{% for %}`) rellenadas con datos de la base de SAP y que se **imprimen desde el navegador**. El motor es genérico y vive en `main` (`apps/api/src/htmlreports/`); los reportes los declara la rama del cliente en `html-reports.extensions.ts` (vacío en `main`) y sus plantillas van en `apps/api/templates//` (`HTML_REPORTS_DIR`; ver `templates/README.md`). Cada `HtmlReportDef` trae `code`, `title`, `category`, `params` (los mismos `GatewayParam` de los reportes del Gateway: texto, número, fecha, lista fija o ventana de selección de documentos/socios/artículos…, con valor por defecto), `template` y `data(values, ctx)`, que arma lo que ve la plantilla con `ctx.query(nombre, params)` (consultas registradas de `sapdb`, cuyo permiso de menú debe incluir `/reportes-html/ejecutar`). La plantilla recibe esos datos y `params`; los valores se escapan solos y `{{ asset('/fonts/F.ttf') }}` entrega un archivo de la carpeta (fuente, logo) como `data:` URI, sin salir de ella. API: `GET /api/html-reports` y `POST /api/html-reports/:code/render {values}` (HTML); permiso de menú `/reportes-html/ejecutar`; auditoría `html_report` (solo los nombres de los parámetros). **Pantalla:** reutiliza la lista y el formulario de «Reportes» (`data.source = 'html'`); el resultado se ve en un marco (`sandbox`) con botón **Imprimir** y un recordatorio: *Márgenes predeterminados, encabezados y pies de página desactivados, escala 100 %* (Chrome y Edge respetan `@page`; conviene fijar `line-height`). **Verificado** (etiquetas de bultos de TAGUMEDICA, WeasyPrint frente al PDF de Chrome con la misma plantilla): mismas páginas, márgenes, rejilla y espacios; con `line-height` fijo el texto difiere 0,3 mm de media. Pendiente: salida PDF desde el servidor (Puppeteer) si hace falta; el botón PDF de los documentos aún no ofrece los reportes HTML. +## TAGUMEDICA: etiquetas de bultos (solo en `cliente/tagumedica`) + +Primer reporte HTML del cliente (`/reportes-html/ejecutar` → «Etiquetas de bultos»), migrado de `mini-erp` (`templates/reports/stickers.html`, Jinja2 + WeasyPrint): plantilla `apps/api/templates/tagumedica/etiquetas-bultos.njk` (la misma, con `line-height: 1.2` y la fuente Orbitron incrustada con `asset()`), definición en `htmlreports/etiquetas-bultos.ts` y consulta `delivery-labels` (HANA: `ODLN`/`DLN12`/`OCST`, reducida a los 4 datos que usa la plantilla: cliente, dirección, lugar —departamento, provincia y distrito— y documento: el `NumAtCard` o el folio). **Parámetros:** la entrega (ventana de selección de documentos, valor = `DocEntry`), la cantidad de etiquetas (entero de 1 a 500) y el papel (Carta, por defecto, o A4). Sale en hojas horizontales de 4 etiquetas (2 × 2) numeradas `1/6`… Verificado con la entrega 144153 contra `SBO_TAGUMEDICA_21` (consulta + plantilla + PDF de Chrome: 2 páginas, Carta 792 × 612 pt y A4). Los usuarios necesitan el permiso de menú `/reportes-html/*` (o `/reportes-html/ejecutar`). Pendiente: ofrecerlo también en el botón PDF de Entregas. + ## Ver el nombre del campo de SAP («Campos») El mini-erp mostraba el nombre del campo de la base junto a cada casillero (`dbField`). Aquí es general: el botón **Campos** de la barra superior (`FieldNames`, se recuerda en `localStorage` `erp_fields`) muestra, debajo de cada etiqueta y en la cabecera de las tablas (listas, líneas de documento, tablas de filas), el nombre con el que el Service Layer lee y guarda el campo (`ItemCode`, `U_SIS_ESTLOT`); sin activarlo, el nombre sale siempre como ayuda al pasar el cursor. Los campos lo reciben con `dbName` (`FieldBase`/`SboField`); los formularios de maestros y documentos lo pasan solos desde su configuración declarativa. Cubre los formularios de maestros, de documentos (incluidas las filas con varios controles: `FolioPrefixString · FolioNumber`, `DocCurrency · DocRate`…, con `fieldDbName` en `document-layouts.ts`), de pagos y de asientos (sus campos van escritos a mano en el HTML con `dbName="…"`: un campo nuevo debe llevarlo) y las cabeceras de tablas. Solo se muestran como campos de SAP los nombres que empiezan con mayúscula (`isSapField`): las columnas internas de la interfaz (`docNum`, `_Name`) no. diff --git a/apps/api/src/htmlreports/etiquetas-bultos.spec.ts b/apps/api/src/htmlreports/etiquetas-bultos.spec.ts new file mode 100644 index 0000000..13a7087 --- /dev/null +++ b/apps/api/src/htmlreports/etiquetas-bultos.spec.ts @@ -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 & 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 <PLASTIC> & CIA S.A.C.'); + expect(out).not.toContain(''); + }); + + 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(':'); + }); +}); diff --git a/apps/api/src/htmlreports/etiquetas-bultos.ts b/apps/api/src/htmlreports/etiquetas-bultos.ts new file mode 100644 index 0000000..5c359be --- /dev/null +++ b/apps/api/src/htmlreports/etiquetas-bultos.ts @@ -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 { + 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 & Pick): 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); + }, +}; diff --git a/apps/api/src/htmlreports/html-reports.extensions.ts b/apps/api/src/htmlreports/html-reports.extensions.ts index 8ece299..ea7b23c 100644 --- a/apps/api/src/htmlreports/html-reports.extensions.ts +++ b/apps/api/src/htmlreports/html-reports.extensions.ts @@ -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]; diff --git a/apps/api/src/sapdb/sapdb.extensions.ts b/apps/api/src/sapdb/sapdb.extensions.ts index 230a42f..f548f68 100644 --- a/apps/api/src/sapdb/sapdb.extensions.ts +++ b/apps/api/src/sapdb/sapdb.extensions.ts @@ -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', diff --git a/apps/api/templates/tagumedica/etiquetas-bultos.njk b/apps/api/templates/tagumedica/etiquetas-bultos.njk new file mode 100644 index 0000000..8cb8602 --- /dev/null +++ b/apps/api/templates/tagumedica/etiquetas-bultos.njk @@ -0,0 +1,140 @@ +{#- + Etiquetas de bultos de una guía de remisión (TAGUMEDICA): 4 etiquetas horizontales por hoja (2 × 2). + Datos: datos.cliente, datos.direccion, datos.lugar, datos.documento · paginas (listas de 4 etiquetas con numero y total) · + paper_size (letter | A4) · pagina_alto (alto útil de la hoja). + Migrada de mini-erp (templates/reports/stickers.html, Jinja2): misma plantilla con el interlineado fijado (line-height) + para que Chrome imprima igual que WeasyPrint, y la fuente incrustada con asset(). +-#} + + + + + + + + {% for pagina in paginas %} +
+ {% for sticker in pagina %} +
+
TAGUMEDICA
+ +
+
DATOS DEL CLIENTE:
+
{{ datos.cliente }}
+
+ +
+
DIRECCIÓN:
+
{{ datos.direccion }}
+
+ +
+
LUGAR:
+
{{ datos.lugar }}
+
+ +
+
+
DOCUMENTO:
+
{{ datos.documento }}
+
+
{{ sticker.numero }}/{{ sticker.total }}
+
+
+ {% endfor %} +
+ {% endfor %} + + + diff --git a/apps/api/templates/tagumedica/fonts/Orbitron-Bold.ttf b/apps/api/templates/tagumedica/fonts/Orbitron-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1172f5532caf720a2bb8694850c92b9772e32c30 GIT binary patch literal 24536 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