From 025271720650b5afeb3806e93d507d4e7255ca40 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 20 Oct 2026 16:54:29 -0500 Subject: [PATCH] =?UTF-8?q?feat(tagumedica):=20etiquetas=20de=20bultos=20s?= =?UTF-8?q?olo=20en=20papel=20Carta=20precortado=20(sin=20par=C3=A1metro?= =?UTF-8?q?=20de=20papel)=20y=20vista=20HTML=20con=20los=20m=C3=A1rgenes?= =?UTF-8?q?=20en=20pantalla?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- .../src/htmlreports/etiquetas-bultos.spec.ts | 32 ++++++++++--------- apps/api/src/htmlreports/etiquetas-bultos.ts | 22 ++++++------- .../templates/tagumedica/etiquetas-bultos.njk | 6 ++++ 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4d478b7..a4773c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Menú **Reportes HTML** (`/reportes-html/ejecutar`): reportes que cada cliente d ## 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. +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`) y la cantidad de etiquetas (entero de 1 a 500). **El papel es siempre Carta precortado** (no hay parámetro de papel): los márgenes de la plantilla (`1.6cm 1.5cm 1.8cm 2.7cm`) y la rejilla están ajustados a los recortes de la hoja, así que no se tocan sin probar con papel; la salida buena es el **PDF** (botón «Generar PDF»), que se imprime en **«Tamaño real / 100 %»**. Sale en hojas horizontales de 4 etiquetas (2 × 2) numeradas `1/6`… Verificado con la entrega 144153 contra `SBO_TAGUMEDICA_21` (consulta + plantilla + `HtmlPdfService`: PDF de 2 páginas Carta de 792 × 612 pt en 0,6 s; la primera etiqueta cae a 76,5 pt del borde izquierdo y 45,4 pt del superior, más el relleno). La vista HTML lleva los mismos márgenes en pantalla (`@media screen`). 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») diff --git a/apps/api/src/htmlreports/etiquetas-bultos.spec.ts b/apps/api/src/htmlreports/etiquetas-bultos.spec.ts index 13a7087..463be68 100644 --- a/apps/api/src/htmlreports/etiquetas-bultos.spec.ts +++ b/apps/api/src/htmlreports/etiquetas-bultos.spec.ts @@ -9,27 +9,26 @@ 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 html = (n: number) => render(ETIQUETAS_BULTOS.template, labelsData(ROW, n)); 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', () => { + it('está registrado con la entrega y la cantidad como parámetros (el papel es siempre Carta precortado)', () => { 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.map((p) => [p.name, p.kind, p.required])).toEqual([['docEntry', 'lookup', true], ['cantidad', 'number', 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' }); + expect(parseHtmlValues(ETIQUETAS_BULTOS, { docEntry: '144153', cantidad: '6' })).toEqual({ docEntry: '144153', cantidad: '6' }); }); 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 }[][] }; + const d = labelsData(ROW, 6) 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); + expect((labelsData(ROW, 8) 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'); + expect((labelsData({ ...ROW, Provincia: null }, 1) 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', () => { @@ -44,21 +43,24 @@ describe('etiquetas de bultos', () => { 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'); + it('la hoja es Carta horizontal con los márgenes ajustados a las etiquetas precortadas; la fuente va incrustada', () => { + expect(html(1)).toMatch(/size: letter landscape/); + expect(html(1)).toContain('margin: 1.6cm 1.5cm 1.8cm 2.7cm;'); + expect(html(1)).toContain('18.19cm'); expect(html(1)).toContain('data:font/ttf;base64,'); expect(html(1)).toContain('line-height: 1.2'); }); + it('en pantalla el cuerpo lleva los mismos márgenes para ver las etiquetas como saldrán', () => { + expect(html(1)).toMatch(/@media screen\s*{\s*body { margin: 1\.6cm 1\.5cm 1\.8cm 2\.7cm !important; }/); + }); + 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 values = { docEntry: '144153', cantidad: '3' }; 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'); + expect(data.paper_size).toBe('letter'); 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/); }); diff --git a/apps/api/src/htmlreports/etiquetas-bultos.ts b/apps/api/src/htmlreports/etiquetas-bultos.ts index 5c359be..7a1930c 100644 --- a/apps/api/src/htmlreports/etiquetas-bultos.ts +++ b/apps/api/src/htmlreports/etiquetas-bultos.ts @@ -3,11 +3,12 @@ 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; +/** + * El papel de las etiquetas es siempre Carta precortado: tamaño para `@page` y alto útil de la hoja horizontal (papel menos + * los márgenes superior e inferior de la plantilla). Los márgenes de la plantilla están ajustados a los recortes de la hoja: + * no se tocan sin probar con papel. + */ +const PAPER = { size: 'letter', height: '18.19cm' } as const; /** Tope de etiquetas por pedido (evita un documento enorme por un error de tecleo). */ export const MAX_LABELS = 500; @@ -17,7 +18,7 @@ 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 { +export function labelsData(row: SapDbRow, quantity: number): 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 { @@ -28,14 +29,14 @@ export function labelsData(row: SapDbRow, quantity: number, paper: keyof typeof 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, + paper_size: PAPER.size, + pagina_alto: 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. */ +/** Etiquetas de bultos de una guía de remisión (entrega): recibe la entrega y la cantidad de etiquetas a imprimir (papel Carta precortado). */ export const ETIQUETAS_BULTOS: HtmlReportDef = { code: 'etiquetas-bultos', title: 'Etiquetas de bultos', @@ -45,13 +46,12 @@ export const ETIQUETAS_BULTOS: HtmlReportDef = { 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); + return labelsData(rows[0], quantity); }, }; diff --git a/apps/api/templates/tagumedica/etiquetas-bultos.njk b/apps/api/templates/tagumedica/etiquetas-bultos.njk index 8cb8602..2eab92b 100644 --- a/apps/api/templates/tagumedica/etiquetas-bultos.njk +++ b/apps/api/templates/tagumedica/etiquetas-bultos.njk @@ -20,6 +20,12 @@ margin: 1.6cm 1.5cm 1.8cm 2.7cm; } + /* En pantalla no hay hoja: el cuerpo lleva los mismos márgenes del @page para ver las etiquetas como saldrán. */ + @media screen { + body { margin: 1.6cm 1.5cm 1.8cm 2.7cm !important; } + .pagina { margin-bottom: 0.6cm; } + } + body { line-height: 1.2; font-family: Arial, Helvetica, sans-serif;