From 1f212e2b308f20ea6839115053c6259b5b757e40 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 14 Oct 2026 15:16:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20cada=20documento=20de=20ventas=20y?= =?UTF-8?q?=20compras=20puede=20elegir=20sus=20columnas=20de=20l=C3=ADnea?= =?UTF-8?q?=20y=20su=20cabecera?= 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 | 42 ++++++++++++++++++- .../features/documents/document-layouts.ts | 18 +++++--- .../app/features/documents/line-columns.ts | 27 ++++++++++-- .../src/app/features/documents/screen-kit.ts | 16 ++++++- 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6a05545..175e115 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. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo. +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. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. Falta: botón PDF, fila del flete y toolbar de ancho completo. 2. Pantallas de maestros (`Items`, `BusinessPartners`: Clientes/Proveedores, Artículos), hoy "no disponible". 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. 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 eea6410..e3b5f90 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -5,9 +5,10 @@ import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angul import { of } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { purchaseRequestLayout } from './compras/compras.layouts'; -import { hasFolio, headerKeysOf } from './document-layouts'; +import { hasFolio, headerKeysOf, marketingLayout } from './document-layouts'; +import { LINE_COLUMNS, lineFieldsOf } from './line-columns'; import { transferRequestLayout } from './inventario/inventario.layouts'; -import { createPayload, updatePayload, validateLayout } from './document-model'; +import { MARKETING_LINES, createPayload, updatePayload, validateLayout } from './document-model'; import { DocumentFormPage } from './document-form.page'; import { DOCUMENT_SCREENS } from './document-screens'; @@ -198,3 +199,40 @@ describe('DocumentFormPage de inventario', () => { ctrl.expectOne('/api/sl/InventoryTransferRequests/471').flush({}); }); }); + +describe('columnas de línea por documento', () => { + const base = { partnerLabel: 'Cliente', taxResource: 'SalesTaxCodes' }; + + 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); + }); + + 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']); + 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' }]); + }); + + it('el total y los campos calculados no se envían', () => { + expect(lineFieldsOf([LINE_COLUMNS.price, LINE_COLUMNS.total])).toEqual(['Price']); + }); + + it('un documento puede traer su cabecera completa en lugar de la base', () => { + const l = marketingLayout({ ...base, header: { left: [], right: [] } }); + expect(headerKeysOf(l)).toEqual(['Comments', 'DiscountPercent']); + }); + + it('los demás documentos no cambian cuando uno elige columnas', () => { + const custom = marketingLayout({ ...base, lineColumns: [LINE_COLUMNS.item] }); + const other = marketingLayout(base); + expect(custom.columns).toHaveLength(1); + expect(other.columns).toHaveLength(8); + }); +}); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 4243298..032b83f 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -1,7 +1,7 @@ import type { GridColumn, GridLine } from '../../shared/grid/grid.types'; import type { VhLookup } from '../../shared/value-help/value-help.types'; import type { DocRecord, LinesSpec } from './document-model'; -import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, newDocumentLine, recalcLine, uomEntryOf } from './line-columns'; +import { DOCUMENT_LINE_COLUMNS, lineColumnsFor, lineFieldsOf, newDocumentLine, recalcLine, uomEntryOf } from './line-columns'; /** Valores calculados o del socio que una fila de solo lectura puede mostrar. */ export type ComputedSource = 'docNum' | 'partnerName' | 'partnerTaxId' | 'subtotal' | 'tax' | 'total'; @@ -95,18 +95,24 @@ export const blankLine = (): GridLine => ({ ItemCode: '', ItemDescription: '', Q /** * Ventas y compras: socio, referencia, fechas; líneas con precio, descuento e impuesto; pie con - * comentarios, descuento y totales. `left` y `right` son los campos que esa pantalla agrega a la - * cabecera (por ejemplo `FIELDS.folio` en las facturas); sin ellos, la cabecera base. + * comentarios, descuento y totales. Es el punto de partida; cada pantalla ajusta lo suyo: + * - `left` y `right` agregan campos a la cabecera base (por ejemplo `FIELDS.folio` en las facturas); + * - `header` la reemplaza por completo; + * - `lineColumns` reemplaza las columnas de línea (elegidas de `LINE_COLUMNS`); lo que se envía a SAP + * por línea se deduce de ellas. */ export function marketingLayout(opts: { partnerLabel: string; taxResource: string; left?: readonly HeaderField[]; right?: readonly HeaderField[]; + header?: FieldColumns; + lineColumns?: readonly GridColumn[]; }): DocumentLayout { + const columns = lineColumnsFor(opts.taxResource, opts.lineColumns); return { - lines: { key: 'DocumentLines', fields: ['ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'DiscountPercent', 'TaxCode'], base: true }, - header: { + lines: { key: 'DocumentLines', fields: lineFieldsOf(columns), base: true }, + header: opts.header ?? { left: [ { type: 'partner', key: 'CardCode', label: opts.partnerLabel, required: true, missing: `el ${opts.partnerLabel.toLowerCase()}` }, { type: 'computed', label: 'Nombre', source: 'partnerName' }, @@ -125,7 +131,7 @@ export function marketingLayout(opts: { { type: 'computed', label: 'Total del documento', source: 'total' }, ], }, - columns: lineColumnsFor(opts.taxResource), + columns, newHeader: () => ({ DocDate: today(), DocDueDate: today(), DiscountPercent: 0 }), newLine: newDocumentLine, recalc: recalcLine, diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index 70c9554..993a062 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -114,10 +114,31 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [ { key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true }, ]; +const columnOf = (key: string): GridColumn => DOCUMENT_LINE_COLUMNS.find((c) => c.key === key)!; + +/** + * Catálogo de columnas de línea: cada documento elige cuáles muestra (y en qué orden) en su pantalla. + * Sin elegir, lleva todas (`DOCUMENT_LINE_COLUMNS`). + */ +export const LINE_COLUMNS = { + item: columnOf('ItemCode'), + description: columnOf('ItemDescription'), + quantity: columnOf('Quantity'), + warehouse: columnOf('WarehouseCode'), + price: columnOf('Price'), + discount: columnOf('DiscountPercent'), + tax: columnOf('TaxCode'), + total: columnOf('LineTotal'), +} as const; + +/** Campos de línea que se envían a SAP: los de las columnas editables (el total y lo calculado lo resuelve SAP). */ +export const lineFieldsOf = (columns: readonly GridColumn[]): string[] => + columns.filter((c) => !c.format && c.readonly !== true).map((c) => c.key); + /** Columnas de línea con el maestro de impuestos que corresponde (por defecto `SalesTaxCodes`). */ -export function lineColumnsFor(taxResource: string): readonly GridColumn[] { - if (taxResource === 'SalesTaxCodes') return DOCUMENT_LINE_COLUMNS; - return DOCUMENT_LINE_COLUMNS.map((c) => +export function lineColumnsFor(taxResource: string, columns: readonly GridColumn[] = DOCUMENT_LINE_COLUMNS): readonly GridColumn[] { + if (taxResource === 'SalesTaxCodes') return columns; + return columns.map((c) => c.key === 'TaxCode' && c.lookup ? { ...c, lookup: { ...c.lookup, config: { ...c.lookup.config, resource: taxResource } } } : c, diff --git a/apps/web/src/app/features/documents/screen-kit.ts b/apps/web/src/app/features/documents/screen-kit.ts index da52a6e..3584ecd 100644 --- a/apps/web/src/app/features/documents/screen-kit.ts +++ b/apps/web/src/app/features/documents/screen-kit.ts @@ -2,7 +2,8 @@ import { MENU } from '@basic-erp/sap-types'; import { formatNumber } from '../../core/format'; import type { ListFilter } from '../../core/sl/list-filters'; import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types'; -import { type DocumentLayout, FIELDS, type HeaderField, marketingLayout } from './document-layouts'; +import type { GridColumn } from '../../shared/grid/grid.types'; +import { type DocumentLayout, FIELDS, type FieldColumns, type HeaderField, marketingLayout } from './document-layouts'; /** Pantalla de un documento: lista de los últimos ingresados + formulario (nuevo / ver-editar). */ export interface DocumentScreen { @@ -113,6 +114,10 @@ export interface MarketingExtras { columns?: readonly ListColumn[]; headerLeft?: readonly HeaderField[]; headerRight?: readonly HeaderField[]; + /** Cabecera completa (reemplaza a la base). */ + header?: FieldColumns; + /** Columnas de línea de este documento (de `LINE_COLUMNS`); sin ellas, todas. */ + lineColumns?: readonly GridColumn[]; } /** Documentos tributarios: la cabecera suma el folio (en el Service Layer `FolioPrefixString` y `FolioNumber`). */ @@ -127,7 +132,14 @@ export const marketing = ( extras: MarketingExtras = {}, ): DocumentScreen => ({ path, - layout: marketingLayout({ partnerLabel: partner, taxResource: 'SalesTaxCodes', left: extras.headerLeft, right: extras.headerRight }), + layout: marketingLayout({ + partnerLabel: partner, + taxResource: 'SalesTaxCodes', + left: extras.headerLeft, + right: extras.headerRight, + header: extras.header, + lineColumns: extras.lineColumns, + }), resource, createLabel, orderby: 'DocEntry desc',