From 91d5135dc76725436381ce89a5280e4ee2de7b53 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 20 Oct 2026 07:59:22 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Lotes=20(datos=20maestros=20de=20invent?= =?UTF-8?q?ario):=20lista=20y=20formulario=20de=20BatchNumberDetails=20con?= =?UTF-8?q?=20clave=20num=C3=A9rica=20y=20sin=20=C2=ABNuevo=C2=BB?= 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 +- apps/api/src/sl/sl-resources.spec.ts | 7 ++ apps/api/src/sl/sl-resources.ts | 2 + apps/web/src/app/core/sl/sl-master.ts | 6 +- .../masters/lotes/lotes.extensions.ts | 17 +++ .../features/masters/lotes/lotes.screens.ts | 75 ++++++++++++ .../app/features/masters/lotes/lotes.spec.ts | 107 ++++++++++++++++++ .../features/masters/master-form.page.html | 4 +- .../app/features/masters/master-form.page.ts | 10 +- .../app/features/masters/master-list.page.ts | 2 +- .../app/features/masters/master-model.spec.ts | 4 +- .../src/app/features/masters/master-model.ts | 3 +- .../src/app/features/masters/master-paths.ts | 2 +- .../app/features/masters/master-screens.ts | 5 +- .../src/app/features/masters/master-types.ts | 9 ++ libs/sap-types/src/menu.ts | 1 + 16 files changed, 244 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/features/masters/lotes/lotes.extensions.ts create mode 100644 apps/web/src/app/features/masters/lotes/lotes.screens.ts create mode 100644 apps/web/src/app/features/masters/lotes/lotes.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5b44cce..7650d69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ Pendiente, en orden: **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). **Redondeo** (`Rounding`, `RoundingDiffAmount`): fila «Redondeo» (casilla + importe) en el pie de ventas y compras, después de «Gastos adicionales» y antes del impuesto (como en SAP; ver `tools/factura.png`). Los campos siempre existen en el documento (se leen y se conservan); la fila solo se ve si la empresa usa redondeo por moneda (`OADM.RoundMthd`, `RoundingMethod = tYES` en el Service Layer; confirmado con el usuario; por documento no se muestra). Esa bandera y el separador de segmentos de las cuentas salen de `GET /api/company/settings` (backend: `CompanyService`, lee `CompanyService_GetAdminInfo` y entrega solo esos dos campos, con caché de 10 min). El importe solo cuenta con la casilla marcada; sin marcarla no se envía nada y SAP calcula el suyo. **Facturas y notas de crédito** (`billing: true`, `BILLING_DOCUMENT` en `screen-kit.ts`): el pie suma «Anticipo total» (`DownPaymentAmount`, tras el descuento), «Importe aplicado» (`PaidToDate`) y «Saldo pendiente» (`DocTotal − PaidToDate`, 0 en un documento nuevo), de solo lectura, como en SAP. **Gastos adicionales** hechos: tabla «Gastos adicionales» en el pie de ventas y compras (una fila por gasto: código con su ventana de selección sobre `AdditionalExpenses`, nombre, importe, impuesto y comentarios; colección `DocumentAdditionalExpenses`). Se suman al total con su propio impuesto (`computeTotals`); al modificar, si cambian las filas se envía la colección completa con `B1S-ReplaceCollectionsOnPatch` (quitarlas todas envía `[]`); `expenses: false` en `marketing(...)` la quita de un documento; las extensiones pueden ocultarla con el nombre `DocumentAdditionalExpenses`. Falta: botón PDF. En ventas un gasto adicional sin cuenta de ingresos falla en SBODemoCL ("Define the freight revenue account…"; 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). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy. +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). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. **Lotes** (`/inventario/lotes`, `masters/lotes/`, recurso `BatchNumberDetails`, solo campos estándar): lista (lote, artículo, descripción, vencimiento, estado; filtros de estado y de vencimiento) y formulario con estado (Liberado / Bloqueado / No accesible), atributos 1 y 2, fechas de admisión, fabricación y vencimiento y detalles. SAP crea los lotes desde los documentos, así que **no hay «Nuevo»**: el recurso admite solo `GET` y `PATCH`. Para esto el framework de maestros ganó `keyType: 'number'` (la clave es `DocEntry`; la navegación primero/anterior/siguiente/último no la pone entre comillas), `creatable: false` (sin botón «Nuevo»; `/nuevo` vuelve a la lista), `titleField` (el título muestra el número de lote, no la clave) y `badge` (la insignia sale del estado del lote). Los campos de usuario de cada cliente van en `lotes.extensions.ts` (vacío en `main`). Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy. 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). 4. **Auditoría — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log. 5. ~~Migrar reportes/PDF vía API Gateway~~ — hecho (ver «Reportes Gateway» arriba; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro). **Pendiente, por cliente: reportes sencillos con plantilla HTML** (decidido con el usuario, no se construye en `main`; solo lo pide la rama de cada cliente). En el mini-erp era una sola plantilla (`mini-backend/templates/reports/stickers.html`, Jinja2 + WeasyPrint: `@page`, CSS grid, `page-break-after`, fuente propia en `fonts/`). Equivalente previsto: **Nunjucks** (misma sintaxis `{{ }}` / `{% for %}`; la plantilla se porta casi igual) + **Puppeteer** (Chrome headless, `page.pdf()`; soporta mejor `@page` y grid). Diseño: módulo genérico `apps/api/src/templates/` (renderiza y genera el PDF; cola de 1-2 con un solo navegador abierto; datos por el Service Layer) y las plantillas en `templates//.njk` con su `fonts/`, dentro de la rama del cliente. Falta decidir cómo se elige una plantilla: botón PDF de un documento (recibe el `DocEntry`) o reporte más en «Reportes» con parámetros (encaja mejor en listados como los stickers). Si basta un documento simple, **pdfmake** evita Chrome (~150 MB en el despliegue). Aparte queda el botón PDF de cada documento con Crystal vía Gateway (falta el código de reporte de cada tipo). diff --git a/apps/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts index d8a3ad7..f74ee10 100644 --- a/apps/api/src/sl/sl-resources.spec.ts +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -85,6 +85,13 @@ describe('recursos del Service Layer', () => { for (const name of ['ShippingTypes', 'WizardPaymentMethods']) expect(SL_RESOURCE_MAP.get(name)!.methods, name).toEqual(['GET']); }); + it('los lotes se consultan y se modifican, pero no se crean desde aquí (nacen en los documentos)', () => { + const r = SL_RESOURCE_MAP.get('BatchNumberDetails')!; + expect(r.key).toBe('number'); + expect(r.methods).toEqual(['GET', 'PATCH']); + expect(r.write).toBe('/inventario/lotes'); + }); + it('los usuarios de SAP solo entregan los campos que necesita el solicitante', () => { const r = SL_RESOURCE_MAP.get('Users')!; expect(r.methods).toEqual(['GET']); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index 4c6c1d6..5d2d546 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -97,6 +97,8 @@ export const SL_RESOURCES: readonly SlResource[] = [ // Maestros (consulta para cualquier sesión; escritura según el módulo) catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']), catalog('Items', 'string', '/inventario/articulos'), + // Datos maestros de lotes: SAP los crea desde los documentos (entradas, recepciones…), aquí solo se consultan y modifican. + { name: 'BatchNumberDetails', key: 'number', methods: ['GET', 'PATCH'], read: null, write: '/inventario/lotes' }, catalog('Warehouses', 'string'), catalog('Currencies', 'string'), catalog('SalesTaxCodes', 'string'), diff --git a/apps/web/src/app/core/sl/sl-master.ts b/apps/web/src/app/core/sl/sl-master.ts index ce20252..138822d 100644 --- a/apps/web/src/app/core/sl/sl-master.ts +++ b/apps/web/src/app/core/sl/sl-master.ts @@ -18,14 +18,14 @@ export class SlMasterApi { } /** Clave del registro vecino (primero / anterior / siguiente / último), o `null` si no hay. */ - async neighbour(resource: string, keyField: string, target: MasterNavTarget, from: string | null, baseFilter?: string): Promise { - const q = masterNavigationQuery(target, keyField, from); + async neighbour(resource: string, keyField: string, target: MasterNavTarget, from: string | null, baseFilter?: string, numeric = false): Promise { + const q = masterNavigationQuery(target, keyField, from, numeric); let params = new HttpParams().set('$select', keyField).set('$top', 1).set('$orderby', q.$orderby); const filter = combineFilters(baseFilter, q.$filter); if (filter) params = params.set('$filter', filter); const res = await firstValueFrom(this.http.get>(this.url(resource), { params })); const key = res.value[0]?.[keyField]; - return typeof key === 'string' ? key : null; + return typeof key === 'string' ? key : typeof key === 'number' ? String(key) : null; } /** Crea el registro; SAP devuelve el creado. */ diff --git a/apps/web/src/app/features/masters/lotes/lotes.extensions.ts b/apps/web/src/app/features/masters/lotes/lotes.extensions.ts new file mode 100644 index 0000000..78c9f32 --- /dev/null +++ b/apps/web/src/app/features/masters/lotes/lotes.extensions.ts @@ -0,0 +1,17 @@ +import type { MasterExtensions } from '../master-extensions'; + +/** + * Campos que este cliente agrega a la pantalla de lotes. En `main` va vacío: el proyecto base solo lleva campos + * estándar de SAP. La rama `cliente/` lo completa, por ruta de pantalla, con campos estándar adicionales o de + * usuario (`U_*`) y no toca ningún otro archivo. + * + * Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura + * (`readOnlyFields`). Un nombre que no exista, o ocultar un campo obligatorio, lanza un error al arrancar. + * + * Ejemplo (los campos son de ejemplo): + * + * '/inventario/lotes': { + * newTabs: [{ id: 'calidad', label: 'Calidad', left: [{ type: 'text', key: 'U_Ubicacion', label: 'Ubicación' }], right: [] }], + * }, + */ +export const LOTES_EXTENSIONS: MasterExtensions = {}; diff --git a/apps/web/src/app/features/masters/lotes/lotes.screens.ts b/apps/web/src/app/features/masters/lotes/lotes.screens.ts new file mode 100644 index 0000000..2b4f250 --- /dev/null +++ b/apps/web/src/app/features/masters/lotes/lotes.screens.ts @@ -0,0 +1,75 @@ +import type { SelectFilter } from '../../../core/sl/list-filters'; +import type { HeaderField } from '../../documents/document-layouts'; +import type { MasterScreen } from '../master-types'; + +const field = (f: HeaderField): HeaderField => f; + +/** Estado del lote (`BatchNumberDetailStatus`). */ +const STATUS = [ + { value: 'bdsStatus_Released', label: 'Liberado' }, + { value: 'bdsStatus_Locked', label: 'Bloqueado' }, + { value: 'bdsStatus_NotAccessible', label: 'No accesible' }, +] as const; + +const statusLabel = (v: unknown): string => STATUS.find((s) => s.value === v)?.label ?? String(v ?? ''); +const statusTone = (v: unknown) => (v === 'bdsStatus_Released' ? ('ok' as const) : v === 'bdsStatus_Locked' ? ('warn' as const) : ('muted' as const)); + +const statusFilter: SelectFilter = { type: 'select', key: 'Status', label: 'Estado', options: STATUS }; + +/** + * Lotes (`BatchNumberDetails`): datos maestros del lote de un artículo (estado, atributos, fechas). SAP crea los lotes + * desde los documentos (entradas, recepciones…), así que aquí solo se consultan y se modifican. Solo campos estándar. + */ +export const LOTES_SCREENS: readonly MasterScreen[] = [ + { + path: '/inventario/lotes', + resource: 'BatchNumberDetails', + keyField: 'DocEntry', + keyType: 'number', + creatable: false, + titleField: 'Batch', + singular: 'Lote', + createLabel: '', + orderby: 'DocEntry desc', + extraFields: ['DocEntry', 'Status'], + badge: (d) => (d['Status'] ? { label: statusLabel(d['Status']), tone: statusTone(d['Status']) } : undefined), + columns: [ + { key: 'Batch', title: 'Lote', width: '160px', mono: true, searchable: true }, + { key: 'ItemCode', title: 'Nº de artículo', width: '170px', mono: true, searchable: true }, + { key: 'ItemDescription', title: 'Descripción', searchable: true }, + { key: 'ExpirationDate', title: 'Vencimiento', type: 'date', width: '110px' }, + { key: 'Status', title: 'Estado', width: '110px', format: (r) => statusLabel(r['Status']), tone: (r) => statusTone(r['Status']) }, + ], + filters: [statusFilter, { type: 'date', key: 'ExpirationDate', label: 'Vencimiento' }], + layout: { + header: { + left: [ + field({ type: 'text', key: 'Batch', label: 'Lote', immutable: true }), + field({ type: 'text', key: 'ItemCode', label: 'Nº de artículo', immutable: true }), + field({ type: 'text', key: 'ItemDescription', label: 'Descripción', immutable: true }), + ], + right: [ + field({ type: 'select', key: 'Status', label: 'Estado', options: STATUS }), + field({ type: 'text', key: 'DocEntry', label: 'Número interno', immutable: true }), + ], + }, + tabs: [ + { + id: 'general', + label: 'General', + left: [ + field({ type: 'text', key: 'BatchAttribute1', label: 'Atributo 1' }), + field({ type: 'text', key: 'BatchAttribute2', label: 'Atributo 2' }), + ], + right: [ + field({ type: 'date', key: 'AdmissionDate', label: 'Fecha de admisión' }), + field({ type: 'date', key: 'ManufacturingDate', label: 'Fecha de fabricación' }), + field({ type: 'date', key: 'ExpirationDate', label: 'Fecha de vencimiento' }), + ], + }, + { id: 'detalles', label: 'Detalles', left: [field({ type: 'textarea', key: 'Details', label: 'Detalles' })], right: [] }, + ], + newHeader: () => ({}), + }, + }, +]; diff --git a/apps/web/src/app/features/masters/lotes/lotes.spec.ts b/apps/web/src/app/features/masters/lotes/lotes.spec.ts new file mode 100644 index 0000000..c17a570 --- /dev/null +++ b/apps/web/src/app/features/masters/lotes/lotes.spec.ts @@ -0,0 +1,107 @@ +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 { MasterFormPage } from '../master-form.page'; +import { masterNavigationQuery } from '../master-model'; +import { BASE_MASTER_SCREENS } from '../master-screens'; + +const lote = (over: Record = {}) => ({ + 'odata.etag': 'W/"2"', + DocEntry: 74701, + Batch: '2090456', + ItemCode: '10101070119011602', + ItemDescription: 'CATGUT SIMPLE 6/0', + Status: 'bdsStatus_Released', + BatchAttribute1: 'PROV-1', + BatchAttribute2: null, + AdmissionDate: '2026-01-05', + ManufacturingDate: '2025-12-01', + ExpirationDate: '2028-12-01', + Details: null, + ...over, +}); + +describe('Lotes (BatchNumberDetails)', () => { + const screen = BASE_MASTER_SCREENS.find((s) => s.path === '/inventario/lotes')!; + 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(); + }; + + async function open(key: string | null, doc?: Record) { + const params = key === null ? {} : { key }; + const route = { snapshot: { data: { screen, title: screen.singular }, paramMap: convertToParamMap(params) }, paramMap: of(convertToParamMap(params)) }; + await TestBed.configureTestingModule({ + imports: [MasterFormPage], + 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(MasterFormPage); + await tick(); + if (key !== null) ctrl.expectOne(`/api/sl/BatchNumberDetails/${key}`).flush(doc ?? {}); + await tick(); + } + + beforeEach(() => TestBed.resetTestingModule()); + + it('la pantalla usa solo campos estándar, clave numérica y no se crea desde aquí', () => { + expect(screen.keyField).toBe('DocEntry'); + expect(screen.keyType).toBe('number'); + expect(screen.creatable).toBe(false); + expect(screen.columns[0].key).toBe('Batch'); + }); + + it('muestra el número de lote en el título, su estado y los datos del artículo sin poder cambiarlos', async () => { + await open('74701', lote()); + expect(el().querySelector('.title')?.textContent).toContain('Lote · 2090456'); + expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Liberado'); + const readonly = (value: string) => [...el().querySelectorAll('input')].find((i) => i.value === value)!.readOnly; + expect(readonly('2090456')).toBe(true); + expect(readonly('10101070119011602')).toBe(true); + expect(readonly('CATGUT SIMPLE 6/0')).toBe(true); + expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Detalles']); + }); + + it('un lote bloqueado se ve como tal', async () => { + await open('74701', lote({ Status: 'bdsStatus_Locked' })); + expect(el().querySelector('.badge.tone-warn')?.textContent?.trim()).toBe('Bloqueado'); + }); + + it('no hay botón «Nuevo» y la ruta /nuevo vuelve a la lista', async () => { + await open('74701', lote()); + expect(el().querySelector('sbo-toolbar')?.textContent).not.toContain('Nuevo'); + TestBed.resetTestingModule(); + await open(null); + expect(navigate).toHaveBeenCalledWith(['..'], expect.anything()); + }); + + it('modifica solo lo que cambió con PATCH a su número interno y If-Match', async () => { + await open('74701', lote()); + page().setValue('Status', 'bdsStatus_Locked'); + page().setValue('BatchAttribute2', 'Estante 4'); + await tick(); + (el().querySelector('.actions .btn.main') as HTMLElement).click(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'PATCH'); + expect(req.request.url).toBe('/api/sl/BatchNumberDetails/74701'); + expect(req.request.headers.get('If-Match')).toBe('W/"2"'); + expect(req.request.body).toEqual({ Status: 'bdsStatus_Locked', BatchAttribute2: 'Estante 4' }); + }); + + it('la navegación por la clave numérica no la pone entre comillas', () => { + expect(masterNavigationQuery('next', 'DocEntry', '74701', true)).toEqual({ $filter: 'DocEntry gt 74701', $orderby: 'DocEntry asc' }); + expect(masterNavigationQuery('prev', 'DocEntry', '74701', true)).toEqual({ $filter: 'DocEntry lt 74701', $orderby: 'DocEntry desc' }); + expect(masterNavigationQuery('next', 'CardCode', 'C1')).toEqual({ $filter: "CardCode gt 'C1'", $orderby: 'CardCode asc' }); + }); +}); diff --git a/apps/web/src/app/features/masters/master-form.page.html b/apps/web/src/app/features/masters/master-form.page.html index dff1d05..291a184 100644 --- a/apps/web/src/app/features/masters/master-form.page.html +++ b/apps/web/src/app/features/masters/master-form.page.html @@ -1,6 +1,8 @@ Lista - Nuevo + @if (creatable) { + Nuevo + } diff --git a/apps/web/src/app/features/masters/master-form.page.ts b/apps/web/src/app/features/masters/master-form.page.ts index 75146ea..3ea8462 100644 --- a/apps/web/src/app/features/masters/master-form.page.ts +++ b/apps/web/src/app/features/masters/master-form.page.ts @@ -63,6 +63,12 @@ export class MasterFormPage { initialValue: this.route.snapshot.paramMap.get('key'), }); protected readonly isNew = computed(() => this.key() === null); + /** SAP no crea este registro desde aquí (los lotes): sin botón «Nuevo» y `/nuevo` vuelve a la lista. */ + protected readonly creatable = this.screen.creatable !== false; + + constructor() { + if (!this.creatable && this.isNew()) void this.router.navigate(['..'], { relativeTo: this.route }); + } // --- datos --------------------------------------------------------------------------------- @@ -128,10 +134,12 @@ export class MasterFormPage { if (this.isNew()) return { label: 'Nuevo', tone: 'info' }; const d = this.doc(); if (!d) return undefined; + if (this.screen.badge) return this.screen.badge(d); return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' }; }); protected readonly windowTitle = computed(() => { const d = this.doc(); + if (this.screen.titleField && d) return `${this.screen.singular} · ${text(d[this.screen.titleField])}`; const code = this.screen.displayField && d ? displayCode({ Code: text(d[this.screen.keyField]), FormatCode: text(d[this.screen.displayField]) }) : d?.[this.screen.keyField]; return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`; }); @@ -189,7 +197,7 @@ export class MasterFormPage { protected async go(target: MasterNavTarget): Promise { try { - const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter); + const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter, this.screen.keyType === 'number'); if (next === null) { this.message.set({ kind: 'warn', text: 'No hay más registros en esa dirección.' }); return; diff --git a/apps/web/src/app/features/masters/master-list.page.ts b/apps/web/src/app/features/masters/master-list.page.ts index 1848e25..d3e092c 100644 --- a/apps/web/src/app/features/masters/master-list.page.ts +++ b/apps/web/src/app/features/masters/master-list.page.ts @@ -21,7 +21,7 @@ import type { MasterScreen } from './master-types'; [orderby]="screen.orderby" [baseFilter]="screen.baseFilter" [rowKey]="screen.keyField" - [createLabel]="screen.createLabel" + [createLabel]="screen.creatable === false ? '' : screen.createLabel" (open)="open($event)" (create)="create()" /> diff --git a/apps/web/src/app/features/masters/master-model.spec.ts b/apps/web/src/app/features/masters/master-model.spec.ts index f1ab409..d1c0c5b 100644 --- a/apps/web/src/app/features/masters/master-model.spec.ts +++ b/apps/web/src/app/features/masters/master-model.spec.ts @@ -89,8 +89,8 @@ describe('pantallas de maestros', () => { for (const s of BASE_MASTER_SCREENS) { for (const k of masterKeysOf(s.layout)) expect(k.startsWith('U_'), `${s.path} ${k}`).toBe(false); for (const c of s.columns) expect(c.key.startsWith('U_')).toBe(false); - expect(s.orderby).toBe(s.keyField); - expect(s.columns[0].key).toBe(s.keyField); + expect(s.orderby.split(' ')[0]).toBe(s.keyField); + expect(s.columns[0].key).toBe(s.titleField ?? s.keyField); } }); diff --git a/apps/web/src/app/features/masters/master-model.ts b/apps/web/src/app/features/masters/master-model.ts index 98c1f95..61bf5fc 100644 --- a/apps/web/src/app/features/masters/master-model.ts +++ b/apps/web/src/app/features/masters/master-model.ts @@ -43,10 +43,11 @@ export function masterNavigationQuery( target: MasterNavTarget, keyField: string, current: string | null, + numeric = false, ): { $filter?: string; $orderby: string } { const asc = `${keyField} asc`; const desc = `${keyField} desc`; - const quoted = current === null ? '' : `'${escapeODataString(current)}'`; + const quoted = current === null ? '' : numeric ? String(Number(current)) : `'${escapeODataString(current)}'`; switch (target) { case 'first': return { $orderby: asc }; diff --git a/apps/web/src/app/features/masters/master-paths.ts b/apps/web/src/app/features/masters/master-paths.ts index 0a9252b..6d46ec8 100644 --- a/apps/web/src/app/features/masters/master-paths.ts +++ b/apps/web/src/app/features/masters/master-paths.ts @@ -3,4 +3,4 @@ * configuración completa (`master-screens.ts`) se carga en diferido al abrir una. Un test comprueba que * coincide con `MASTER_SCREENS`. */ -export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/finanzas/plan-de-cuentas']; +export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/inventario/lotes', '/finanzas/plan-de-cuentas']; diff --git a/apps/web/src/app/features/masters/master-screens.ts b/apps/web/src/app/features/masters/master-screens.ts index 20349c6..fdf5c37 100644 --- a/apps/web/src/app/features/masters/master-screens.ts +++ b/apps/web/src/app/features/masters/master-screens.ts @@ -2,13 +2,15 @@ import { FINANZAS_EXTENSIONS } from './finanzas/finanzas.extensions'; import { FINANZAS_SCREENS } from './finanzas/finanzas.screens'; import { ARTICULOS_SCREENS } from './articulos/articulos.screens'; import { ARTICULOS_EXTENSIONS } from './articulos/articulos.extensions'; +import { LOTES_EXTENSIONS } from './lotes/lotes.extensions'; +import { LOTES_SCREENS } from './lotes/lotes.screens'; import { type MasterExtensions, applyMasterExtensions } from './master-extensions'; import { SOCIOS_EXTENSIONS } from './socios-negocio/socios.extensions'; import { SOCIOS_SCREENS } from './socios-negocio/socios.screens'; import type { MasterScreen } from './master-types'; /** Las pantallas de maestros del proyecto base, agrupadas por módulo (una carpeta cada uno). */ -export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...FINANZAS_SCREENS]; +export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...LOTES_SCREENS, ...FINANZAS_SCREENS]; /** Cada módulo solo puede extender sus propias pantallas: una ruta ajena es un error, no un descuido silencioso. */ function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions): MasterExtensions { @@ -22,6 +24,7 @@ function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions) export const MASTER_EXTENSIONS: MasterExtensions = { ...ownedBy(SOCIOS_SCREENS, SOCIOS_EXTENSIONS), ...ownedBy(ARTICULOS_SCREENS, ARTICULOS_EXTENSIONS), + ...ownedBy(LOTES_SCREENS, LOTES_EXTENSIONS), ...ownedBy(FINANZAS_SCREENS, FINANZAS_EXTENSIONS), }; diff --git a/apps/web/src/app/features/masters/master-types.ts b/apps/web/src/app/features/masters/master-types.ts index b382edd..7199e14 100644 --- a/apps/web/src/app/features/masters/master-types.ts +++ b/apps/web/src/app/features/masters/master-types.ts @@ -1,6 +1,7 @@ import type { ListFilter } from '../../core/sl/list-filters'; import type { DetailColumn } from '../../shared/grid/grid.types'; import type { ListColumn } from '../../shared/list/list.types'; +import type { WindowBadge } from '../../shared/window'; import type { FieldColumns } from '../documents/document-layouts'; import type { DocRecord } from '../documents/document-model'; @@ -67,6 +68,14 @@ export interface MasterScreen { resource: string; /** Campo clave del recurso (`CardCode`, `ItemCode`). */ keyField: string; + /** Tipo de la clave (`DocEntry` de un lote es numérica); por defecto, texto. */ + keyType?: 'string' | 'number'; + /** `false` si SAP no crea el registro desde aquí (los lotes nacen en los documentos): sin botón «Nuevo». */ + creatable?: boolean; + /** Campo que se muestra en el título junto al nombre en singular, en vez de la clave (el número de lote). */ + titleField?: string; + /** La insignia del estado del registro (por defecto, Activo / Inactivo según `Frozen`). */ + badge?: (doc: DocRecord) => WindowBadge | undefined; /** Campo que se muestra en el título en vez de la clave, si la tiene (`FormatCode` del plan de cuentas). */ displayField?: string; /** Nombre en singular, para el título del formulario (`Cliente`). */ diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index 71c275d..f9e8fcf 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -50,6 +50,7 @@ export const MENU: readonly MenuModule[] = [ label: 'Inventario', items: [ { path: '/inventario/articulos', label: 'Artículos' }, + { path: '/inventario/lotes', label: 'Lotes' }, { path: '/inventario/entradas', label: 'Entrada de inventario' }, { path: '/inventario/salidas', label: 'Salida de inventario' }, { path: '/inventario/transferencias', label: 'Transferencias de stock' },