fix(web): la grilla de líneas no aprieta las columnas (ancho mínimo = suma de anchos), el total va tras el impuesto y el proyecto de la cabecera abre su ventana

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 11:09:47 -05:00
1 parent f66ef108cf
commit f077a61d08
11 files changed
+90 -13

No files matched your search

+1 -1
View File
@@ -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/<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).
**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 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 `--`).
@@ -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');
});
});
@@ -91,6 +91,17 @@
(picked)="onPartner($event)"
/>
}
@case ('lookup') {
<sbo-lookup-field
[label]="f.label"
[required]="!!f.required"
[readonly]="readOnly()"
[value]="str(header()[f.key])"
(valueChange)="setHeader(f.key, $event)"
[lookup]="f.lookup"
resolveOnLeave
/>
}
@case ('warehouse') {
<sbo-lookup-field
[label]="f.label"
@@ -206,9 +206,9 @@ describe('columnas de línea por documento', () => {
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([
@@ -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',
]);
});
@@ -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)!;
@@ -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' });
});
@@ -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,
+1 -1
View File
@@ -10,7 +10,7 @@
</div>
<div class="gridwrap">
<table class="grid">
<table class="grid" [style.min-width.px]="tableMinWidth()">
<thead>
<tr>
<th class="rn">#</th>
@@ -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<HTMLElement>('table.grid')!;
expect(parseFloat(table.style.minWidth)).toBe(100 + 60 + 220 + 34 + 30);
});
});
@@ -55,6 +55,19 @@ export class LinesGrid {
protected readonly lookupTarget = signal<LookupTarget | null>(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 {