From b3b47c34a366edd38b0895463fc7acb7c505bf10 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 14 Oct 2026 15:28:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(web):=20extensiones=20por=20cliente=20(cam?= =?UTF-8?q?pos=20est=C3=A1ndar=20adicionales=20y=20U=5F*)=20por=20m=C3=B3d?= =?UTF-8?q?ulo,=20y=20campo=20de=20selecci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 4 +- .../features/documents/client-extensions.ts | 22 +++ .../documents/compras/compras.extensions.ts | 17 ++ .../documents/document-form.page.html | 11 ++ .../features/documents/document-form.page.ts | 4 +- .../documents/document-layouts.spec.ts | 6 +- .../features/documents/document-layouts.ts | 4 +- .../features/documents/document-screens.ts | 11 +- .../inventario/inventario.extensions.ts | 17 ++ .../documents/screen-extensions.spec.ts | 156 ++++++++++++++++++ .../features/documents/screen-extensions.ts | 63 +++++++ .../documents/ventas/ventas.extensions.ts | 21 +++ 12 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/features/documents/client-extensions.ts create mode 100644 apps/web/src/app/features/documents/compras/compras.extensions.ts create mode 100644 apps/web/src/app/features/documents/inventario/inventario.extensions.ts create mode 100644 apps/web/src/app/features/documents/screen-extensions.spec.ts create mode 100644 apps/web/src/app/features/documents/screen-extensions.ts create mode 100644 apps/web/src/app/features/documents/ventas/ventas.extensions.ts diff --git a/CLAUDE.md b/CLAUDE.md index 175e115..1e3a377 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` 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. diff --git a/apps/web/src/app/features/documents/client-extensions.ts b/apps/web/src/app/features/documents/client-extensions.ts new file mode 100644 index 0000000..8afdc06 --- /dev/null +++ b/apps/web/src/app/features/documents/client-extensions.ts @@ -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), +}; diff --git a/apps/web/src/app/features/documents/compras/compras.extensions.ts b/apps/web/src/app/features/documents/compras/compras.extensions.ts new file mode 100644 index 0000000..72258a8 --- /dev/null +++ b/apps/web/src/app/features/documents/compras/compras.extensions.ts @@ -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/` 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 = {}; diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index a8ff1c2..a584722 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -111,6 +111,17 @@ (valueChange)="setHeader(f.key, $event)" /> } + @case ('select') { + + } @case ('number') { { @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; } 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 e3b5f90..4758732 100644 --- a/apps/web/src/app/features/documents/document-layouts.spec.ts +++ b/apps/web/src/app/features/documents/document-layouts.spec.ts @@ -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']); diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 032b83f..a53e882 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -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 }] : [], ); diff --git a/apps/web/src/app/features/documents/document-screens.ts b/apps/web/src/app/features/documents/document-screens.ts index 2307b67..2aeaee1 100644 --- a/apps/web/src/app/features/documents/document-screens.ts +++ b/apps/web/src/app/features/documents/document-screens.ts @@ -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); diff --git a/apps/web/src/app/features/documents/inventario/inventario.extensions.ts b/apps/web/src/app/features/documents/inventario/inventario.extensions.ts new file mode 100644 index 0000000..1b81391 --- /dev/null +++ b/apps/web/src/app/features/documents/inventario/inventario.extensions.ts @@ -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/` 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 = {}; diff --git a/apps/web/src/app/features/documents/screen-extensions.spec.ts b/apps/web/src/app/features/documents/screen-extensions.spec.ts new file mode 100644 index 0000000..dc2f5c6 --- /dev/null +++ b/apps/web/src/app/features/documents/screen-extensions.spec.ts @@ -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; + 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) { + 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('select')!.value).toBe('W'); + expect([...el().querySelectorAll('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[] }).DocumentLines[0]).toMatchObject({ ItemCode: 'A1', U_Lote: 'L-80' }); + req.flush(null); + await tick(); + ctrl.expectOne('/api/sl/Orders/5').flush(order); + }); +}); diff --git a/apps/web/src/app/features/documents/screen-extensions.ts b/apps/web/src/app/features/documents/screen-extensions.ts new file mode 100644 index 0000000..66e11eb --- /dev/null +++ b/apps/web/src/app/features/documents/screen-extensions.ts @@ -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>; + +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 ?? [])], + }; + }); +} diff --git a/apps/web/src/app/features/documents/ventas/ventas.extensions.ts b/apps/web/src/app/features/documents/ventas/ventas.extensions.ts new file mode 100644 index 0000000..11ccddc --- /dev/null +++ b/apps/web/src/app/features/documents/ventas/ventas.extensions.ts @@ -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/` 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 = {};