feat(web): cada documento de ventas y compras puede elegir sus columnas de línea y su cabecera

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 15:16:22 -05:00
1 parent c5368c7ad0
commit 1f212e2b30
5 files changed
+91 -14

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`).
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.
@@ -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);
});
});
@@ -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,
@@ -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,
@@ -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',