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 0000000..1172f55 Binary files /dev/null and b/apps/api/templates/tagumedica/fonts/Orbitron-Bold.ttf differ