From 4e9d6a757de2fadc86b7d2e47a98dcede5857403 Mon Sep 17 00:00:00 2001 From: George Date: Fri, 16 Oct 2026 12:31:27 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20tablas=20en=20los=20maestros=20(di?= =?UTF-8?q?recciones,=20personas=20de=20contacto,=20precios=20por=20lista,?= =?UTF-8?q?=20stock=20por=20almac=C3=A9n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sbo-rows-table reutilizable (también en la ventana de lotes). Direcciones y contactos se envían completos con reemplazo; los precios solo por filas cambiadas. Probado contra SBODemoCL. Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 2 +- apps/web/src/app/core/sl/sl-master.ts | 5 +- .../masters/articulos/articulos.screens.ts | 40 +++ .../masters/master-collections.spec.ts | 272 ++++++++++++++++++ .../features/masters/master-form.page.html | 13 + .../features/masters/master-form.page.spec.ts | 2 +- .../app/features/masters/master-form.page.ts | 57 +++- .../app/features/masters/master-model.spec.ts | 2 +- .../src/app/features/masters/master-model.ts | 60 +++- .../src/app/features/masters/master-types.ts | 31 +- .../masters/socios-negocio/socios.screens.ts | 55 ++++ apps/web/src/app/shared/grid/grid.types.ts | 8 +- .../src/app/shared/grid/line-detail-dialog.ts | 86 +----- apps/web/src/app/shared/grid/rows-table.ts | 142 +++++++++ 14 files changed, 679 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/app/features/masters/master-collections.spec.ts create mode 100644 apps/web/src/app/shared/grid/rows-table.ts diff --git a/CLAUDE.md b/CLAUDE.md index 2d264b0..5f34cec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,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). 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. +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. 3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`). 4. Auditoría (`audit_log` en SQLite; hoy solo líneas `AUDIT` en el log) y `nestjs-cls` para el usuario en los logs. 5. Migrar reportes/PDF vía API Gateway (módulo y cola aparte; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro). diff --git a/apps/web/src/app/core/sl/sl-master.ts b/apps/web/src/app/core/sl/sl-master.ts index c5bfc27..ce20252 100644 --- a/apps/web/src/app/core/sl/sl-master.ts +++ b/apps/web/src/app/core/sl/sl-master.ts @@ -34,8 +34,9 @@ export class SlMasterApi { } /** Modifica el registro. Con `etag`, si alguien lo cambió antes SAP responde 412 y no se pisa su cambio. */ - update(resource: string, key: string, body: Rec, etag: string | undefined): Promise { - const headers = etag ? new HttpHeaders().set('If-Match', etag) : new HttpHeaders(); + update(resource: string, key: string, body: Rec, etag: string | undefined, replaceCollections = false): Promise { + let headers = etag ? new HttpHeaders().set('If-Match', etag) : new HttpHeaders(); + if (replaceCollections) headers = headers.set('B1S-ReplaceCollectionsOnPatch', 'true'); return firstValueFrom(this.http.patch(this.url(resource, key), body, { headers })); } } diff --git a/apps/web/src/app/features/masters/articulos/articulos.screens.ts b/apps/web/src/app/features/masters/articulos/articulos.screens.ts index 39fca03..2861788 100644 --- a/apps/web/src/app/features/masters/articulos/articulos.screens.ts +++ b/apps/web/src/app/features/masters/articulos/articulos.screens.ts @@ -117,6 +117,46 @@ export const ARTICULOS_SCREENS: readonly MasterScreen[] = [ ], right: [], }, + { + id: 'precios', + label: 'Precios', + left: [], + right: [], + collection: { + key: 'ItemPrices', + // Una fila por lista de precios: solo se envían las que cambian (SAP recalcula las listas derivadas). + mode: 'merge', + rowKey: 'PriceList', + identity: ['PriceList'], + columns: [ + { key: 'PriceList', title: 'Lista de precios', type: 'text', readonly: true, width: '130px' }, + { key: 'Price', title: 'Precio', type: 'number', decimals: 2, width: '130px' }, + { key: 'Currency', title: 'Moneda', type: 'text', width: '90px' }, + { key: 'Factor', title: 'Factor', type: 'number', readonly: true, width: '90px' }, + { key: 'BasePriceList', title: 'Lista base', type: 'text', readonly: true, width: '100px' }, + ], + }, + }, + { + id: 'stock', + label: 'Stock por almacén', + left: [], + right: [], + collection: { + key: 'ItemWarehouseInfoCollection', + mode: 'replace', + readonly: true, + identity: ['WarehouseCode'], + columns: [ + { key: 'WarehouseCode', title: 'Almacén', type: 'text', width: '110px' }, + { key: 'InStock', title: 'En stock', type: 'number', width: '110px' }, + { key: 'Committed', title: 'Comprometido', type: 'number', width: '110px' }, + { key: 'Ordered', title: 'Pedido', type: 'number', width: '110px' }, + { key: 'MinimalStock', title: 'Mínimo', type: 'number', width: '100px' }, + { key: 'MaximalStock', title: 'Máximo', type: 'number', width: '100px' }, + ], + }, + }, { id: 'observaciones', label: 'Observaciones', left: [field({ type: 'textarea', key: 'User_Text', label: 'Observaciones' })], right: [] }, ], newHeader: () => ({ diff --git a/apps/web/src/app/features/masters/master-collections.spec.ts b/apps/web/src/app/features/masters/master-collections.spec.ts new file mode 100644 index 0000000..9c64c4d --- /dev/null +++ b/apps/web/src/app/features/masters/master-collections.spec.ts @@ -0,0 +1,272 @@ +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 { collectionPayload, collectionRowsOf, collectionsCreatePayload, isBlankRow, masterCollectionsOf } from './master-model'; +import { BASE_MASTER_SCREENS } from './master-screens'; + +const screen = (path: string) => BASE_MASTER_SCREENS.find((s) => s.path === path)!; +const collection = (path: string, key: string) => masterCollectionsOf(screen(path).layout).find((c) => c.key === key)!; + +const ADDR = { AddressName: 'BillTo', AddressType: 'bo_BillTo', Street: 'Calle 1', City: 'Santiago', Country: 'CL', BPCode: 'C1', RowNum: 0 }; +const SHIP = { AddressName: 'Bodega', AddressType: 'bo_ShipTo', Street: 'Ruta 5', City: 'Rancagua', Country: 'CL', BPCode: 'C1', RowNum: 1 }; + +describe('tablas de los maestros: qué se envía', () => { + const addresses = collection('/socios/clientes', 'BPAddresses'); + const prices = collection('/inventario/articulos', 'ItemPrices'); + + it('los clientes y proveedores tienen direcciones y contactos; el artículo, precios y stock por almacén', () => { + for (const p of ['/socios/clientes', '/socios/proveedores']) { + expect(masterCollectionsOf(screen(p).layout).map((c) => c.key)).toEqual(['BPAddresses', 'ContactEmployees']); + } + expect(masterCollectionsOf(screen('/inventario/articulos').layout).map((c) => c.key)).toEqual(['ItemPrices', 'ItemWarehouseInfoCollection']); + }); + + it('direcciones y contactos se envían completos (replace); los precios por filas (merge); el stock no se edita', () => { + expect(addresses.mode).toBe('replace'); + expect(collection('/socios/clientes', 'ContactEmployees').mode).toBe('replace'); + expect(prices.mode).toBe('merge'); + expect(prices.rowKey).toBe('PriceList'); + expect(collection('/inventario/articulos', 'ItemWarehouseInfoCollection').readonly).toBe(true); + }); + + it('sin cambios no se envía nada', () => { + expect(collectionPayload(addresses, [ADDR, SHIP], [{ ...ADDR }, { ...SHIP }])).toBeNull(); + expect(collectionPayload(addresses, [], [])).toBeNull(); + }); + + it('replace: al cambiar una celda se envía la colección completa, con las filas leídas y sus campos de SAP', () => { + const out = collectionPayload(addresses, [ADDR, SHIP], [{ ...ADDR, Street: 'Calle 2' }, { ...SHIP }])!; + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ AddressName: 'BillTo', Street: 'Calle 2', BPCode: 'C1', RowNum: 0 }); // conserva lo que SAP trae + expect(out[1]).toEqual(SHIP); + }); + + it('replace: añadir, quitar o reordenar una fila cuenta como cambio; una fila vacía no', () => { + expect(collectionPayload(addresses, [ADDR], [ADDR, { AddressName: 'Nueva', AddressType: 'bo_ShipTo' }])).toHaveLength(2); + expect(collectionPayload(addresses, [ADDR, SHIP], [ADDR])).toEqual([ADDR]); + expect(collectionPayload(addresses, [ADDR], [])).toEqual([]); + expect(collectionPayload(addresses, [ADDR], [ADDR, { AddressName: '', Street: '' }])).toBeNull(); + expect(isBlankRow(addresses, { AddressName: ' ', Street: 'x' })).toBe(true); + expect(isBlankRow(addresses, { AddressName: 'A' })).toBe(false); + }); + + it('merge: solo se envían las filas cambiadas, con su clave y los campos que cambiaron', () => { + const original = [ + { PriceList: 1, Price: 100, Currency: 'CLP', Factor: 1 }, + { PriceList: 2, Price: 50, Currency: 'CLP', Factor: 0.5 }, + ]; + const out = collectionPayload(prices, original, [{ ...original[0], Price: 120 }, { ...original[1] }]); + expect(out).toEqual([{ PriceList: 1, Price: 120 }]); + expect(collectionPayload(prices, original, [{ ...original[0], Price: '100' }, { ...original[1] }])).toBeNull(); // 100 = '100' + }); + + it('merge: vaciar un precio lo envía como null; las columnas de solo lectura no cuentan', () => { + const original = [{ PriceList: 1, Price: 100, Currency: 'CLP', Factor: 1 }]; + expect(collectionPayload(prices, original, [{ ...original[0], Price: '' }])).toEqual([{ PriceList: 1, Price: null }]); + expect(collectionPayload(prices, original, [{ ...original[0], Factor: 9 }])).toBeNull(); + }); + + it('el stock por almacén, de solo lectura, nunca se envía', () => { + const stock = collection('/inventario/articulos', 'ItemWarehouseInfoCollection'); + expect(collectionPayload(stock, [{ WarehouseCode: '01', InStock: 5 }], [{ WarehouseCode: '01', InStock: 99 }])).toBeNull(); + }); + + it('al crear se envían las filas con datos y sin campos vacíos; las vacías y el stock no', () => { + const body = collectionsCreatePayload(screen('/socios/clientes').layout, { + BPAddresses: [{ AddressName: 'BillTo', AddressType: 'bo_BillTo', Street: 'Calle 1', City: '' }, { AddressName: '' }], + ContactEmployees: [], + }); + expect(body).toEqual({ BPAddresses: [{ AddressName: 'BillTo', AddressType: 'bo_BillTo', Street: 'Calle 1' }] }); + expect(collectionsCreatePayload(screen('/inventario/articulos').layout, { ItemPrices: [{ PriceList: 1, Price: 10 }], ItemWarehouseInfoCollection: [{ WarehouseCode: '01' }] })).toEqual({ + ItemPrices: [{ PriceList: 1, Price: 10 }], + }); + }); + + it('copia las filas de SAP para editarlas sin tocar el original', () => { + const doc = { BPAddresses: [ADDR] }; + const rows = collectionRowsOf(doc, addresses); + rows[0]['Street'] = 'otra'; + expect(doc.BPAddresses[0].Street).toBe('Calle 1'); + expect(collectionRowsOf(undefined, addresses)).toEqual([]); + }); +}); + +describe('formulario con tablas', () => { + 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 save = () => (el().querySelector('.actions .btn.main') as HTMLElement).click(); + + async function open(path: string, key: string | null, doc?: Record) { + const s = screen(path); + const params = key === null ? {} : { key }; + const route = { snapshot: { data: { screen: s, title: s.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/${s.resource}/${encodeURIComponent(key)}`).flush(doc ?? {}); + await tick(); + } + + const customer = () => ({ + 'odata.etag': 'W/"4"', CardCode: 'C1', CardName: 'ACME', CardType: 'cCustomer', Frozen: 'tNO', + BPAddresses: [{ ...ADDR }, { ...SHIP }], + ContactEmployees: [{ Name: 'Juan Perez', FirstName: 'Juan', E_Mail: 'j@x.cl', InternalCode: 39, CardCode: 'C1' }], + }); + const item = () => ({ + 'odata.etag': 'W/"2"', ItemCode: 'A1', ItemName: 'Pieza', Frozen: 'tNO', + ItemPrices: [{ PriceList: 1, Price: 100, Currency: 'CLP', Factor: 1, BasePriceList: 1 }, { PriceList: 2, Price: 50, Currency: 'CLP', Factor: 0.5, BasePriceList: 1 }], + ItemWarehouseInfoCollection: [{ WarehouseCode: '01', InStock: 990, Committed: 2, Ordered: 1, MinimalStock: 0, MaximalStock: 0 }], + }); + const cell = (tab: number, row: number, col: number): HTMLInputElement => + el().querySelectorAll('sbo-tab')[tab].querySelectorAll('tbody tr')[row].querySelectorAll('input, select')[col] as HTMLInputElement; + + beforeEach(() => TestBed.resetTestingModule()); + + it('muestra las direcciones y los contactos que trae SAP en sus pestañas', async () => { + await open('/socios/clientes', 'C1', customer()); + const tabs = el().querySelectorAll('sbo-tab'); + const addrRows = [...tabs[3].querySelectorAll('tbody tr')]; + expect(addrRows).toHaveLength(2); + expect(addrRows[0].querySelector('input')!.value).toBe('BillTo'); + expect(tabs[4].querySelector('tbody input')!.value).toBe('Juan Perez'); + expect(page().dirty()).toBe(false); + }); + + it('editar una dirección envía la colección completa con el reemplazo de colecciones', async () => { + await open('/socios/clientes', 'C1', customer()); + const street = cell(3, 0, 2); + street.value = 'Calle 99'; + street.dispatchEvent(new Event('change')); + await tick(); + expect(page().dirty()).toBe(true); + save(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'PATCH'); + expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true'); + const body = req.request.body as { BPAddresses: Record[] }; + expect(Object.keys(body)).toEqual(['BPAddresses']); + expect(body.BPAddresses).toHaveLength(2); + expect(body.BPAddresses[0]).toMatchObject({ AddressName: 'BillTo', Street: 'Calle 99', RowNum: 0, BPCode: 'C1' }); + expect(body.BPAddresses[1]).toEqual(SHIP); + req.flush(null); + await tick(); + ctrl.expectOne('/api/sl/BusinessPartners/C1').flush(customer()); + }); + + it('añadir una persona de contacto y quitar una dirección', async () => { + await open('/socios/clientes', 'C1', customer()); + page().addRow(masterCollectionsOf(screen('/socios/clientes').layout)[1]); + await tick(); + expect(el().querySelectorAll('sbo-tab')[4].querySelectorAll('tbody tr')).toHaveLength(2); + const name = cell(4, 1, 0); + name.value = 'Ana Soto'; + name.dispatchEvent(new Event('change')); + (el().querySelectorAll('sbo-tab')[3].querySelectorAll('td.del button')[1] as HTMLElement).click(); // quita «Bodega» + await tick(); + save(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'PATCH'); + const body = req.request.body as { BPAddresses: unknown[]; ContactEmployees: Record[] }; + expect(body.BPAddresses).toHaveLength(1); + expect(body.ContactEmployees.map((c) => c['Name'])).toEqual(['Juan Perez', 'Ana Soto']); + expect(body.ContactEmployees[0]).toMatchObject({ InternalCode: 39 }); // la persona existente conserva su código de SAP + req.flush(null); + await tick(); + ctrl.expectOne('/api/sl/BusinessPartners/C1').flush(customer()); + }); + + it('una fila añadida y dejada vacía no se envía ni cuenta como cambio', async () => { + await open('/socios/clientes', 'C1', customer()); + page().addRow(masterCollectionsOf(screen('/socios/clientes').layout)[0]); + await tick(); + expect(page().dirty()).toBe(false); + save(); + await tick(); + ctrl.expectNone((r) => r.method === 'PATCH'); + expect(el().querySelector('.status')?.textContent).toContain('No hay cambios'); + }); + + it('un cliente nuevo con una dirección la envía en el POST', async () => { + await open('/socios/clientes', null); + page().setValue('CardCode', 'C9'); + page().setValue('CardName', 'Nuevo'); + page().addRow(masterCollectionsOf(screen('/socios/clientes').layout)[0]); + await tick(); + const name = cell(3, 0, 0); + name.value = 'BillTo'; + name.dispatchEvent(new Event('change')); + await tick(); + save(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'POST'); + expect((req.request.body as Record)['BPAddresses']).toEqual([{ AddressName: 'BillTo', AddressType: 'bo_ShipTo' }]); + req.flush({ CardCode: 'C9' }); + await tick(); + expect(navigate).toHaveBeenCalled(); + }); + + it('precios: solo se envía la lista que cambió, sin reemplazar la colección', async () => { + await open('/inventario/articulos', 'A1', item()); + const price = cell(4, 1, 1); + expect(price.value).toBe('50.00'); + price.value = '777'; + price.dispatchEvent(new Event('change')); + await tick(); + save(); + await tick(); + const req = ctrl.expectOne((r) => r.method === 'PATCH'); + expect(req.request.headers.has('B1S-ReplaceCollectionsOnPatch')).toBe(false); + expect(req.request.body).toEqual({ ItemPrices: [{ PriceList: 2, Price: 777 }] }); + req.flush(null); + await tick(); + ctrl.expectOne('/api/sl/Items/A1').flush(item()); + }); + + it('precios: la lista y el factor se ven pero no se editan, y no se pueden añadir ni quitar filas', async () => { + await open('/inventario/articulos', 'A1', item()); + const tab = el().querySelectorAll('sbo-tab')[4]; + expect((tab.querySelector('tbody tr input') as HTMLInputElement).readOnly).toBe(true); // lista de precios + expect(tab.querySelectorAll('td.del button')).toHaveLength(0); + expect(tab.querySelector('.addrow')).toBeNull(); + }); + + it('el stock por almacén se muestra completo y de solo lectura', async () => { + await open('/inventario/articulos', 'A1', item()); + const tab = el().querySelectorAll('sbo-tab')[5]; + const inputs = [...tab.querySelectorAll('tbody input')]; + expect(inputs.map((i) => i.value)).toEqual(['01', '990', '2', '1', '0', '0']); + expect(inputs.every((i) => i.readOnly)).toBe(true); + expect(tab.querySelector('.addrow')).toBeNull(); + expect(page().dirty()).toBe(false); + }); + + it('un artículo con cambios en un campo y en los precios envía las dos cosas juntas', async () => { + await open('/inventario/articulos', 'A1', item()); + page().setValue('ItemName', 'Pieza nueva'); + const price = cell(4, 0, 1); + price.value = '120'; + price.dispatchEvent(new Event('change')); + await tick(); + save(); + await tick(); + expect(ctrl.expectOne((r) => r.method === 'PATCH').request.body).toEqual({ ItemName: 'Pieza nueva', ItemPrices: [{ PriceList: 1, Price: 120 }] }); + }); +}); 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 7488345..b534e0c 100644 --- a/apps/web/src/app/features/masters/master-form.page.html +++ b/apps/web/src/app/features/masters/master-form.page.html @@ -37,6 +37,19 @@ } + @if (tab.collection; as c) { + + @if (growable(c)) { +

+ } + } } diff --git a/apps/web/src/app/features/masters/master-form.page.spec.ts b/apps/web/src/app/features/masters/master-form.page.spec.ts index cd9a7ed..fd73196 100644 --- a/apps/web/src/app/features/masters/master-form.page.spec.ts +++ b/apps/web/src/app/features/masters/master-form.page.spec.ts @@ -74,7 +74,7 @@ describe('MasterFormPage', () => { it('muestra el código y el nombre, el estado y las pestañas con sus campos', () => { expect(el().querySelector('.title')?.textContent).toContain('Cliente · C100'); expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Activo'); - expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Condiciones de pago', 'Contabilidad', 'Observaciones']); + expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Condiciones de pago', 'Contabilidad', 'Direcciones', 'Personas de contacto', 'Observaciones']); expect(labels()).toEqual(expect.arrayContaining(['Código', 'Nombre', 'RUT', 'Teléfono 1', 'Correo electrónico', 'Condición de pago', 'Límite de crédito'])); const values = [...el().querySelectorAll('input')].map((i) => i.value); expect(values).toEqual(expect.arrayContaining(['C100', 'ACME Ltda', '76111222-3', 'ventas@acme.cl'])); 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 d20929d..592f49e 100644 --- a/apps/web/src/app/features/masters/master-form.page.ts +++ b/apps/web/src/app/features/masters/master-form.page.ts @@ -5,19 +5,25 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { map } from 'rxjs'; import { API_URL, apiErrorMessage } from '../../core/api'; import { SlMasterApi } from '../../core/sl/sl-master'; +import { RowsTable } from '../../shared/grid/rows-table'; import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window'; import { updatePayload, type DocRecord } from '../documents/document-model'; import type { HeaderField } from '../documents/document-layouts'; import { MasterField } from './master-field'; import { + type CollectionRow, type MasterNavTarget, + collectionPayload, + collectionRowsOf, + collectionsCreatePayload, createMasterPayload, + masterCollectionsOf, masterKeysOf, masterRequiredOf, pickMaster, validateMaster, } from './master-model'; -import type { MasterScreen } from './master-types'; +import type { MasterCollection, MasterScreen } from './master-types'; const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); @@ -30,8 +36,9 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin @Component({ selector: 'sbo-master-form-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [Icon, MasterField, RouterLink, SboWindow, Tab, Tabs, Toolbar], + imports: [Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar], templateUrl: './master-form.page.html', + styles: '.addrow { margin: 6px 0 0; }', }) export class MasterFormPage { private readonly route = inject(ActivatedRoute); @@ -43,6 +50,7 @@ export class MasterFormPage { protected readonly layout = this.screen.layout; private readonly keys = masterKeysOf(this.layout); private readonly required = masterRequiredOf(this.layout); + private readonly collections = masterCollectionsOf(this.layout); protected readonly key = toSignal(this.route.paramMap.pipe(map((p) => p.get('key'))), { initialValue: this.route.snapshot.paramMap.get('key'), @@ -68,6 +76,22 @@ export class MasterFormPage { return pickMaster(doc, this.keys, this.layout.newHeader()); } + /** Filas de las tablas (direcciones, contactos, precios…); se reinician al cargar o recargar el registro. */ + protected readonly tables = linkedSignal>(() => + Object.fromEntries(this.collections.map((c) => [c.key, collectionRowsOf(this.doc(), c)])), + ); + + /** Lo que cambió en cada tabla (colección → filas a enviar). */ + private tableChanges(): Record { + const d = this.doc(); + return Object.fromEntries( + this.collections.flatMap((c) => { + const changed = collectionPayload(c, collectionRowsOf(d, c), this.tables()[c.key] ?? []); + return changed ? [[c.key, changed] as const] : []; + }), + ); + } + // --- estado -------------------------------------------------------------------------------- protected readonly saving = signal(false); @@ -82,7 +106,7 @@ export class MasterFormPage { protected readonly dirty = computed(() => { const d = this.doc(); if (!d) return false; - return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0; + return Object.keys(updatePayload(this.pick(d), this.header(), [], []).body).length > 0 || Object.keys(this.tableChanges()).length > 0; }); protected readonly status = computed(() => { @@ -123,6 +147,24 @@ export class MasterFormPage { return this.lockedKeys.has(key); } + protected rowsOf(c: MasterCollection): CollectionRow[] { + return this.tables()[c.key] ?? []; + } + + protected setRows(c: MasterCollection, rows: CollectionRow[]): void { + this.tables.update((t) => ({ ...t, [c.key]: rows })); + this.message.set(null); + } + + protected addRow(c: MasterCollection): void { + this.setRows(c, [...this.rowsOf(c), { ...(c.newRow?.() ?? {}) }]); + } + + /** La tabla permite añadir y quitar filas. */ + protected growable(c: MasterCollection): boolean { + return !c.readonly && c.mode === 'replace' && c.growable !== false; + } + protected value(key: string): unknown { return this.header()[key]; } @@ -153,17 +195,22 @@ export class MasterFormPage { try { const d = this.doc(); if (!d) { - const created = await this.api.create(this.screen.resource, createMasterPayload(this.header(), this.layout.fixed)); + const body = { ...createMasterPayload(this.header(), this.layout.fixed), ...collectionsCreatePayload(this.layout, this.tables()) }; + const created = await this.api.create(this.screen.resource, body); const key = text(created[this.screen.keyField]); await this.router.navigate(['..', key], { relativeTo: this.route, state: { created: key } }); return; } const { body } = updatePayload(this.pick(d), this.header(), [], []); + const changes = this.tableChanges(); + Object.assign(body, changes); if (!Object.keys(body).length) { this.message.set({ kind: 'info', text: 'No hay cambios que guardar.' }); return; } - await this.api.update(this.screen.resource, this.key()!, body, text(d['odata.etag']) || undefined); + // Reemplazar la colección solo cuando se manda completa (direcciones, contactos), nunca en las que van por filas. + const replace = this.collections.some((c) => c.mode === 'replace' && c.key in changes); + await this.api.update(this.screen.resource, this.key()!, body, text(d['odata.etag']) || undefined, replace); this.message.set({ kind: 'ok', text: `${this.screen.singular} actualizado.` }); this.resource.reload(); } catch (e) { 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 f1168a9..f1ab409 100644 --- a/apps/web/src/app/features/masters/master-model.spec.ts +++ b/apps/web/src/app/features/masters/master-model.spec.ts @@ -133,7 +133,7 @@ describe('más campos de los maestros', () => { it('el artículo suma unidades, proveedor principal, método de valoración y si es sujeto a impuesto', () => { const a = by('/inventario/articulos').layout; expect(masterKeysOf(a)).toEqual(expect.arrayContaining(['SalesUnit', 'PurchaseUnit', 'Mainsupplier', 'CostAccountingMethod', 'GLMethod', 'VatLiable'])); - expect(a.tabs.map((t) => t.id)).toEqual(['general', 'inventario', 'ventas', 'compras', 'observaciones']); + expect(a.tabs.map((t) => t.id)).toEqual(['general', 'inventario', 'ventas', 'compras', 'precios', 'stock', 'observaciones']); expect(a.newHeader()).toMatchObject({ VatLiable: 'tYES', CostAccountingMethod: 'bis_MovingAverage', GLMethod: 'glm_ItemClass' }); }); diff --git a/apps/web/src/app/features/masters/master-model.ts b/apps/web/src/app/features/masters/master-model.ts index 76a0fdb..0a786f0 100644 --- a/apps/web/src/app/features/masters/master-model.ts +++ b/apps/web/src/app/features/masters/master-model.ts @@ -1,7 +1,7 @@ import { escapeODataString } from '../../core/sl/odata'; import { type HeaderField, keysOfFields, requiredOfFields } from '../documents/document-layouts'; import { type DocRecord, compact } from '../documents/document-model'; -import type { MasterLayout } from './master-types'; +import type { MasterCollection, MasterLayout } from './master-types'; const isEmpty = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === ''); @@ -58,3 +58,61 @@ export function masterNavigationQuery( return current === null ? { $orderby: asc } : { $filter: `${keyField} gt ${quoted}`, $orderby: asc }; } } + +// --- tablas (colecciones) ---------------------------------------------------------------- + +export type CollectionRow = Record; + +/** Las tablas que declara el formulario, de todas sus pestañas. */ +export const masterCollectionsOf = (layout: MasterLayout): MasterCollection[] => + layout.tabs.flatMap((t) => (t.collection ? [t.collection] : [])); + +/** Copia de las filas que trae SAP de una colección (sobre ellas se aplican los cambios). */ +export const collectionRowsOf = (doc: DocRecord | undefined, c: MasterCollection): CollectionRow[] => + ((doc?.[c.key] as CollectionRow[] | undefined) ?? []).map((r) => ({ ...r })); + +const has = (v: unknown): boolean => !(v === null || v === undefined || (typeof v === 'string' && v.trim() === '')); + +/** Una fila vacía: ninguno de los campos que la identifican tiene valor. */ +export const isBlankRow = (c: MasterCollection, row: CollectionRow): boolean => !c.identity.some((k) => has(row[k])); + +const editableKeys = (c: MasterCollection): string[] => c.columns.filter((col) => !col.readonly).map((col) => col.key); +/** Dos valores de una celda son iguales (vacío = vacío; un número escrito como texto = el número). */ +function same(a: unknown, b: unknown): boolean { + if (!has(a) && !has(b)) return true; + if (!has(a) || !has(b)) return false; + return a === b || (Number.isFinite(Number(a)) && Number(a) === Number(b)); +} + +/** + * Lo que se envía a SAP de una tabla tras editarla, o `null` si no cambió. En `replace` la colección completa + * (las filas leídas con los cambios y las nuevas, sin las vacías); en `merge` solo las filas cambiadas con su + * `rowKey` y los campos que cambiaron. + */ +export function collectionPayload(c: MasterCollection, original: readonly CollectionRow[], current: readonly CollectionRow[]): CollectionRow[] | null { + if (c.readonly) return null; + const keys = editableKeys(c); + const rows = current.filter((r) => !isBlankRow(c, r)); + if (c.mode === 'merge') { + const rk = c.rowKey ?? ''; + const changed = rows.flatMap((r) => { + const before = original.find((o) => o[rk] === r[rk]); + const diff = keys.filter((k) => !same(before?.[k], r[k])); + return before && diff.length ? [{ [rk]: r[rk], ...Object.fromEntries(diff.map((k) => [k, has(r[k]) ? r[k] : null])) }] : []; + }); + return changed.length ? changed : null; + } + const unchanged = rows.length === original.length && rows.every((r, i) => keys.every((k) => same(original[i]?.[k], r[k]))); + return unchanged ? null : rows; +} + +/** Tablas con filas para enviar al crear el registro (las vacías no). */ +export function collectionsCreatePayload(layout: MasterLayout, current: Readonly>): DocRecord { + const body: DocRecord = {}; + for (const c of masterCollectionsOf(layout)) { + if (c.readonly) continue; + const rows = (current[c.key] ?? []).filter((r) => !isBlankRow(c, r)).map((r) => compact(r)); + if (rows.length) body[c.key] = rows; + } + return body; +} diff --git a/apps/web/src/app/features/masters/master-types.ts b/apps/web/src/app/features/masters/master-types.ts index 3eb6bae..da15fb7 100644 --- a/apps/web/src/app/features/masters/master-types.ts +++ b/apps/web/src/app/features/masters/master-types.ts @@ -1,12 +1,41 @@ 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 { FieldColumns } from '../documents/document-layouts'; import type { DocRecord } from '../documents/document-model'; -/** Una pestaña del maestro (General, Condiciones de pago…): dos columnas de campos como máximo. */ +/** + * Una tabla de filas del maestro (direcciones, personas de contacto, precios, stock por almacén): una + * colección del Service Layer que se edita en la pestaña, debajo de los campos. + */ +export interface MasterCollection { + /** Colección de SAP (`BPAddresses`, `ContactEmployees`, `ItemPrices`…). */ + key: string; + columns: readonly DetailColumn[]; + /** + * Cómo se envía al modificar: `replace` manda la colección completa (las filas leídas con los cambios + * aplicados y las nuevas; SAP quita las que faltan) y `merge` solo las filas cambiadas, identificadas por + * `rowKey` (los precios: SAP recalcula las listas derivadas si no se le reenvían con valores viejos). + */ + mode: 'replace' | 'merge'; + /** Campo que identifica una fila en `merge` (`PriceList`). */ + rowKey?: string; + /** Solo se muestra (el stock por almacén). */ + readonly?: boolean; + /** Se pueden añadir y quitar filas (por defecto sí; no en `merge` ni en solo lectura). */ + growable?: boolean; + addLabel?: string; + /** Una fila sin ninguno de estos campos con valor se considera vacía y no se guarda. */ + identity: readonly string[]; + /** Valores de una fila nueva. */ + newRow?: () => Record; +} + +/** Una pestaña del maestro (General, Condiciones de pago…): dos columnas de campos como máximo y, si la hay, una tabla. */ export interface MasterTab extends FieldColumns { id: string; label: string; + collection?: MasterCollection; } /** diff --git a/apps/web/src/app/features/masters/socios-negocio/socios.screens.ts b/apps/web/src/app/features/masters/socios-negocio/socios.screens.ts index a3698b9..c194807 100644 --- a/apps/web/src/app/features/masters/socios-negocio/socios.screens.ts +++ b/apps/web/src/app/features/masters/socios-negocio/socios.screens.ts @@ -108,6 +108,61 @@ const partner = (opts: { path: string; type: 'cCustomer' | 'cSupplier'; singular ], right: [], }, + { + id: 'direcciones', + label: 'Direcciones', + left: [], + right: [], + collection: { + key: 'BPAddresses', + mode: 'replace', + identity: ['AddressName'], + addLabel: '+ Añadir dirección', + newRow: () => ({ AddressType: 'bo_ShipTo' }), + columns: [ + { key: 'AddressName', title: 'Nombre', type: 'text', required: true, width: '130px' }, + { + key: 'AddressType', + title: 'Tipo', + type: 'select', + width: '170px', + options: [ + { value: 'bo_BillTo', label: 'Facturación' }, + { value: 'bo_ShipTo', label: 'Envío' }, + ], + }, + { key: 'Street', title: 'Calle', type: 'text', width: '200px' }, + { key: 'StreetNo', title: 'Número', type: 'text', width: '80px' }, + { key: 'Block', title: 'Complemento', type: 'text', width: '140px' }, + { key: 'City', title: 'Ciudad', type: 'text', width: '130px' }, + { key: 'County', title: 'Comuna', type: 'text', width: '130px' }, + { key: 'State', title: 'Región', type: 'text', width: '100px' }, + { key: 'ZipCode', title: 'Código postal', type: 'text', width: '110px' }, + { key: 'Country', title: 'País', type: 'text', width: '70px' }, + ], + }, + }, + { + id: 'contactos', + label: 'Personas de contacto', + left: [], + right: [], + collection: { + key: 'ContactEmployees', + mode: 'replace', + identity: ['Name'], + addLabel: '+ Añadir persona de contacto', + columns: [ + { key: 'Name', title: 'Nombre', type: 'text', required: true, width: '160px' }, + { key: 'FirstName', title: 'Nombres', type: 'text', width: '130px' }, + { key: 'LastName', title: 'Apellidos', type: 'text', width: '130px' }, + { key: 'Position', title: 'Cargo', type: 'text', width: '130px' }, + { key: 'Phone1', title: 'Teléfono', type: 'text', width: '110px' }, + { key: 'MobilePhone', title: 'Celular', type: 'text', width: '110px' }, + { key: 'E_Mail', title: 'Correo electrónico', type: 'text', width: '200px' }, + ], + }, + }, { id: 'observaciones', label: 'Observaciones', left: [field({ type: 'textarea', key: 'Notes', label: 'Observaciones' })], right: [] }, ], newHeader: () => ({ Frozen: 'tNO', CreditLimit: 0, DiscountPercent: 0, MaxCommitment: 0 }), diff --git a/apps/web/src/app/shared/grid/grid.types.ts b/apps/web/src/app/shared/grid/grid.types.ts index 854e78e..2159251 100644 --- a/apps/web/src/app/shared/grid/grid.types.ts +++ b/apps/web/src/app/shared/grid/grid.types.ts @@ -48,9 +48,15 @@ export interface GridMultiAdd { export interface DetailColumn { key: string; title: string; - type: 'text' | 'number' | 'date'; + type: 'text' | 'number' | 'date' | 'select'; width?: string; required?: boolean; + /** Solo se muestra (aunque la tabla sea editable). */ + readonly?: boolean; + /** Opciones de una columna `select`. */ + options?: readonly { value: string; label: string }[]; + /** Decimales fijos de una columna numérica; sin él, hasta 3. */ + decimals?: number; } /** diff --git a/apps/web/src/app/shared/grid/line-detail-dialog.ts b/apps/web/src/app/shared/grid/line-detail-dialog.ts index 2adb1b6..334a7b7 100644 --- a/apps/web/src/app/shared/grid/line-detail-dialog.ts +++ b/apps/web/src/app/shared/grid/line-detail-dialog.ts @@ -1,12 +1,9 @@ import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } from '@angular/core'; -import { formatNumber, parseDecimal } from '../../core/format'; import type { DetailColumn } from './grid.types'; +import { RowsTable } from './rows-table'; 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. @@ -14,6 +11,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) @Component({ selector: 'sbo-line-detail-dialog', changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RowsTable], template: `