From abc1cc4ac6c3e4b221f9f2b57abbc32bb87626ae Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 12:25:01 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20lotes=20y=20series=20en=20las=20l?= =?UTF-8?q?=C3=ADneas=20de=20entradas,=20salidas=20y=20transferencias=20de?= =?UTF-8?q?=20stock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Una ventana por línea con tabla editable; validación de suma de lotes y una serie por unidad. Probado contra SBODemoCL: entrada de inventario 156 con SU01 y su lote. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 +- .../documents/document-batches.spec.ts | 148 ++++++++++++++++ .../documents/document-form.page.html | 2 + .../features/documents/document-form.page.ts | 3 + .../features/documents/document-layouts.ts | 13 +- .../app/features/documents/document-model.ts | 6 + .../inventario/inventario.layouts.ts | 8 +- .../app/features/documents/line-columns.ts | 2 +- .../features/documents/line-details.spec.ts | 96 +++++++++++ .../app/features/documents/line-details.ts | 114 +++++++++++++ apps/web/src/app/shared/grid/grid.types.ts | 27 +++ .../src/app/shared/grid/line-detail-dialog.ts | 161 ++++++++++++++++++ apps/web/src/app/shared/grid/lines-grid.css | 20 +++ apps/web/src/app/shared/grid/lines-grid.html | 25 ++- .../src/app/shared/grid/lines-grid.spec.ts | 90 ++++++++++ apps/web/src/app/shared/grid/lines-grid.ts | 43 ++++- 16 files changed, 752 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/features/documents/document-batches.spec.ts create mode 100644 apps/web/src/app/features/documents/line-details.spec.ts create mode 100644 apps/web/src/app/features/documents/line-details.ts create mode 100644 apps/web/src/app/shared/grid/line-detail-dialog.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8f47e39..2d264b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Cada menú abre primero una **lista de los últimos documentos** (barra de filtr Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compartida y cola, login, permisos SQLite, proxy `/api/sl`) · web (temas, login, layout con menú por permisos, `sbo-value-help`, campos y filas compuestas, listas con filtros, ventana/toolbar/pestañas/menús, grilla de líneas, `/dev/componentes`). Pendiente, en orden: -1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: los artículos por **lote o serie** no se pueden contabilizar (SAP pide `BatchNumbers`/`SerialNumbers`); las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras también lo envían (la unidad de venta o de compra del artículo, o la de inventario; `itemColumnWithUom`), y no se envía la unidad manual (-1). Probado: orden de compra 2884 con `SU01`, que no tiene unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla (en la carpeta de su módulo) declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. En ventas y compras la pantalla puede agregar campos (`headerLeft`/`headerRight`), reemplazar la cabecera (`header`) o elegir sus columnas de línea del catálogo `LINE_COLUMNS` (`lineColumns: [LINE_COLUMNS.item, LINE_COLUMNS.quantity, …]`; lo que se envía por línea sale de ellas); sin elegir, el documento lleva la cabecera y todas las columnas base. +1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras también lo envían (la unidad de venta o de compra del artículo, o la de inventario; `itemColumnWithUom`), y no se envía la unidad manual (-1). Probado: orden de compra 2884 con `SU01`, que no tiene unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla (en la carpeta de su módulo) declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. En ventas y compras la pantalla puede agregar campos (`headerLeft`/`headerRight`), reemplazar la cabecera (`header`) o elegir sus columnas de línea del catálogo `LINE_COLUMNS` (`lineColumns: [LINE_COLUMNS.item, LINE_COLUMNS.quantity, …]`; lo que se envía por línea sale de ellas); sin elegir, el documento lleva la cabecera y todas las columnas base. **Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). La alineación de una columna se declara con `align: 'left' | 'center' | 'right'` (listas y detalle; por defecto, derecha en las numéricas). @@ -65,6 +65,8 @@ Pendiente, en orden: **Movimientos de inventario:** cabecera con fecha de contabilización, fecha del documento (`TaxDate`), vencimiento (`DueDate`, solo traslados), `Reference2` y comentarios del asiento (`JournalMemo`); `Reference1` lo asigna SAP. Copiar de/a: la transferencia de stock se crea desde una solicitud de traslado (`COPY_FLOWS`); en las líneas de transferencia `BaseType` es un **enumerado de texto** de SAP (`InventoryTransferRequest`, `WarehouseTransfers`…; `STOCK_BASE_TYPES`, `hasBaseRef`), no el número de objeto de ventas y compras. +**Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo). + Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (``). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885). 2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). Pendiente en maestros: colecciones (direcciones, personas de contacto, precios por lista, almacenes por artículo), lotes/series, y cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy. 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). diff --git a/apps/web/src/app/features/documents/document-batches.spec.ts b/apps/web/src/app/features/documents/document-batches.spec.ts new file mode 100644 index 0000000..038a9d1 --- /dev/null +++ b/apps/web/src/app/features/documents/document-batches.spec.ts @@ -0,0 +1,148 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DocumentFormPage } from './document-form.page'; +import { DOCUMENT_SCREENS } from './document-screens'; + +describe('entrada de inventario con un artículo por lote', () => { + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + let navigate: ReturnType; + const el = () => fixture.nativeElement as HTMLElement; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const page = () => fixture.componentInstance as any; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + const create = () => (el().querySelector('.actions .btn.main') as HTMLElement).click(); + const status = () => el().querySelector('.status')?.textContent ?? ''; + + beforeEach(async () => { + TestBed.resetTestingModule(); + const screen = DOCUMENT_SCREENS.find((s) => s.path === '/inventario/entradas')!; + const route = { + snapshot: { data: { screen, title: 'Entrada' }, paramMap: convertToParamMap({}) }, + paramMap: of(convertToParamMap({})), + }; + await TestBed.configureTestingModule({ + imports: [DocumentFormPage], + providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], + }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + ctrl.match((r) => r.url.includes('SeriesService')).forEach((r) => r.flush({ value: [] })); + }); + + /** Escribe un código de artículo en la primera línea, como lo haría el usuario, y responde el maestro de artículos. */ + async function typeItem(item: Record) { + page().setLines([{ ItemCode: '', ItemDescription: '', Quantity: 5, WarehouseCode: '01', Price: null }]); + await tick(); + const input = el().querySelector('input[data-r="0"][data-c="0"]')!; + input.value = String(item['ItemCode']); + input.dispatchEvent(new Event('change')); + await tick(); + const req = ctrl.expectOne((r) => r.url === '/api/sl/Items'); + expect(req.request.params.get('$select')).toContain('ManageBatchNumbers'); + req.flush({ value: [item], 'odata.count': '1' }); + await tick(); + } + + it('al elegir un artículo por lote la línea pide sus lotes', async () => { + await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES', ManageSerialNumbers: 'tNO', UoMGroupEntry: -1 }); + const buttons = [...el().querySelectorAll('.detbtn')].map((b) => b.textContent?.trim()); + expect(buttons).toEqual(['Indicar lotes']); + }); + + it('un artículo sin lote ni serie no muestra botón', async () => { + await typeItem({ ItemCode: 'A1', ItemName: 'Pieza', ManageBatchNumbers: 'tNO', ManageSerialNumbers: 'tNO' }); + expect(el().querySelectorAll('.detbtn')).toHaveLength(0); + }); + + it('un artículo por serie pide series', async () => { + await typeItem({ ItemCode: 'E00001', ItemName: 'Equipo', ManageBatchNumbers: 'tNO', ManageSerialNumbers: 'tYES' }); + expect([...el().querySelectorAll('.detbtn')].map((b) => b.textContent?.trim())).toEqual(['Indicar series']); + }); + + it('no deja crear sin los lotes, ni con lotes que no suman la cantidad', async () => { + await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES' }); + create(); + await tick(); + ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); + expect(status()).toContain('indique los lotes de SU01'); + page().setLines([{ ...page().lines()[0], BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }] }]); + await tick(); + create(); + await tick(); + ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService')); + expect(status()).toContain('los lotes suman 3 y la cantidad es 5'); + }); + + it('con los lotes completos crea la entrada con BatchNumbers dentro de la línea', async () => { + await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES', UoMGroupEntry: -1 }); + page().setLines([{ ...page().lines()[0], BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3, ExpiryDate: '2027-03-01' }, { BatchNumber: 'L2', Quantity: 2 }] }]); + await tick(); + expect(el().querySelector('.detbtn')?.textContent?.trim()).toBe('2 lotes · 5'); + create(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService')); + expect(req.request.url).toBe('/api/sl/InventoryGenEntries'); + const body = req.request.body as { DocumentLines: Record[] }; + expect(body.DocumentLines).toEqual([ + { + ItemCode: 'SU01', + ItemDescription: 'Sutura', + Quantity: 5, + WarehouseCode: '01', + BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3, ExpiryDate: '2027-03-01' }, { BatchNumber: 'L2', Quantity: 2 }], + }, + ]); + req.flush({ DocEntry: 160, DocNum: 160 }); + await tick(); + expect(navigate).toHaveBeenCalled(); + }); + + it('cambiar el artículo de la línea descarta los lotes del anterior', async () => { + await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES' }); + page().setLines([{ ...page().lines()[0], BatchNumbers: [{ BatchNumber: 'L1', Quantity: 5 }] }]); + await tick(); + const input = el().querySelector('input[data-r="0"][data-c="0"]')!; + input.value = 'A1'; + input.dispatchEvent(new Event('change')); + await tick(); + ctrl.expectOne((r) => r.url === '/api/sl/Items').flush({ value: [{ ItemCode: 'A1', ItemName: 'Pieza', ManageBatchNumbers: 'tNO' }], 'odata.count': '1' }); + await tick(); + expect(page().lines()[0]['BatchNumbers']).toBeUndefined(); + expect(el().querySelectorAll('.detbtn')).toHaveLength(0); + }); + + it('una entrada contabilizada muestra sus lotes en solo lectura', async () => { + TestBed.resetTestingModule(); + const screen = DOCUMENT_SCREENS.find((s) => s.path === '/inventario/entradas')!; + const route = { snapshot: { data: { screen, title: 'Entrada' }, paramMap: convertToParamMap({ docEntry: '154' }) }, paramMap: of(convertToParamMap({ docEntry: '154' })) }; + await TestBed.configureTestingModule({ + imports: [DocumentFormPage], + providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], + }).compileComponents(); + ctrl = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(DocumentFormPage); + await tick(); + ctrl.match((r) => r.url.includes('SeriesService')).forEach((r) => r.flush({ value: [] })); + ctrl.expectOne('/api/sl/InventoryGenEntries/154').flush({ + DocEntry: 154, DocNum: 154, DocDate: '2026-09-30T00:00:00Z', DocumentStatus: 'bost_Open', Cancelled: 'tNO', + DocumentLines: [{ LineNum: 0, ItemCode: 'SU01', ItemDescription: 'Sutura', Quantity: 3, WarehouseCode: '01', BatchNumbers: [{ BatchNumber: 'LT001', Quantity: 3, SystemSerialNumber: 1 }] }], + }); + await tick(); + expect(el().querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote · 3'); + (el().querySelector('.detbtn') as HTMLElement).click(); + await tick(); + expect(el().querySelector('sbo-line-detail-dialog tbody input')!.value).toBe('LT001'); + expect(el().querySelector('sbo-line-detail-dialog footer .btn.main')).toBeNull(); + }); +}); diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index f6197f7..d986b37 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -31,6 +31,8 @@ [newLine]="newLine" [recalc]="recalc" [multiAdd]="multi" + [details]="layout.details ?? []" + [detailHint]="detailHint" />
diff --git a/apps/web/src/app/features/documents/document-form.page.ts b/apps/web/src/app/features/documents/document-form.page.ts index c8d722e..9f9f2f9 100644 --- a/apps/web/src/app/features/documents/document-form.page.ts +++ b/apps/web/src/app/features/documents/document-form.page.ts @@ -32,6 +32,7 @@ import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKU import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy'; import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens'; import { OBJECT_TYPES } from './document-objects'; +import { detailHint, validateLineDetails } from './line-details'; import { ITEMS_MULTI_ADD } from './line-columns'; const today = (): string => new Date().toISOString().slice(0, 10); @@ -316,6 +317,7 @@ export class DocumentFormPage { private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0]; protected readonly newLine = () => this.layout.newLine(this.header()); /** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */ + protected readonly detailHint = detailHint; protected readonly recalc = this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined; protected readonly multi: GridMultiAdd = { ...ITEMS_MULTI_ADD, @@ -411,6 +413,7 @@ export class DocumentFormPage { protected async save(): Promise { if (this.saving() || this.readOnly()) return; const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine); + if (this.layout.details?.length) errors.push(...validateLineDetails(this.lines())); if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.'); if (errors.length) { this.message.set({ kind: 'err', text: errors[0] }); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 3daa598..233cd7b 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -1,4 +1,4 @@ -import type { GridColumn, GridLine } from '../../shared/grid/grid.types'; +import type { GridColumn, GridDetail, GridLine } from '../../shared/grid/grid.types'; import type { ListColumn } from '../../shared/list/list.types'; import type { VhLookup } from '../../shared/value-help/value-help.types'; import type { DocRecord, LinesSpec } from './document-model'; @@ -73,6 +73,8 @@ export interface DocumentLayout { newLine: (header: DocRecord) => GridLine; /** Recalcula una línea tras un cambio (total = cantidad × precio…); recibe la tasa de cada código de impuesto. */ recalc?: (line: GridLine, changedKey: string, rates?: ReadonlyMap) => GridLine; + /** Detalles de línea (lotes, series) que este documento permite indicar. */ + details?: readonly GridDetail[]; /** Campos de cabecera que se ven pero no se editan (un cliente puede querer verlos sin cambiarlos). */ lockedKeys?: readonly string[]; /** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */ @@ -131,7 +133,14 @@ export function itemColumnWithUom(item: GridColumn, side: 'purchase' | 'sales'): ...item, lookup: { ...item.lookup!, - apply: (picked, line) => ({ ...item.lookup!.apply(picked, line), UoMEntry: uomEntryOf(picked, side) }), + apply: (picked, line) => ({ + ...item.lookup!.apply(picked, line), + UoMEntry: uomEntryOf(picked, side), + // Lotes y series: si el artículo se gestiona por ellos la línea los pide; al cambiar de artículo se descartan. + _manageBatches: picked['ManageBatchNumbers'] === 'tYES', + _manageSerials: picked['ManageSerialNumbers'] === 'tYES', + ...(picked['ItemCode'] === line['ItemCode'] ? {} : { BatchNumbers: undefined, SerialNumbers: undefined }), + }), }, }; } diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index 2daf05c..1b0d7b8 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -1,5 +1,6 @@ import type { GridLine } from '../../shared/grid/grid.types'; import { hasBaseRef } from './document-objects'; +import { DETAIL_KEYS, detailRowsPayload } from './line-details'; /** Documento tal como lo devuelve / recibe el Service Layer. */ export type DocRecord = Record; @@ -139,6 +140,11 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE .map((l) => { const out: DocRecord = {}; for (const f of spec.fields) { + if (DETAIL_KEYS.includes(f)) { + const rows = detailRowsPayload(f, l[f]); + if (rows.length) out[f] = rows; + continue; + } if (isEmpty(l[f])) continue; if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla out[f] = l[f]; diff --git a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts index f2af35a..0832f77 100644 --- a/apps/web/src/app/features/documents/inventario/inventario.layouts.ts +++ b/apps/web/src/app/features/documents/inventario/inventario.layouts.ts @@ -12,11 +12,13 @@ import { } from '../document-layouts'; import type { GridColumn } from '../../../shared/grid/grid.types'; import { ANALYTIC_COLUMNS, LINE_COLUMNS, lineFieldsOf } from '../line-columns'; +import { DETAIL_KEYS, LINE_DETAILS } from '../line-details'; /** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */ const goodsColumns: readonly GridColumn[] = [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn, ...ANALYTIC_COLUMNS]; export const goodsLayout: DocumentLayout = { - lines: { key: 'DocumentLines', fields: [...lineFieldsOf(goodsColumns), 'UoMEntry'], base: false }, + lines: { key: 'DocumentLines', fields: [...lineFieldsOf(goodsColumns), 'UoMEntry', ...DETAIL_KEYS], base: false }, + details: LINE_DETAILS, header: { left: [FIELDS.docDate, FIELDS.taxDate, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.reference2, FIELDS.journalMemo], @@ -50,7 +52,9 @@ const transfer = (immutable: boolean): DocumentLayout => { ]; return { // `base: true`: una transferencia hecha desde una solicitud de traslado envía la referencia al origen. - lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true }, + lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry', ...(immutable ? DETAIL_KEYS : [])], base: true }, + // Las transferencias de stock indican lotes y series; la solicitud de traslado no. + ...(immutable ? { details: LINE_DETAILS } : {}), header: { left: [FIELDS.fromWarehouse, FIELDS.toWarehouse, FIELDS.indicator, FIELDS.project], right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.taxDate, FIELDS.dueDate, FIELDS.reference2, FIELDS.journalMemo], diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index 038f147..56938f3 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -98,7 +98,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [ title: 'Artículos', resource: 'Items', orderby: 'ItemCode', - extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'DefaultSalesUoMEntry', 'InventoryUoMEntry'], + extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'DefaultSalesUoMEntry', 'InventoryUoMEntry', 'ManageBatchNumbers', 'ManageSerialNumbers'], columns: [ { key: 'ItemCode', title: 'Código', width: '150px', mono: true }, { key: 'ItemName', title: 'Descripción' }, diff --git a/apps/web/src/app/features/documents/line-details.spec.ts b/apps/web/src/app/features/documents/line-details.spec.ts new file mode 100644 index 0000000..8fb2bd3 --- /dev/null +++ b/apps/web/src/app/features/documents/line-details.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { createPayload } from './document-model'; +import { DOCUMENT_SCREENS } from './document-screens'; +import { BATCH_DETAIL, SERIAL_DETAIL, detailHint, detailRowsPayload, validateLineDetails } from './line-details'; + +describe('lotes y series de una línea', () => { + it('el lote aplica si el artículo se gestiona por lote o la línea ya trae lotes; no si no', () => { + expect(BATCH_DETAIL.applies({ _manageBatches: true })).toBe(true); + expect(BATCH_DETAIL.applies({ BatchNumbers: [{ BatchNumber: 'L1', Quantity: 2 }] })).toBe(true); + expect(BATCH_DETAIL.applies({ _manageBatches: false, BatchNumbers: [] })).toBe(false); + expect(BATCH_DETAIL.applies({})).toBe(false); + expect(SERIAL_DETAIL.applies({ _manageSerials: true })).toBe(true); + expect(SERIAL_DETAIL.applies({ _manageBatches: true })).toBe(false); + }); + + it('el botón resume lo indicado', () => { + expect(BATCH_DETAIL.summary({ _manageBatches: true })).toBe('Indicar lotes'); + expect(BATCH_DETAIL.summary({ BatchNumbers: [{ BatchNumber: 'A', Quantity: 3 }, { BatchNumber: 'B', Quantity: 2 }] })).toBe('2 lotes · 5'); + expect(BATCH_DETAIL.summary({ BatchNumbers: [{ BatchNumber: 'A', Quantity: 1 }] })).toBe('1 lote · 1'); + expect(SERIAL_DETAIL.summary({ _manageSerials: true })).toBe('Indicar series'); + expect(SERIAL_DETAIL.summary({ SerialNumbers: [{ InternalSerialNumber: 'S1' }, { InternalSerialNumber: 'S2' }] })).toBe('2 series'); + }); + + it('el aviso compara las filas en edición con la cantidad de la línea', () => { + expect(detailHint(BATCH_DETAIL, { Quantity: 10 }, [{ Quantity: 6 }, { Quantity: 4 }])).toEqual({ text: 'Cantidad de la línea: 10 · los lotes suman: 10', bad: false }); + expect(detailHint(BATCH_DETAIL, { Quantity: 10 }, [{ Quantity: 6 }]).bad).toBe(true); + expect(detailHint(SERIAL_DETAIL, { Quantity: 2 }, [{ InternalSerialNumber: 'S1' }, { InternalSerialNumber: '' }])).toEqual({ text: 'Series: 1 de 2', bad: true }); + }); + + it('a SAP solo van los campos propios, sin vacíos ni filas sin lote, con fechas AAAA-MM-DD y cada serie con cantidad 1', () => { + expect( + detailRowsPayload('BatchNumbers', [ + { BatchNumber: 'L1', Quantity: 4, ExpiryDate: '2027-01-31T00:00:00Z', Notes: 'x', SystemSerialNumber: 7, ManufacturingDate: '' }, + { BatchNumber: ' ', Quantity: 9 }, + ]), + ).toEqual([{ BatchNumber: 'L1', Quantity: 4, ExpiryDate: '2027-01-31' }]); + expect(detailRowsPayload('SerialNumbers', [{ InternalSerialNumber: 'S1', ManufacturerSerialNumber: 'M1' }])).toEqual([ + { InternalSerialNumber: 'S1', ManufacturerSerialNumber: 'M1', Quantity: 1 }, + ]); + expect(detailRowsPayload('Otro', [{ A: 1 }])).toEqual([]); + expect(detailRowsPayload('BatchNumbers', undefined)).toEqual([]); + }); + + describe('validación', () => { + it('un artículo por lote necesita lotes que sumen la cantidad', () => { + expect(validateLineDetails([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }])).toEqual(['Línea 1: indique los lotes de SU01.']); + expect(validateLineDetails([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true, BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }] }])).toEqual([ + 'Línea 1: los lotes suman 3 y la cantidad es 5.', + ]); + expect(validateLineDetails([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true, BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }, { BatchNumber: 'L2', Quantity: 2 }] }])).toEqual([]); + }); + + it('un artículo por serie necesita una serie por unidad', () => { + expect(validateLineDetails([{ ItemCode: 'A6', Quantity: 2, _manageSerials: true, SerialNumbers: [{ InternalSerialNumber: 'S1' }] }])).toEqual([ + 'Línea 2: indique 2 número(s) de serie de A6 (hay 1).'.replace('Línea 2', 'Línea 1'), + ]); + expect(validateLineDetails([{ ItemCode: 'A6', Quantity: 2, _manageSerials: true, SerialNumbers: [{ InternalSerialNumber: 'S1' }, { InternalSerialNumber: 'S2' }] }])).toEqual([]); + }); + + it('las líneas sin lotes ni series, y las vacías, no se validan; se numera solo lo real', () => { + expect(validateLineDetails([{ ItemCode: '', Quantity: 1 }, { ItemCode: 'X', Quantity: 1 }])).toEqual([]); + expect(validateLineDetails([{ ItemCode: '', Quantity: 1 }, { ItemCode: 'SU01', Quantity: 1, _manageBatches: true }])).toEqual(['Línea 1: indique los lotes de SU01.']); + }); + }); + + describe('qué documentos los llevan', () => { + const lines = (p: string) => DOCUMENT_SCREENS.find((s) => s.path === p)!.layout; + it('entradas, salidas y transferencias de stock sí; solicitudes de traslado, de compra y ventas no', () => { + for (const p of ['/inventario/entradas', '/inventario/salidas', '/inventario/transferencias']) { + expect(lines(p).details?.map((d) => d.key), p).toEqual(['BatchNumbers', 'SerialNumbers']); + expect(lines(p).lines.fields, p).toEqual(expect.arrayContaining(['BatchNumbers', 'SerialNumbers'])); + } + for (const p of ['/inventario/solicitudes-traslado', '/compras/solicitudes', '/ventas/ordenes']) { + expect(lines(p).details, p).toBeUndefined(); + expect(lines(p).lines.fields, p).not.toContain('BatchNumbers'); + } + }); + + it('los lotes viajan dentro de la línea, en la colección que espera SAP', () => { + const entry = lines('/inventario/entradas').lines; + const body = createPayload({}, [{ ItemCode: 'SU01', Quantity: 5, WarehouseCode: '01', _manageBatches: true, BatchNumbers: [{ BatchNumber: 'L1', Quantity: 5, ExpiryDate: '2027-01-01' }] }], entry); + expect(body['DocumentLines']).toEqual([ + { ItemCode: 'SU01', Quantity: 5, WarehouseCode: '01', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 5, ExpiryDate: '2027-01-01' }] }, + ]); + const transfer = createPayload({}, [{ ItemCode: 'A6', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02', SerialNumbers: [{ InternalSerialNumber: 'S1' }] }], lines('/inventario/transferencias').lines); + expect(transfer['StockTransferLines']).toEqual([ + { ItemCode: 'A6', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02', SerialNumbers: [{ InternalSerialNumber: 'S1', Quantity: 1 }] }, + ]); + }); + + it('la marca interna de «gestiona lotes» nunca se envía', () => { + const body = createPayload({}, [{ ItemCode: 'A', Quantity: 1, WarehouseCode: '01', _manageBatches: true, _manageSerials: false }], lines('/inventario/entradas').lines); + expect(JSON.stringify(body)).not.toContain('_manage'); + }); + }); +}); diff --git a/apps/web/src/app/features/documents/line-details.ts b/apps/web/src/app/features/documents/line-details.ts new file mode 100644 index 0000000..ec294b7 --- /dev/null +++ b/apps/web/src/app/features/documents/line-details.ts @@ -0,0 +1,114 @@ +import type { DetailColumn, GridDetail, GridLine } from '../../shared/grid/grid.types'; + +type Row = Record; + +const num = (v: unknown): number => { + const n = Number(v ?? 0); + return Number.isFinite(n) ? n : 0; +}; +const rowsOf = (line: GridLine, key: string): Row[] => (Array.isArray(line[key]) ? (line[key] as Row[]) : []); +const sum = (rows: readonly Row[]): number => rows.reduce((total, r) => total + num(r['Quantity']), 0); +const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`; + +const batchColumns: readonly DetailColumn[] = [ + { key: 'BatchNumber', title: 'Lote', type: 'text', required: true, width: '170px' }, + { key: 'Quantity', title: 'Cantidad', type: 'number', required: true, width: '100px' }, + { key: 'ExpiryDate', title: 'Vencimiento', type: 'date', width: '130px' }, + { key: 'ManufacturingDate', title: 'Fabricación', type: 'date', width: '130px' }, +]; + +const serialColumns: readonly DetailColumn[] = [ + { key: 'InternalSerialNumber', title: 'Nº de serie', type: 'text', required: true, width: '170px' }, + { key: 'ManufacturerSerialNumber', title: 'Serie del fabricante', type: 'text', width: '170px' }, + { key: 'ExpiryDate', title: 'Vencimiento', type: 'date', width: '130px' }, + { key: 'ManufacturingDate', title: 'Fabricación', type: 'date', width: '130px' }, +]; + +/** Lotes de una línea (`BatchNumbers`): aplica si el artículo se gestiona por lote o la línea ya trae lotes. */ +export const BATCH_DETAIL: GridDetail = { + key: 'BatchNumbers', + title: 'Lotes', + applies: (l) => l['_manageBatches'] === true || rowsOf(l, 'BatchNumbers').length > 0, + columns: batchColumns, + summary: (l) => { + const rows = rowsOf(l, 'BatchNumbers'); + return rows.length ? `${plural(rows.length, 'lote', 'lotes')} · ${sum(rows)}` : 'Indicar lotes'; + }, +}; + +/** Números de serie de una línea (`SerialNumbers`): uno por unidad. */ +export const SERIAL_DETAIL: GridDetail = { + key: 'SerialNumbers', + title: 'Números de serie', + applies: (l) => l['_manageSerials'] === true || rowsOf(l, 'SerialNumbers').length > 0, + columns: serialColumns, + summary: (l) => { + const rows = rowsOf(l, 'SerialNumbers'); + return rows.length ? plural(rows.length, 'serie', 'series') : 'Indicar series'; + }, +}; + +export const LINE_DETAILS: readonly GridDetail[] = [BATCH_DETAIL, SERIAL_DETAIL]; +export const DETAIL_KEYS: readonly string[] = LINE_DETAILS.map((d) => d.key); + +/** Aviso bajo la tabla de la ventana de lotes o series, según lo que lleva la línea y las filas en edición. */ +export function detailHint(detail: GridDetail, line: GridLine, rows: readonly Row[]): { text: string; bad: boolean } { + const expected = num(line['Quantity']); + if (detail.key === 'SerialNumbers') { + const filled = rows.filter((r) => String(r['InternalSerialNumber'] ?? '').trim() !== '').length; + return { text: `Series: ${filled} de ${expected}`, bad: filled !== expected }; + } + const total = sum(rows); + return { text: `Cantidad de la línea: ${expected} · los lotes suman: ${total}`, bad: total !== expected }; +} + +/** Campos de cada fila que se envían a SAP. */ +const DETAIL_FIELDS: Readonly> = { + BatchNumbers: ['BatchNumber', 'Quantity', 'ExpiryDate', 'ManufacturingDate'], + SerialNumbers: ['InternalSerialNumber', 'ManufacturerSerialNumber', 'ExpiryDate', 'ManufacturingDate'], +}; + +/** + * Filas listas para SAP: solo los campos propios del detalle (no los que SAP agrega al leer, como + * `SystemSerialNumber`), sin vacíos, y sin filas sin lote o sin serie. Cada serie lleva `Quantity: 1`. + */ +export function detailRowsPayload(key: string, rows: unknown): Row[] { + const fields = DETAIL_FIELDS[key]; + if (!fields || !Array.isArray(rows)) return []; + const id = key === 'SerialNumbers' ? 'InternalSerialNumber' : 'BatchNumber'; + return (rows as Row[]) + .filter((r) => String(r[id] ?? '').trim() !== '') + .map((r) => { + const out: Row = {}; + for (const f of fields) { + const v = r[f]; + if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) continue; + out[f] = typeof v === 'string' && /T00:00:00/.test(v) ? v.slice(0, 10) : v; + } + if (key === 'SerialNumbers') out['Quantity'] = 1; + return out; + }); +} + +/** + * Errores de lotes y series que se pueden detectar sin preguntarle a SAP: una línea con artículo por lote + * necesita lotes que sumen su cantidad, y por serie, una serie por unidad. + */ +export function validateLineDetails(lines: readonly GridLine[]): string[] { + const errors: string[] = []; + lines + .filter((l) => String(l['ItemCode'] ?? '').trim() !== '') + .forEach((l, i) => { + const quantity = num(l['Quantity']); + if (BATCH_DETAIL.applies(l)) { + const rows = detailRowsPayload('BatchNumbers', l['BatchNumbers']); + if (!rows.length) errors.push(`Línea ${i + 1}: indique los lotes de ${String(l['ItemCode'])}.`); + else if (sum(rows) !== quantity) errors.push(`Línea ${i + 1}: los lotes suman ${sum(rows)} y la cantidad es ${quantity}.`); + } + if (SERIAL_DETAIL.applies(l)) { + const rows = detailRowsPayload('SerialNumbers', l['SerialNumbers']); + if (rows.length !== quantity) errors.push(`Línea ${i + 1}: indique ${quantity} número(s) de serie de ${String(l['ItemCode'])} (hay ${rows.length}).`); + } + }); + return errors; +} diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts index c9a845f..854e78e 100644 --- a/apps/web/src/app/shared/grid/grid.types.ts +++ b/apps/web/src/app/shared/grid/grid.types.ts @@ -43,3 +43,30 @@ export interface GridMultiAdd { lookup: VhLookup; toLine: (picked: ListRow) => GridLine; } + +/** Una columna de las filas de un detalle de línea (lotes, series). */ +export interface DetailColumn { + key: string; + title: string; + type: 'text' | 'number' | 'date'; + width?: string; + required?: boolean; +} + +/** + * Detalle de una línea: una lista de filas propias de la línea (los lotes o los números de serie de un + * artículo) que se edita en una ventana aparte, abierta desde un botón de la grilla. + */ +export interface GridDetail { + /** Campo de la línea que guarda las filas (`BatchNumbers`, `SerialNumbers`). */ + key: string; + /** Título de la ventana y de la columna de la grilla. */ + title: string; + /** Si el detalle aplica a esta línea (p. ej. el artículo se gestiona por lote). */ + applies: (line: GridLine) => boolean; + columns: readonly DetailColumn[]; + /** Texto del botón de la grilla ("2 lotes · 10"). */ + summary: (line: GridLine) => string; + /** Aviso bajo la tabla ("La suma de las cantidades debe ser 10"). */ + hint?: (line: GridLine) => string; +} diff --git a/apps/web/src/app/shared/grid/line-detail-dialog.ts b/apps/web/src/app/shared/grid/line-detail-dialog.ts new file mode 100644 index 0000000..2adb1b6 --- /dev/null +++ b/apps/web/src/app/shared/grid/line-detail-dialog.ts @@ -0,0 +1,161 @@ +import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } from '@angular/core'; +import { formatNumber, parseDecimal } from '../../core/format'; +import type { DetailColumn } from './grid.types'; + +type Row = Record; + +/** `2026-01-05T00:00:00Z` → `2026-01-05` (el formato del selector de fecha). */ +const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : ''); + +/** + * Ventana con las filas de un detalle de línea (lotes, números de serie): tabla editable con «Añadir fila» + * y «Quitar». Solo emite `saved` al aceptar; cancelar descarta los cambios. + */ +@Component({ + selector: 'sbo-line-detail-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ +
+ `, + styles: ` + .overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--overlay); } + .dlg { display: flex; flex-direction: column; width: min(720px, 100%); max-height: 80vh; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; box-shadow: var(--shadow-pop); } + header { display: flex; align-items: baseline; gap: 10px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); } + h2 { margin: 0; font-size: 14px; font-weight: 600; } + .sub { font-size: 12px; color: var(--quiet); } + .body { overflow: auto; padding: 8px 12px; } + table { width: 100%; border-collapse: collapse; } + th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; } + th.req::after { content: ' *'; color: var(--err); } + td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); } + td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); } + td.del, th.del { width: 30px; text-align: center; } + input { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); } + input.num { text-align: right; } + input:hover:not([readonly]) { border-color: var(--field-b); } + input:focus { background: var(--field); border-color: var(--focus); outline: none; } + td.del button { padding: 2px 6px; background: none; border: none; color: var(--quiet); font-size: 15px; line-height: 1; cursor: pointer; border-radius: var(--radius); } + td.del button:hover { color: var(--err); background: var(--err-bg); } + tr.none td { padding: 14px; text-align: center; color: var(--quiet); } + .hint { margin: 0; padding: 6px 12px; font-size: 12px; color: var(--quiet); border-top: 1px solid var(--line-soft); } + .hint.bad { color: var(--err); } + footer { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--line); background: var(--panel); } + .sp { flex: 1; } + `, +}) +export class LineDetailDialog { + readonly title = input.required(); + readonly subtitle = input(''); + readonly columns = input.required(); + readonly initial = input([]); + readonly readonly = input(false); + /** Texto bajo la tabla, calculado por quien abre la ventana con las filas en edición. */ + readonly hintFor = input<((rows: readonly Row[]) => { text: string; bad: boolean }) | undefined>(undefined); + + readonly saved = output(); + readonly closed = output(); + + protected readonly rows = linkedSignal(() => this.initial().map((r) => ({ ...r }))); + protected readonly hintState = computed(() => this.hintFor()?.(this.rows()) ?? { text: '', bad: false }); + protected readonly hint = computed(() => this.hintState().text); + protected readonly bad = computed(() => this.hintState().bad); + + protected text(row: Row, c: DetailColumn): string { + const v = row[c.key]; + if (c.type === 'date') return dateOnly(v); + if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, undefined); + return v === null || v === undefined ? '' : String(v); + } + + protected add(): void { + this.rows.update((rs) => [...rs, {}]); + } + + protected remove(index: number): void { + this.rows.update((rs) => rs.filter((_, i) => i !== index)); + } + + protected set(index: number, c: DetailColumn, input: HTMLInputElement): void { + const raw = input.value; + let value: unknown = raw; + if (c.type === 'number') { + const parsed = raw.trim() === '' ? null : parseDecimal(raw); + if (raw.trim() !== '' && parsed === null) { + input.value = this.text(this.rows()[index], c); // no es un número: se conserva el anterior + return; + } + value = parsed; + } + this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r))); + } + + /** Las filas sin ningún dato no se guardan. */ + protected clean(): Row[] { + return this.rows().filter((r) => this.columns().some((c) => r[c.key] !== null && r[c.key] !== undefined && r[c.key] !== '')); + } +} diff --git a/apps/web/src/app/shared/grid/lines-grid.css b/apps/web/src/app/shared/grid/lines-grid.css index 653ba42..c1b93b5 100644 --- a/apps/web/src/app/shared/grid/lines-grid.css +++ b/apps/web/src/app/shared/grid/lines-grid.css @@ -102,6 +102,26 @@ td.ro { .pick button:hover { background: var(--btn-h); } +th.det, +td.det { + width: 130px; + padding: 0 4px; + text-align: center; +} +.detbtn { + width: 100%; + height: 22px; + padding: 0 6px; + background: var(--btn); + border: 1px solid var(--field-b); + border-radius: var(--radius); + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} +.detbtn:hover { + background: var(--btn-h); +} td.del, th.del { width: 30px; diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html index 09994af..05bda82 100644 --- a/apps/web/src/app/shared/grid/lines-grid.html +++ b/apps/web/src/app/shared/grid/lines-grid.html @@ -17,6 +17,9 @@ @for (c of columns(); track c.key) { {{ c.title }} } + @if (hasDetails()) { + Lotes / Series + } @if (!readonly()) { Quitar } @@ -67,6 +70,13 @@ } } + @if (hasDetails()) { + + @for (d of detailsOf(line); track d.key) { + + } + + } @if (!readonly()) { @@ -75,7 +85,7 @@ } @empty { - + {{ readonly() ? 'El documento no tiene líneas.' : 'No hay líneas. Pulse «Añadir línea» para comenzar.' }} @@ -84,6 +94,19 @@
+@if (detailTarget(); as t) { + +} + @if (lookup(); as cfg) { { expect(cells).toEqual(['left', 'right', 'right', 'center', 'left']); }); }); + +describe('LinesGrid: detalles de línea (lotes)', () => { + const BATCHES = { + key: 'BatchNumbers', + title: 'Lotes', + applies: (l: GridLine) => l['_manageBatches'] === true || (Array.isArray(l['BatchNumbers']) && l['BatchNumbers'].length > 0), + columns: [ + { key: 'BatchNumber', title: 'Lote', type: 'text' as const }, + { key: 'Quantity', title: 'Cantidad', type: 'number' as const }, + ], + summary: (l: GridLine) => (Array.isArray(l['BatchNumbers']) && l['BatchNumbers'].length ? `${l['BatchNumbers'].length} lote(s)` : 'Indicar lotes'), + }; + + @Component({ + imports: [LinesGrid], + template: ``, + }) + class DetailHost { + columns = [{ key: 'ItemCode', title: 'Artículo' }, { key: 'Quantity', title: 'Cantidad', type: 'number' as const }]; + lines = signal([]); + readonly = signal(false); + newLine = () => ({}); + details = [BATCHES]; + } + + async function make(lines: GridLine[], readonly = false) { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ imports: [DetailHost], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents(); + const fixture = TestBed.createComponent(DetailHost); + fixture.componentInstance.lines.set(lines); + fixture.componentInstance.readonly.set(readonly); + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + await tick(); + return { fixture, el: fixture.nativeElement as HTMLElement, tick }; + } + + it('solo las líneas a las que aplica el detalle muestran su botón', async () => { + const { el } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }, { ItemCode: 'A1', Quantity: 1 }]); + const buttons = [...el.querySelectorAll('.detbtn')]; + expect(buttons.map((b) => b.textContent?.trim())).toEqual(['Indicar lotes']); + expect(el.querySelector('th.det')?.textContent).toContain('Lotes / Series'); + }); + + it('el botón abre la ventana, se añaden filas y al aceptar quedan en la línea', async () => { + const { fixture, el, tick } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }]); + (el.querySelector('.detbtn') as HTMLElement).click(); + await tick(); + expect(el.querySelector('sbo-line-detail-dialog [role=dialog]')).toBeTruthy(); + (el.querySelector('sbo-line-detail-dialog footer .btn') as HTMLElement).click(); // + Añadir fila + await tick(); + const inputs = el.querySelectorAll('sbo-line-detail-dialog tbody input'); + inputs[0].value = 'LT-9'; + inputs[0].dispatchEvent(new Event('change')); + inputs[1].value = '5'; + inputs[1].dispatchEvent(new Event('change')); + await tick(); + (el.querySelector('sbo-line-detail-dialog footer .btn.main') as HTMLElement).click(); // Aceptar + await tick(); + expect(el.querySelector('sbo-line-detail-dialog')).toBeNull(); + expect(fixture.componentInstance.lines()[0]['BatchNumbers']).toEqual([{ BatchNumber: 'LT-9', Quantity: 5 }]); + expect(el.querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote(s)'); + }); + + it('cancelar descarta los cambios', async () => { + const { fixture, el, tick } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }]); + (el.querySelector('.detbtn') as HTMLElement).click(); + await tick(); + (el.querySelector('sbo-line-detail-dialog footer .btn') as HTMLElement).click(); + await tick(); + const cancel = [...el.querySelectorAll('sbo-line-detail-dialog footer .btn')].find((b) => b.textContent?.trim() === 'Cancelar')!; + cancel.click(); + await tick(); + expect(fixture.componentInstance.lines()[0]['BatchNumbers']).toBeUndefined(); + }); + + it('en solo lectura se ven los lotes pero no se editan', async () => { + const { el, tick } = await make([{ ItemCode: 'SU01', Quantity: 3, BatchNumbers: [{ BatchNumber: 'LT001', Quantity: 3 }] }], true); + expect(el.querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote(s)'); + (el.querySelector('.detbtn') as HTMLElement).click(); + await tick(); + const input = el.querySelector('sbo-line-detail-dialog tbody input')!; + expect(input.value).toBe('LT001'); + expect(input.readOnly).toBe(true); + expect(el.querySelector('sbo-line-detail-dialog footer .btn.main')).toBeNull(); + }); +}); diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts index 43bd02b..29ed822 100644 --- a/apps/web/src/app/shared/grid/lines-grid.ts +++ b/apps/web/src/app/shared/grid/lines-grid.ts @@ -16,7 +16,8 @@ import { SlLookupApi } from '../../core/sl/sl-lookup'; import type { ListRow } from '../list/list.types'; import { ColResize } from '../table/col-resize'; import { ValueHelp } from '../value-help/value-help'; -import type { GridColumn, GridLine, GridMultiAdd } from './grid.types'; +import type { GridColumn, GridDetail, GridLine, GridMultiAdd } from './grid.types'; +import { LineDetailDialog } from './line-detail-dialog'; interface LookupTarget { row: number; @@ -31,7 +32,7 @@ interface LookupTarget { @Component({ selector: 'sbo-lines-grid', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ValueHelp, ColResize], + imports: [ValueHelp, ColResize, LineDetailDialog], templateUrl: './lines-grid.html', styleUrl: './lines-grid.css', }) @@ -45,6 +46,10 @@ export class LinesGrid { /** Recalcula la línea tras un cambio (ej. total = cantidad × precio). */ readonly recalc = input<((line: GridLine, changedKey: string) => GridLine) | undefined>(undefined); readonly multiAdd = input(); + /** Detalles de línea (lotes, series): una columna de botones que abre su ventana de edición. */ + readonly details = input([]); + /** Dado el detalle y las filas en edición, el aviso bajo la tabla de la ventana (ej. «suman 8 de 10»). */ + readonly detailHint = input<((detail: GridDetail, line: GridLine, rows: readonly Record[]) => { text: string; bad: boolean }) | undefined>(undefined); readonly addLabel = input('Añadir línea'); /** Avisos de la grilla (ej. un código escrito que no existe), para la barra de estado del formulario. */ readonly notice = output(); @@ -53,8 +58,22 @@ export class LinesGrid { private readonly injector = inject(Injector); protected readonly lookupTarget = signal(null); + protected readonly detailTarget = signal<{ row: number; detail: GridDetail } | null>(null); + protected readonly detailHintFn = computed(() => { + const t = this.detailTarget(); + const hint = this.detailHint(); + const line = t ? this.lines()[t.row] : undefined; + return t && hint && line ? (rows: readonly Record[]) => hint(t.detail, line, rows) : undefined; + }); protected readonly multiOpen = signal(false); protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config); + protected readonly hasDetails = computed(() => this.details().length > 0); + /** Filas con que se abre la ventana (estable mientras no cambie la línea: la ventana edita una copia). */ + protected readonly detailInitial = computed[]>(() => { + const t = this.detailTarget(); + const line = t ? this.lines()[t.row] : undefined; + return (line?.[t?.detail.key ?? ''] as Record[] | undefined) ?? []; + }); /** * Ancho mínimo de la tabla: la suma de los anchos de sus columnas (las que no declaran uno, como la @@ -66,7 +85,7 @@ export class LinesGrid { const w = parseFloat(c.width ?? ''); return Number.isFinite(w) ? w : 220; }; - return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30)); + return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30) + (this.hasDetails() ? 130 : 0)); }); protected readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple); @@ -231,6 +250,24 @@ export class LinesGrid { if (!focus()) afterNextRender(() => void focus(), { injector: this.injector }); } + // --- detalles de línea (lotes, series) ---------------------------------------------------- + + /** Los detalles que aplican a una línea. */ + protected detailsOf(line: GridLine): readonly GridDetail[] { + return this.details().filter((d) => d.applies(line)); + } + + protected openDetail(row: number, detail: GridDetail): void { + this.detailTarget.set({ row, detail }); + } + + protected detailSaved(rows: Record[]): void { + const t = this.detailTarget(); + if (!t) return; + this.lines.update((ls) => ls.map((l, i) => (i === t.row ? { ...l, [t.detail.key]: rows } : l))); + this.detailTarget.set(null); + } + // --- listas de valores ----------------------------------------------------------------- protected openLookup(row: number, column: GridColumn): void {