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:
1 parent
1f212e2b30
commit
b3b47c34a3
12 files changed
+326
-10
No files matched your search
@@ -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 = {};
|
||||
Reference in new issue
Block a user