diff --git a/CLAUDE.md b/CLAUDE.md index 563084e..15110d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,7 +58,7 @@ Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compart 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 también lo envían (la unidad de venta o de compra del artículo, o la de inventario; `itemColumnWithUom`), y no se envía la unidad manual (-1). Probado: orden de compra 2884 con `SU01`, que no tiene 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). +**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 al final 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). **Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. 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. **Campos de contabilidad analítica y numeración (estándar):** cabecera de todos los documentos: `Series` (la fila «Número» lleva la lista de series del documento, pedida con la acción `SeriesService_GetDocumentSeries` del Service Layer —registrada en el proxy como acción de solo consulta—, filtrada por el subtipo de la pantalla; no se puede cambiar una vez creado; sin elegir, SAP usa la por defecto), `Indicator` y `Project` (lista de proyectos). Detalle de ventas y compras: `PriceAfterVAT` (el precio con IVA; solo se envía a SAP el que el usuario escribió, `Price` o `PriceAfterVAT`, nunca los dos), `CostingCode` a `CostingCode5` y `ProjectCode` (cada uno con su **ventana modal** de selección: reglas de distribución por dimensión y proyectos), `AccountCode` (plan de cuentas) y `BaseType`/`BaseEntry`/`BaseLine` (solo lectura: el documento de origen de una línea copiada). Inventario y solicitudes de compra llevan en el detalle solo las cinco dimensiones y el proyecto. `OBJECT_TYPES` (`document-objects.ts`) da el tipo de objeto de SAP de cada pantalla. El subtipo de las series de boleta (`IB`) y el resto de `SERIES_SUBTYPE` no está verificado contra SBODemoCL (solo hay series `--`). diff --git a/apps/web/src/app/features/documents/document-analytics.spec.ts b/apps/web/src/app/features/documents/document-analytics.spec.ts index 1b5cb88..740086c 100644 --- a/apps/web/src/app/features/documents/document-analytics.spec.ts +++ b/apps/web/src/app/features/documents/document-analytics.spec.ts @@ -210,3 +210,38 @@ describe('copiar conserva dimensiones y proyecto', () => { expect(objectLabel(-1)).toBe(''); }); }); + +describe('el proyecto de la cabecera se elige de su ventana modal', () => { + it('el campo tiene su botón «…» y abre la lista de proyectos', async () => { + TestBed.resetTestingModule(); + const screen = DOCUMENT_SCREENS.find((s) => s.path === '/ventas/ordenes')!; + const route = { + snapshot: { data: { screen, title: 'Documento' }, paramMap: convertToParamMap({}) }, + paramMap: of(convertToParamMap({})), + }; + await TestBed.configureTestingModule({ + imports: [DocumentFormPage], + providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }], + }).compileComponents(); + const ctrl = TestBed.inject(HttpTestingController); + const fixture = TestBed.createComponent(DocumentFormPage); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const row = [...el.querySelectorAll('sbo-lookup-field')].find((f) => f.querySelector('label')?.textContent?.trim() === 'Proyecto')!; + expect(row, 'campo Proyecto con lista').toBeDefined(); + (row.querySelector('button.sbo-btn') as HTMLElement).click(); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + const req = ctrl.expectOne((r) => r.url === '/api/sl/Projects'); + expect(req.request.params.get('$filter')).toContain(`Active eq 'tYES'`); + req.flush({ value: [{ Code: 'PE001', Name: 'Proyecto uno' }], 'odata.count': '1' }); + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + (el.querySelector('.dlg tbody tr') as HTMLElement).click(); + fixture.detectChanges(); + expect(fixture.componentInstance['header']()['Project']).toBe('PE001'); + }); +}); 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 3436b2c..72cf026 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -91,6 +91,17 @@ (picked)="onPartner($event)" /> } + @case ('lookup') { + + } @case ('warehouse') { { 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', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', + 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', 'LineTotal', 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', - 'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal', + 'BaseType', 'BaseEntry', 'BaseLine', ]); // lo que se envía por línea: las columnas editables (sin el total ni las de solo lectura) y la unidad de medida expect(l.lines.fields).toEqual([ diff --git a/apps/web/src/app/features/documents/line-columns.spec.ts b/apps/web/src/app/features/documents/line-columns.spec.ts index 82b1f56..0c6d141 100644 --- a/apps/web/src/app/features/documents/line-columns.spec.ts +++ b/apps/web/src/app/features/documents/line-columns.spec.ts @@ -25,9 +25,9 @@ describe('columnas estándar de línea', () => { it('son las de una línea de documento de SAP (con TaxCode)', () => { expect(keys).toEqual([ - 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', + 'ItemCode', 'ItemDescription', 'Quantity', 'WarehouseCode', 'Price', 'PriceAfterVAT', 'DiscountPercent', 'TaxCode', 'LineTotal', 'CostingCode', 'CostingCode2', 'CostingCode3', 'CostingCode4', 'CostingCode5', 'ProjectCode', 'AccountCode', - 'BaseType', 'BaseEntry', 'BaseLine', 'LineTotal', + 'BaseType', 'BaseEntry', 'BaseLine', ]); }); diff --git a/apps/web/src/app/features/documents/line-columns.ts b/apps/web/src/app/features/documents/line-columns.ts index 0f5883e..6442658 100644 --- a/apps/web/src/app/features/documents/line-columns.ts +++ b/apps/web/src/app/features/documents/line-columns.ts @@ -152,6 +152,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [ apply: (tax, line) => ({ ...line, TaxCode: tax['Code'] }), }, }, + { key: 'LineTotal', title: 'Total', type: 'number', decimals: 2, width: '110px', readonly: true }, dimension(1), dimension(2), dimension(3), @@ -175,7 +176,6 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [ { key: 'BaseType', title: 'Doc. base', width: '150px', readonly: true, format: (l) => (hasBase(l) ? objectLabel(l['BaseType']) : '') }, { key: 'BaseEntry', title: 'Nº base', width: '80px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseEntry']) : '') }, { key: 'BaseLine', title: 'Línea base', width: '90px', mono: true, readonly: true, format: (l) => (hasBase(l) ? String(l['BaseLine'] ?? '') : '') }, - { 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)!; diff --git a/apps/web/src/app/features/documents/screen-extensions.spec.ts b/apps/web/src/app/features/documents/screen-extensions.spec.ts index a1c86ce..d771769 100644 --- a/apps/web/src/app/features/documents/screen-extensions.spec.ts +++ b/apps/web/src/app/features/documents/screen-extensions.spec.ts @@ -35,10 +35,11 @@ describe('extensiones por cliente', () => { 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', () => { + it('agregan columnas de línea al final (después de las base) 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(keys.at(-1)).toBe('U_Lote'); + expect(keys.indexOf('LineTotal')).toBeLessThan(keys.indexOf('U_Lote')); expect(s.layout.lines.fields).toContain('U_Lote'); expect(s.columns.at(-1)).toMatchObject({ key: 'U_Obra' }); }); diff --git a/apps/web/src/app/features/documents/screen-extensions.ts b/apps/web/src/app/features/documents/screen-extensions.ts index 04264f7..70a6b01 100644 --- a/apps/web/src/app/features/documents/screen-extensions.ts +++ b/apps/web/src/app/features/documents/screen-extensions.ts @@ -14,7 +14,7 @@ export interface ScreenExtension { 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. */ + /** Columnas de línea nuevas, al final; se envían a SAP. */ lineColumns?: readonly GridColumn[]; /** Columnas nuevas de la lista, al final. */ listColumns?: readonly ListColumn[]; @@ -32,9 +32,7 @@ const withFields = (base: FieldColumns, left?: readonly HeaderField[], right?: r 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 columns = [...layout.columns, ...extra]; const fields = [...new Set([...layout.lines.fields, ...lineFieldsOf(extra)])]; const extended: DocumentLayout = { ...layout, diff --git a/apps/web/src/app/shared/grid/lines-grid.html b/apps/web/src/app/shared/grid/lines-grid.html index 4385260..b14fccd 100644 --- a/apps/web/src/app/shared/grid/lines-grid.html +++ b/apps/web/src/app/shared/grid/lines-grid.html @@ -10,7 +10,7 @@
- +
diff --git a/apps/web/src/app/shared/grid/lines-grid.spec.ts b/apps/web/src/app/shared/grid/lines-grid.spec.ts index 35d1892..8d4de98 100644 --- a/apps/web/src/app/shared/grid/lines-grid.spec.ts +++ b/apps/web/src/app/shared/grid/lines-grid.spec.ts @@ -422,3 +422,22 @@ describe('LinesGrid', () => { }); }); }); + +describe('LinesGrid: ancho de la tabla', () => { + it('el ancho mínimo es la suma de los anchos de sus columnas, para no apretarlas', async () => { + await TestBed.configureTestingModule({ + imports: [Host], + providers: [provideHttpClient(), provideHttpClientTesting()], + }).compileComponents(); + const fixture = TestBed.createComponent(Host); + fixture.componentInstance.columns = [ + { key: 'A', title: 'A', width: '100px' }, + { key: 'B', title: 'B', width: '60px' }, + { key: 'C', title: 'C' }, // sin ancho: reserva 220 + ]; + fixture.detectChanges(); + await fixture.whenStable(); + const table = (fixture.nativeElement as HTMLElement).querySelector('table.grid')!; + expect(parseFloat(table.style.minWidth)).toBe(100 + 60 + 220 + 34 + 30); + }); +}); diff --git a/apps/web/src/app/shared/grid/lines-grid.ts b/apps/web/src/app/shared/grid/lines-grid.ts index 3bcb7f3..5f131fc 100644 --- a/apps/web/src/app/shared/grid/lines-grid.ts +++ b/apps/web/src/app/shared/grid/lines-grid.ts @@ -55,6 +55,19 @@ export class LinesGrid { protected readonly lookupTarget = signal(null); protected readonly multiOpen = signal(false); protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config); + + /** + * Ancho mínimo de la tabla: la suma de los anchos de sus columnas (las que no declaran uno, como la + * descripción, reservan 220 px), más el número de fila y el botón de quitar. Si no cabe en pantalla, la + * grilla se desplaza en horizontal en lugar de apretar las columnas hasta cortar su contenido. + */ + protected readonly tableMinWidth = computed(() => { + const px = (c: GridColumn): number => { + const w = parseFloat(c.width ?? ''); + return Number.isFinite(w) ? w : 220; + }; + return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30)); + }); protected readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple); protected cellText(line: GridLine, column: GridColumn): string {
#