feat(web): extensiones por cliente (campos estándar adicionales y U_*) por módulo, y campo de selección

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-14 15:28:03 -05:00
1 parent 1f212e2b30
commit b3b47c34a3
12 files changed
+326 -10

No files matched your search

+3 -1
View File
@@ -56,7 +56,9 @@ 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. 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.
**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). 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.
@@ -0,0 +1,22 @@
import { COMPRAS_EXTENSIONS } from './compras/compras.extensions';
import { INVENTARIO_EXTENSIONS } from './inventario/inventario.extensions';
import type { ScreenExtensions } from './screen-extensions';
import { VENTAS_EXTENSIONS } from './ventas/ventas.extensions';
/** Cada módulo solo puede extender sus propias pantallas: una ruta ajena es un error, no un descuido silencioso. */
function ownedBy(prefix: string, extensions: ScreenExtensions): ScreenExtensions {
for (const path of Object.keys(extensions)) {
if (!path.startsWith(prefix)) throw new Error(`Extensión en «${prefix}…» con la ruta «${path}», que es de otro módulo`);
}
return extensions;
}
/**
* Lo que el cliente agrega a las pantallas, reunido de los archivos de extensiones de cada módulo
* (`ventas/ventas.extensions.ts`, `compras/compras.extensions.ts`, `inventario/inventario.extensions.ts`).
*/
export const CLIENT_EXTENSIONS: ScreenExtensions = {
...ownedBy('/ventas/', VENTAS_EXTENSIONS),
...ownedBy('/compras/', COMPRAS_EXTENSIONS),
...ownedBy('/inventario/', INVENTARIO_EXTENSIONS),
};
@@ -0,0 +1,17 @@
import type { ScreenExtensions } from '../screen-extensions';
/**
* Campos que este cliente agrega a las pantallas de compras (solo rutas `/compras/…`). En `main` va
* vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa
* con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
*
* Ejemplo (los campos son de ejemplo):
*
* '/compras/facturas': {
* headerLeft: [{ type: 'text', key: 'U_Centro', label: 'Centro de costo' }],
* },
* '/compras/solicitudes': {
* lineColumns: [{ key: 'U_Justificacion', title: 'Justificación' }],
* },
*/
export const COMPRAS_EXTENSIONS: ScreenExtensions = {};
@@ -111,6 +111,17 @@
(valueChange)="setHeader(f.key, $event)"
/>
}
@case ('select') {
<sbo-select-field
[label]="f.label"
[required]="!!f.required"
[readonly]="readOnly()"
[options]="f.options"
placeholder=""
[value]="str(header()[f.key]) || null"
(valueChange)="setHeader(f.key, $event ?? '')"
/>
}
@case ('number') {
<sbo-number-field
[label]="f.label"
@@ -8,7 +8,7 @@ import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { formatNumber } from '../../core/format';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { DateField, LookupField, NumberField, SboField, TextField, TextInput, TextareaField } from '../../shared/form';
import { DateField, LookupField, NumberField, SboField, SelectField, TextField, TextInput, TextareaField } from '../../shared/form';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup } from '../../shared/value-help/value-help.types';
@@ -60,7 +60,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@Component({
selector: 'sbo-document-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
imports: [DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, RouterLink, SboField, SelectField, SboWindow, TextField, TextInput, TextareaField, Toolbar, ValueHelp],
templateUrl: './document-form.page.html',
styles: `
h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; }
@@ -10,7 +10,7 @@ import { LINE_COLUMNS, lineFieldsOf } from './line-columns';
import { transferRequestLayout } from './inventario/inventario.layouts';
import { MARKETING_LINES, createPayload, updatePayload, validateLayout } from './document-model';
import { DocumentFormPage } from './document-form.page';
import { DOCUMENT_SCREENS } from './document-screens';
import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens';
describe('modelo de los formularios sin socio', () => {
const transfer = transferRequestLayout;
@@ -36,8 +36,8 @@ describe('modelo de los formularios sin socio', () => {
expect(headerKeysOf(by('Orders'))).not.toContain('FolioNumber');
});
it('la cabecera y el pie tienen como máximo dos columnas y no usan campos de usuario', () => {
for (const s of DOCUMENT_SCREENS) {
it('el proyecto base no usa campos de usuario (los de cada cliente van en client-extensions.ts)', () => {
for (const s of BASE_SCREENS) {
for (const k of headerKeysOf(s.layout)) expect(k.startsWith('U_')).toBe(false);
for (const c of s.layout.columns) expect(c.key.startsWith('U_')).toBe(false);
expect(Object.keys(s.layout.header)).toEqual(['left', 'right']);
@@ -15,6 +15,8 @@ export type HeaderField =
| { type: 'date'; key: string; label: string; required?: boolean; missing?: string }
| { type: 'number'; key: string; label: string; decimals?: number }
| { type: 'textarea'; key: string; label: string }
/** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */
| { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string }
/** Abre la lista de almacenes. */
| { type: 'warehouse'; key: string; label: string; required?: boolean; missing?: string }
/** Código del socio de negocio (cliente o proveedor) con su lista; solo editable al crear. */
@@ -164,7 +166,7 @@ export const headerKeysOf = (layout: DocumentLayout): readonly string[] =>
/** Campos obligatorios de la cabecera, con el texto para el aviso ("Indique el cliente."). */
export const requiredHeaderOf = (layout: DocumentLayout): { key: string; label: string }[] =>
allFields(layout).flatMap((f) =>
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner') && f.required
(f.type === 'date' || f.type === 'warehouse' || f.type === 'partner' || f.type === 'select') && f.required
? [{ key: f.key, label: f.missing ?? f.label }]
: [],
);
@@ -2,12 +2,17 @@ import type { DocumentScreen } from './screen-kit';
import { COMPRAS_SCREENS } from './compras/compras.screens';
import { INVENTARIO_SCREENS } from './inventario/inventario.screens';
import { VENTAS_SCREENS } from './ventas/ventas.screens';
import { CLIENT_EXTENSIONS } from './client-extensions';
import { applyExtensions } from './screen-extensions';
// Lo que usan el resto de la aplicación y los tests (tipo, columnas y estados comunes).
export * from './screen-kit';
/**
* Todas las pantallas de documentos, agrupadas por módulo (una carpeta cada uno). Los maestros
* (socios de negocio, artículos) irán en su propia carpeta cuando existan.
* Las pantallas del proyecto base, agrupadas por módulo (una carpeta cada uno). Los maestros (socios
* de negocio, artículos) irán en su propia carpeta cuando existan.
*/
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = [...VENTAS_SCREENS, ...COMPRAS_SCREENS, ...INVENTARIO_SCREENS];
export const BASE_SCREENS: readonly DocumentScreen[] = [...VENTAS_SCREENS, ...COMPRAS_SCREENS, ...INVENTARIO_SCREENS];
/** Las pantallas con lo que cada cliente agrega en `client-extensions.ts` (en `main`, igual al base). */
export const DOCUMENT_SCREENS: readonly DocumentScreen[] = applyExtensions(BASE_SCREENS, CLIENT_EXTENSIONS);
@@ -0,0 +1,17 @@
import type { ScreenExtensions } from '../screen-extensions';
/**
* Campos que este cliente agrega a las pantallas de inventario (solo rutas `/inventario/…`). En `main` va
* vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa
* con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
*
* Ejemplo (los campos son de ejemplo):
*
* '/inventario/transferencias': {
* headerRight: [{ type: 'text', key: 'U_Chofer', label: 'Chofer' }],
* },
* '/inventario/entradas': {
* lineColumns: [{ key: 'U_Origen', title: 'Origen' }],
* },
*/
export const INVENTARIO_EXTENSIONS: ScreenExtensions = {};
@@ -0,0 +1,156 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CLIENT_EXTENSIONS } from './client-extensions';
import { headerKeysOf } from './document-layouts';
import { DocumentFormPage } from './document-form.page';
import { BASE_SCREENS, DOCUMENT_SCREENS } from './document-screens';
import { type ScreenExtensions, applyExtensions } from './screen-extensions';
const by = (screens: readonly { path: string }[], path: string) => screens.find((s) => s.path === path)! as (typeof BASE_SCREENS)[number];
const EXT: ScreenExtensions = {
'/ventas/facturas': {
headerRight: [{ type: 'text', key: 'U_Obra', label: 'Obra' }],
footerLeft: [{ type: 'text', key: 'U_Nota', label: 'Nota interna' }],
lineColumns: [{ key: 'U_Lote', title: 'Lote', width: '90px' }],
listColumns: [{ key: 'U_Obra', title: 'Obra', searchable: true }],
},
'/inventario/entradas': { headerLeft: [{ type: 'text', key: 'U_Chofer', label: 'Chofer' }], lineColumns: [{ key: 'U_Origen', title: 'Origen' }] },
};
describe('extensiones por cliente', () => {
it('en main no agregan nada: las pantallas son las del base', () => {
expect(CLIENT_EXTENSIONS).toEqual({});
expect(DOCUMENT_SCREENS).toEqual(BASE_SCREENS);
});
it('agregan campos de cabecera y de pie al final y los envían a SAP', () => {
const s = by(applyExtensions(BASE_SCREENS, EXT), '/ventas/facturas');
expect(s.layout.header.right.at(-1)).toMatchObject({ key: 'U_Obra' });
expect(s.layout.footer.left.at(-1)).toMatchObject({ key: 'U_Nota' });
expect(headerKeysOf(s.layout)).toEqual(expect.arrayContaining(['U_Obra', 'U_Nota']));
});
it('agregan columnas de línea antes del total y las envían; y columnas de lista al final', () => {
const s = by(applyExtensions(BASE_SCREENS, EXT), '/ventas/facturas');
const keys = s.layout.columns.map((c) => c.key);
expect(keys.indexOf('U_Lote')).toBe(keys.indexOf('LineTotal') - 1);
expect(s.layout.lines.fields).toContain('U_Lote');
expect(s.columns.at(-1)).toMatchObject({ key: 'U_Obra' });
});
it('en un documento sin total las columnas van al final', () => {
const s = by(applyExtensions(BASE_SCREENS, EXT), '/inventario/entradas');
expect(s.layout.columns.at(-1)).toMatchObject({ key: 'U_Origen' });
});
it('solo cambia la pantalla extendida (aunque comparta layout con otra)', () => {
const out = applyExtensions(BASE_SCREENS, EXT);
expect(by(out, '/inventario/salidas')).toEqual(by(BASE_SCREENS, '/inventario/salidas'));
expect(by(out, '/ventas/ordenes')).toEqual(by(BASE_SCREENS, '/ventas/ordenes'));
expect(by(BASE_SCREENS, '/ventas/facturas').layout.columns.map((c) => c.key)).not.toContain('U_Lote');
});
it('una ruta que no existe es un error', () => {
expect(() => applyExtensions(BASE_SCREENS, { '/ventas/facturaz': { listColumns: [] } })).toThrow(/no existe la pantalla/);
});
});
describe('formulario con campos de cliente', () => {
let fixture: ComponentFixture<DocumentFormPage>;
let ctrl: HttpTestingController;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(doc: Record<string, unknown>) {
const screen = by(
applyExtensions(BASE_SCREENS, {
'/ventas/ordenes': {
headerLeft: [
{ type: 'select', key: 'U_Canal', label: 'Canal', options: [{ value: 'W', label: 'Web' }, { value: 'T', label: 'Tienda' }] },
],
headerRight: [{ type: 'text', key: 'U_Obra', label: 'Obra' }],
lineColumns: [{ key: 'U_Lote', title: 'Lote' }],
},
}),
'/ventas/ordenes',
);
const route = {
snapshot: { data: { screen, title: 'Órdenes de venta' }, paramMap: convertToParamMap({ docEntry: '5' }) },
paramMap: of(convertToParamMap({ docEntry: '5' })),
};
await TestBed.configureTestingModule({
imports: [DocumentFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(DocumentFormPage);
await tick();
ctrl.expectOne((r) => r.url === '/api/sl/SalesTaxCodes').flush({ value: [] });
ctrl.expectOne('/api/sl/Orders/5').flush(doc);
await tick();
}
const order = {
'odata.etag': 'W/"1"',
DocEntry: 5,
DocNum: 5,
CardCode: 'C1',
CardName: 'ACME',
DocDate: '2026-09-11T00:00:00Z',
DocDueDate: '2026-09-20T00:00:00Z',
DiscountPercent: 0,
DocumentStatus: 'bost_Open',
Cancelled: 'tNO',
U_Canal: 'W',
U_Obra: 'Torre 3',
DocumentLines: [{ LineNum: 0, ItemCode: 'A1', ItemDescription: 'Pieza', Quantity: 1, Price: 10, DiscountPercent: 0, TaxCode: '', LineTotal: 10, WarehouseCode: '01', U_Lote: 'L-77' }],
};
beforeEach(() => TestBed.resetTestingModule());
it('muestra el campo de usuario, el desplegable y la columna de línea con sus valores de SAP', async () => {
await open(order);
expect([...el().querySelectorAll('label')].map((l) => l.textContent?.trim())).toEqual(expect.arrayContaining(['Canal', 'Obra']));
expect(el().querySelector<HTMLSelectElement>('select')!.value).toBe('W');
expect([...el().querySelectorAll<HTMLInputElement>('input')].map((i) => i.value)).toEqual(expect.arrayContaining(['Torre 3', 'L-77']));
});
it('al modificar envía solo el campo de usuario que cambió', async () => {
await open(order);
page().setHeader('U_Canal', 'T');
page().setHeader('U_Obra', 'Torre 4');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.body).toEqual({ U_Canal: 'T', U_Obra: 'Torre 4' });
req.flush(null);
await tick();
ctrl.expectOne('/api/sl/Orders/5').flush(order);
});
it('al cambiar la columna de línea envía las líneas con el campo de usuario', async () => {
await open(order);
page().setLines([{ ...page().lines()[0], U_Lote: 'L-80' }]);
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect((req.request.body as { DocumentLines: Record<string, unknown>[] }).DocumentLines[0]).toMatchObject({ ItemCode: 'A1', U_Lote: 'L-80' });
req.flush(null);
await tick();
ctrl.expectOne('/api/sl/Orders/5').flush(order);
});
});
@@ -0,0 +1,63 @@
import type { GridColumn } from '../../shared/grid/grid.types';
import type { ListColumn } from '../../shared/list/list.types';
import type { DocumentLayout, FieldColumns, HeaderField } from './document-layouts';
import type { DocumentScreen } from './screen-kit';
import { lineFieldsOf } from './line-columns';
/**
* Lo que una rama de cliente agrega a una pantalla (campos estándar adicionales o de usuario `U_*`)
* sin tocar las pantallas base. Solo suma: no quita ni cambia lo que ya trae el base.
*/
export interface ScreenExtension {
/** Campos al final de cada columna de la cabecera o del pie. */
headerLeft?: readonly HeaderField[];
headerRight?: readonly HeaderField[];
footerLeft?: readonly HeaderField[];
footerRight?: readonly HeaderField[];
/** Columnas de línea nuevas (antes del total, si el documento lo tiene); se envían a SAP. */
lineColumns?: readonly GridColumn[];
/** Columnas nuevas de la lista, al final. */
listColumns?: readonly ListColumn[];
}
/** Extensiones por ruta de la pantalla (`/ventas/facturas`). */
export type ScreenExtensions = Readonly<Record<string, ScreenExtension>>;
const withFields = (base: FieldColumns, left?: readonly HeaderField[], right?: readonly HeaderField[]): FieldColumns => ({
left: [...base.left, ...(left ?? [])],
right: [...base.right, ...(right ?? [])],
});
function extendLayout(layout: DocumentLayout, ext: ScreenExtension): DocumentLayout {
const extra = ext.lineColumns ?? [];
const at = layout.columns.findIndex((c) => c.key === 'LineTotal');
const columns =
at < 0 ? [...layout.columns, ...extra] : [...layout.columns.slice(0, at), ...extra, ...layout.columns.slice(at)];
const fields = [...new Set([...layout.lines.fields, ...lineFieldsOf(extra)])];
return {
...layout,
header: withFields(layout.header, ext.headerLeft, ext.headerRight),
footer: withFields(layout.footer, ext.footerLeft, ext.footerRight),
columns,
lines: { ...layout.lines, fields },
};
}
/**
* Aplica las extensiones a las pantallas base. Una ruta que no existe es un error (un typo no debe
* pasar en silencio); las pantallas sin extensión quedan idénticas.
*/
export function applyExtensions(screens: readonly DocumentScreen[], extensions: ScreenExtensions): readonly DocumentScreen[] {
for (const path of Object.keys(extensions)) {
if (!screens.some((s) => s.path === path)) throw new Error(`client-extensions: no existe la pantalla «${path}»`);
}
return screens.map((s) => {
const ext = extensions[s.path];
if (!ext) return s;
return {
...s,
layout: extendLayout(s.layout, ext),
columns: [...s.columns, ...(ext.listColumns ?? [])],
};
});
}
@@ -0,0 +1,21 @@
import type { ScreenExtensions } from '../screen-extensions';
/**
* Campos que este cliente agrega a las pantallas de ventas (solo rutas `/ventas/…`). En `main` va
* vacío: el proyecto base solo lleva campos estándar de SAP. La rama `cliente/<nombre>` lo completa
* con campos estándar adicionales o de usuario (`U_*`) y no toca ningún otro archivo.
*
* Ejemplo (los campos son de ejemplo):
*
* '/ventas/facturas': {
* headerRight: [{ type: 'text', key: 'U_Obra', label: 'Obra' }],
* lineColumns: [{ key: 'U_Lote', title: 'Lote', width: '90px' }],
* listColumns: [{ key: 'U_Obra', title: 'Obra', searchable: true }],
* },
* '/ventas/ordenes': {
* headerLeft: [
* { type: 'select', key: 'U_Canal', label: 'Canal', options: [{ value: 'W', label: 'Web' }, { value: 'T', label: 'Tienda' }] },
* ],
* },
*/
export const VENTAS_EXTENSIONS: ScreenExtensions = {};