From d3ab97cd2e72b18c386078add654259bc98c76c4 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 14 Oct 2026 16:05:25 -0500 Subject: [PATCH] =?UTF-8?q?fix(web):=20ventas=20y=20compras=20env=C3=ADan?= =?UTF-8?q?=20la=20unidad=20de=20medida=20del=20art=C3=ADculo=20(UoMEntry)?= 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 +- .../documents/document-layouts.spec.ts | 39 +++++++++++++++++-- .../features/documents/document-layouts.ts | 32 ++++++++++----- .../app/features/documents/document-model.ts | 6 ++- .../app/features/documents/line-columns.ts | 10 ++--- .../documents/screen-extensions.spec.ts | 1 + .../src/app/features/documents/screen-kit.ts | 1 + 7 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c875535..37c503b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,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 (`marketing`) aún no lo envían, y los de compra fallan con artículos sin 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: 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. **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 antes del total y se envían a SAP) y `listColumns`. Solo suma, no quita; una ruta inexistente o ajena al módulo lanza 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). diff --git a/apps/web/src/app/features/documents/document-layouts.spec.ts b/apps/web/src/app/features/documents/document-layouts.spec.ts index 4758732..87f5c58 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -201,21 +201,21 @@ describe('DocumentFormPage de inventario', () => { }); describe('columnas de línea por documento', () => { - const base = { partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes' }; + const base = { partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom: 'sales' } as const; it('sin elegir, el documento lleva todas las columnas y envía los mismos campos de siempre', () => { const l = marketingLayout(base); expect(l.columns.map((c) => c.key)).toEqual([ 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode', 'LineTotal', ]); - expect(l.lines.fields).toEqual(MARKETING_LINES.fields); + expect(l.lines.fields).toEqual([...MARKETING_LINES.fields, 'UoMEntry']); }); it('un documento elige sus columnas y el orden; los campos enviados salen de ellas', () => { const { item, description, quantity, warehouse } = LINE_COLUMNS; const l = marketingLayout({ ...base, lineColumns: [item, description, quantity, warehouse] }); expect(l.columns.map((c) => c.key)).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode']); - expect(l.lines.fields).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode']); + expect(l.lines.fields).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'UoMEntry']); const body = createPayload({ CardCode: 'C1' }, [{ ItemCode: 'A', Quantity: 1, WarehouseCode: '01', Price: 9, TaxCode: 'IVA' }], l.lines); expect(body['DocumentLines']).toEqual([{ ItemCode: 'A', Quantity: 1, WarehouseCode: '01' }]); }); @@ -236,3 +236,36 @@ describe('columnas de línea por documento', () => { expect(other.columns).toHaveLength(8); }); }); + +describe('unidad de medida en ventas y compras', () => { + const item = { ItemCode: 'SU01', ItemName: 'Sutura', UoMGroupEntry: 1, InventoryUoMEntry: 1, DefaultPurchasingUoMEntry: null, DefaultSalesUoMEntry: 2 }; + const pick = (uom: 'sales' | 'purchase', picked: Record, line: Record = {}) => + marketingLayout({ partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom }) + .columns.find((c) => c.key === 'ItemCode')! + .lookup!.apply(picked, line); + + it('al elegir un artículo la línea toma su unidad: la de venta en ventas, la de compra en compras', () => { + expect(pick('sales', item)).toMatchObject({ ItemCode: 'SU01', UoMEntry: 2 }); + expect(pick('purchase', item)).toMatchObject({ ItemCode: 'SU01', UoMEntry: 1 }); // sin unidad de compra: la de inventario + }); + + it('un artículo de unidad manual no lleva unidad, y al cambiar de artículo no arrastra la anterior', () => { + const manual = { ItemCode: 'M1', UoMGroupEntry: -1, InventoryUoMEntry: -1 }; + expect(pick('purchase', manual, { ItemCode: 'SU01', UoMEntry: 1 })['UoMEntry']).toBeUndefined(); + }); + + it('la unidad se envía a SAP solo si es válida (la manual, -1, no)', () => { + const l = marketingLayout({ partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', uom: 'purchase' }); + const body = createPayload({}, [{ ItemCode: 'A', Quantity: 1, UoMEntry: 3 }, { ItemCode: 'B', Quantity: 1, UoMEntry: -1 }], l.lines); + expect(body['DocumentLines']).toEqual([ + { ItemCode: 'A', Quantity: 1, UoMEntry: 3 }, + { ItemCode: 'B', Quantity: 1 }, + ]); + }); + + it('cada pantalla toma la unidad de su lado', () => { + const ap = (path: string) => DOCUMENT_SCREENS.find((s) => s.path === path)!.layout.columns.find((c) => c.key === 'ItemCode')!.lookup!.apply(item, {}); + expect(ap('/ventas/ordenes')['UoMEntry']).toBe(2); + expect(ap('/compras/ordenes')['UoMEntry']).toBe(1); + }); +}); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 84eda11..7a5869c 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -88,14 +88,22 @@ export const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] } const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS; export { descriptionColumn, priceColumn, quantityColumn, warehouseColumn }; -/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */ -export const itemColumn: GridColumn = { - ...baseItemColumn, - lookup: { - ...baseItemColumn.lookup!, - apply: (item, line) => ({ ...baseItemColumn.lookup!.apply(item, line), UoMEntry: uomEntryOf(item) ?? line['UoMEntry'] }), - }, -}; +/** + * Columna de artículo que además completa la unidad de medida de la línea (`UoMEntry`): sin ella SAP + * rechaza el documento si el artículo no tiene unidad por defecto. Al cambiar de artículo se reemplaza + * (un artículo de unidad manual no lleva). + */ +export function itemColumnWithUom(item: GridColumn, side: 'purchase' | 'sales'): GridColumn { + return { + ...item, + lookup: { + ...item.lookup!, + apply: (picked, line) => ({ ...item.lookup!.apply(picked, line), UoMEntry: uomEntryOf(picked, side) }), + }, + }; +} + +export const itemColumn: GridColumn = itemColumnWithUom(baseItemColumn, 'purchase'); /** Lista de almacenes de la cabecera (la misma de las líneas). */ export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config; @@ -160,10 +168,14 @@ export function marketingLayout(opts: { lineColumns?: readonly GridColumn[]; /** Columnas de las listas de valores (cliente, artículo, almacén, impuesto) de este documento. */ lookups?: LookupColumns; + /** Unidad de medida que se toma del artículo: la de venta o la de compra. */ + uom: 'sales' | 'purchase'; }): DocumentLayout { - const columns = lineColumnsFor(opts.taxResource, opts.lineColumns); + const columns = lineColumnsFor(opts.taxResource, opts.lineColumns).map((c) => + c.key === 'ItemCode' && c.lookup ? itemColumnWithUom(c, opts.uom) : c, + ); const layout: DocumentLayout = { - lines: { key: 'DocumentLines', fields: lineFieldsOf(columns), base: true }, + lines: { key: 'DocumentLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true }, header: opts.header ?? { left: [ { type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` }, diff --git a/apps/web/src/app/features/documents/document-model.ts b/apps/web/src/app/features/documents/document-model.ts index e63f0ec..782aef9 100644 --- a/apps/web/src/app/features/documents/document-model.ts +++ b/apps/web/src/app/features/documents/document-model.ts @@ -91,7 +91,11 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE .filter((l) => !isBlankLine(l)) .map((l) => { const out: DocRecord = {}; - for (const f of spec.fields) if (!isEmpty(l[f])) out[f] = l[f]; + for (const f of spec.fields) { + if (isEmpty(l[f])) continue; + if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla + out[f] = l[f]; + } if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f]; return out; }); diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index 993a062..ee475cd 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -17,13 +17,13 @@ export function recalcLine(line: GridLine): GridLine { * usuario no lo escribe no se envía y SAP aplica la lista de precios del socio de negocio. */ /** - * Unidad de medida de un artículo con grupo de unidades, para las líneas de compra e inventario: la de - * compra por defecto o, si no tiene, la de inventario. Sin ella SAP rechaza el documento ("specify a + * Unidad de medida de un artículo con grupo de unidades, para las líneas de un documento: la de + * compra (o de venta) por defecto o, si no tiene, la de inventario. Sin ella SAP rechaza el documento ("specify a * UoM code"); y a un artículo de unidad manual (sin grupo) SAP no admite ponérsela. */ -export function uomEntryOf(item: Record): number | undefined { +export function uomEntryOf(item: Record, side: 'purchase' | 'sales' = 'purchase'): number | undefined { if (!(Number(item['UoMGroupEntry']) > 0)) return undefined; - for (const key of ['DefaultPurchasingUoMEntry', 'InventoryUoMEntry']) { + for (const key of [side === 'sales' ? 'DefaultSalesUoMEntry' : 'DefaultPurchasingUoMEntry', 'InventoryUoMEntry']) { const entry = Number(item[key]); if (entry > 0) return entry; } @@ -58,7 +58,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [ title: 'Artículos', resource: 'Items', orderby: 'ItemCode', - extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'InventoryUoMEntry'], + extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'DefaultSalesUoMEntry', 'InventoryUoMEntry'], columns: [ { key: 'ItemCode', title: 'Código', width: '150px', mono: true }, { key: 'ItemName', title: 'Descripción' }, diff --git a/apps/web/src/app/features/documents/screen-extensions.spec.ts b/apps/web/src/app/features/documents/screen-extensions.spec.ts index e6e48b4..a1c86ce 100644 --- a/apps/web/src/app/features/documents/screen-extensions.spec.ts +++ b/apps/web/src/app/features/documents/screen-extensions.spec.ts @@ -184,6 +184,7 @@ describe('columnas de las listas de valores por documento', () => { const layout = marketingLayout({ partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes', + uom: 'sales', lookups: { item: [{ key: 'ItemCode', title: 'Código' }, { key: 'ItemName', title: 'Descripción' }, { key: 'QuantityOnStock', title: 'Stock', type: 'number' }], partner: [{ key: 'CardCode', title: 'Código' }, { key: 'CardName', title: 'Nombre' }, { key: 'Phone1', title: 'Teléfono' }], diff --git a/apps/web/src/app/features/documents/screen-kit.ts b/apps/web/src/app/features/documents/screen-kit.ts index 63228e2..8fe2692 100644 --- a/apps/web/src/app/features/documents/screen-kit.ts +++ b/apps/web/src/app/features/documents/screen-kit.ts @@ -142,6 +142,7 @@ export const marketing = ( header: extras.header, lineColumns: extras.lineColumns, lookups: extras.lookups, + uom: path.startsWith('/ventas') ? 'sales' : 'purchase', }), resource, createLabel,