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

@@ -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',