fix(web): ventas y compras envían la unidad de medida del artículo (UoMEntry)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 16:05:25 -05:00
1 parent 0d5a3074c8
commit d3ab97cd2e
7 files changed
+71 -20

No files matched your search

+1 -1
View File
@@ -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`). 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: 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/<nombre>` 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). **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/<nombre>` 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).
@@ -201,21 +201,21 @@ describe('DocumentFormPage de inventario', () => {
}); });
describe('columnas de línea por documento', () => { 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', () => { it('sin elegir, el documento lleva todas las columnas y envía los mismos campos de siempre', () => {
const l = marketingLayout(base); const l = marketingLayout(base);
expect(l.columns.map((c) => c.key)).toEqual([ expect(l.columns.map((c) => c.key)).toEqual([
'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode', 'LineTotal', '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', () => { it('un documento elige sus columnas y el orden; los campos enviados salen de ellas', () => {
const { item, description, quantity, warehouse } = LINE_COLUMNS; const { item, description, quantity, warehouse } = LINE_COLUMNS;
const l = marketingLayout({ ...base, lineColumns: [item, description, quantity, warehouse] }); const l = marketingLayout({ ...base, lineColumns: [item, description, quantity, warehouse] });
expect(l.columns.map((c) => c.key)).toEqual(['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode']); 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); 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' }]); 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); 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<string, unknown>, line: Record<string, unknown> = {}) =>
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);
});
});
@@ -88,14 +88,22 @@ export const commentsOnly: FieldColumns = { left: [FIELDS.comments], right: [] }
const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS; const [baseItemColumn, descriptionColumn, quantityColumn, warehouseColumn, priceColumn] = DOCUMENT_LINE_COLUMNS;
export { descriptionColumn, priceColumn, quantityColumn, warehouseColumn }; export { descriptionColumn, priceColumn, quantityColumn, warehouseColumn };
/** Artículo que además completa la unidad de medida de la línea (`UoMEntry`). */ /**
export const itemColumn: GridColumn = { * Columna de artículo que además completa la unidad de medida de la línea (`UoMEntry`): sin ella SAP
...baseItemColumn, * rechaza el documento si el artículo no tiene unidad por defecto. Al cambiar de artículo se reemplaza
lookup: { * (un artículo de unidad manual no lleva).
...baseItemColumn.lookup!, */
apply: (item, line) => ({ ...baseItemColumn.lookup!.apply(item, line), UoMEntry: uomEntryOf(item) ?? line['UoMEntry'] }), 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). */ /** Lista de almacenes de la cabecera (la misma de las líneas). */
export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config; export const WAREHOUSE_LOOKUP: VhLookup = warehouseColumn.lookup!.config;
@@ -160,10 +168,14 @@ export function marketingLayout(opts: {
lineColumns?: readonly GridColumn[]; lineColumns?: readonly GridColumn[];
/** Columnas de las listas de valores (cliente, artículo, almacén, impuesto) de este documento. */ /** Columnas de las listas de valores (cliente, artículo, almacén, impuesto) de este documento. */
lookups?: LookupColumns; lookups?: LookupColumns;
/** Unidad de medida que se toma del artículo: la de venta o la de compra. */
uom: 'sales' | 'purchase';
}): DocumentLayout { }): 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 = { const layout: DocumentLayout = {
lines: { key: 'DocumentLines', fields: lineFieldsOf(columns), base: true }, lines: { key: 'DocumentLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true },
header: opts.header ?? { header: opts.header ?? {
left: [ left: [
{ type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` }, { type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` },
@@ -91,7 +91,11 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE
.filter((l) => !isBlankLine(l)) .filter((l) => !isBlankLine(l))
.map((l) => { .map((l) => {
const out: DocRecord = {}; 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]; if (spec.base && hasBase(l)) for (const f of BASE_FIELDS) out[f] = l[f];
return out; return out;
}); });
@@ -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. * 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 * Unidad de medida de un artículo con grupo de unidades, para las líneas de un documento: la de
* compra por defecto o, si no tiene, la de inventario. Sin ella SAP rechaza el documento ("specify a * 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. * UoM code"); y a un artículo de unidad manual (sin grupo) SAP no admite ponérsela.
*/ */
export function uomEntryOf(item: Record<string, unknown>): number | undefined { export function uomEntryOf(item: Record<string, unknown>, side: 'purchase' | 'sales' = 'purchase'): number | undefined {
if (!(Number(item['UoMGroupEntry']) > 0)) return 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]); const entry = Number(item[key]);
if (entry > 0) return entry; if (entry > 0) return entry;
} }
@@ -58,7 +58,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
title: 'Artículos', title: 'Artículos',
resource: 'Items', resource: 'Items',
orderby: 'ItemCode', orderby: 'ItemCode',
extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'InventoryUoMEntry'], extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'DefaultSalesUoMEntry', 'InventoryUoMEntry'],
columns: [ columns: [
{ key: 'ItemCode', title: 'Código', width: '150px', mono: true }, { key: 'ItemCode', title: 'Código', width: '150px', mono: true },
{ key: 'ItemName', title: 'Descripción' }, { key: 'ItemName', title: 'Descripción' },
@@ -184,6 +184,7 @@ describe('columnas de las listas de valores por documento', () => {
const layout = marketingLayout({ const layout = marketingLayout({
partnerLabel: 'Cliente', partnerLabel: 'Cliente',
taxResource: 'SalesTaxCodes', taxResource: 'SalesTaxCodes',
uom: 'sales',
lookups: { lookups: {
item: [{ key: 'ItemCode', title: 'Código' }, { key: 'ItemName', title: 'Descripción' }, { key: 'QuantityOnStock', title: 'Stock', type: 'number' }], 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' }], partner: [{ key: 'CardCode', title: 'Código' }, { key: 'CardName', title: 'Nombre' }, { key: 'Phone1', title: 'Teléfono' }],
@@ -142,6 +142,7 @@ export const marketing = (
header: extras.header, header: extras.header,
lineColumns: extras.lineColumns, lineColumns: extras.lineColumns,
lookups: extras.lookups, lookups: extras.lookups,
uom: path.startsWith('/ventas') ? 'sales' : 'purchase',
}), }),
resource, resource,
createLabel, createLabel,