feat(web): lotes y series en las líneas de entradas, salidas y transferencias de stock
Una ventana por línea con tabla editable; validación de suma de lotes y una serie por unidad. Probado contra SBODemoCL: entrada de inventario 156 con SU01 y su lote. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5592fac76
commit
abc1cc4ac6
16 files changed
+752
-10
No files matched your search
@@ -57,7 +57,7 @@ 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 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.
|
||||
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: 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 al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan 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). La alineación de una columna se declara con `align: 'left' | 'center' | 'right'` (listas y detalle; por defecto, derecha en las numéricas).
|
||||
|
||||
@@ -65,6 +65,8 @@ Pendiente, en orden:
|
||||
|
||||
**Movimientos de inventario:** cabecera con fecha de contabilización, fecha del documento (`TaxDate`), vencimiento (`DueDate`, solo traslados), `Reference2` y comentarios del asiento (`JournalMemo`); `Reference1` lo asigna SAP. Copiar de/a: la transferencia de stock se crea desde una solicitud de traslado (`COPY_FLOWS`); en las líneas de transferencia `BaseType` es un **enumerado de texto** de SAP (`InventoryTransferRequest`, `WarehouseTransfers`…; `STOCK_BASE_TYPES`, `hasBaseRef`), no el número de objeto de ventas y compras.
|
||||
|
||||
**Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo).
|
||||
|
||||
Flete hecho: fila «Flete» en el pie de ventas y compras (gasto adicional + importe; `freightPayload` lo guarda como `DocumentAdditionalExpenses`, reemplazando la colección; el catálogo `AdditionalExpenses` está registrado en el proxy; con varios gastos adicionales cargados en SAP solo se muestra). `freight: false` en `marketing(...)` lo quita de un documento. El toolbar del formulario ocupa todo el ancho del contenido (`<sbo-toolbar bleed>`). Falta: botón PDF. En ventas el flete falla en SBODemoCL ("Define the freight revenue account…"): el gasto adicional «Flete» solo tiene cuenta de gasto, no de ingresos (configuración de SAP, no del código); en compras funciona (orden de compra 2885).
|
||||
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). Pendiente en maestros: colecciones (direcciones, personas de contacto, precios por lista, almacenes por artículo), lotes/series, y cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
|
||||
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { DocumentFormPage } from './document-form.page';
|
||||
import { DOCUMENT_SCREENS } from './document-screens';
|
||||
|
||||
describe('entrada de inventario con un artículo por lote', () => {
|
||||
let fixture: ComponentFixture<DocumentFormPage>;
|
||||
let ctrl: HttpTestingController;
|
||||
let navigate: ReturnType<typeof vi.spyOn>;
|
||||
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();
|
||||
};
|
||||
const create = () => (el().querySelector('.actions .btn.main') as HTMLElement).click();
|
||||
const status = () => el().querySelector('.status')?.textContent ?? '';
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.resetTestingModule();
|
||||
const screen = DOCUMENT_SCREENS.find((s) => s.path === '/inventario/entradas')!;
|
||||
const route = {
|
||||
snapshot: { data: { screen, title: 'Entrada' }, paramMap: convertToParamMap({}) },
|
||||
paramMap: of(convertToParamMap({})),
|
||||
};
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DocumentFormPage],
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
|
||||
}).compileComponents();
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
|
||||
fixture = TestBed.createComponent(DocumentFormPage);
|
||||
await tick();
|
||||
ctrl.match((r) => r.url.includes('SeriesService')).forEach((r) => r.flush({ value: [] }));
|
||||
});
|
||||
|
||||
/** Escribe un código de artículo en la primera línea, como lo haría el usuario, y responde el maestro de artículos. */
|
||||
async function typeItem(item: Record<string, unknown>) {
|
||||
page().setLines([{ ItemCode: '', ItemDescription: '', Quantity: 5, WarehouseCode: '01', Price: null }]);
|
||||
await tick();
|
||||
const input = el().querySelector<HTMLInputElement>('input[data-r="0"][data-c="0"]')!;
|
||||
input.value = String(item['ItemCode']);
|
||||
input.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/sl/Items');
|
||||
expect(req.request.params.get('$select')).toContain('ManageBatchNumbers');
|
||||
req.flush({ value: [item], 'odata.count': '1' });
|
||||
await tick();
|
||||
}
|
||||
|
||||
it('al elegir un artículo por lote la línea pide sus lotes', async () => {
|
||||
await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES', ManageSerialNumbers: 'tNO', UoMGroupEntry: -1 });
|
||||
const buttons = [...el().querySelectorAll('.detbtn')].map((b) => b.textContent?.trim());
|
||||
expect(buttons).toEqual(['Indicar lotes']);
|
||||
});
|
||||
|
||||
it('un artículo sin lote ni serie no muestra botón', async () => {
|
||||
await typeItem({ ItemCode: 'A1', ItemName: 'Pieza', ManageBatchNumbers: 'tNO', ManageSerialNumbers: 'tNO' });
|
||||
expect(el().querySelectorAll('.detbtn')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('un artículo por serie pide series', async () => {
|
||||
await typeItem({ ItemCode: 'E00001', ItemName: 'Equipo', ManageBatchNumbers: 'tNO', ManageSerialNumbers: 'tYES' });
|
||||
expect([...el().querySelectorAll('.detbtn')].map((b) => b.textContent?.trim())).toEqual(['Indicar series']);
|
||||
});
|
||||
|
||||
it('no deja crear sin los lotes, ni con lotes que no suman la cantidad', async () => {
|
||||
await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES' });
|
||||
create();
|
||||
await tick();
|
||||
ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
|
||||
expect(status()).toContain('indique los lotes de SU01');
|
||||
page().setLines([{ ...page().lines()[0], BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }] }]);
|
||||
await tick();
|
||||
create();
|
||||
await tick();
|
||||
ctrl.expectNone((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
|
||||
expect(status()).toContain('los lotes suman 3 y la cantidad es 5');
|
||||
});
|
||||
|
||||
it('con los lotes completos crea la entrada con BatchNumbers dentro de la línea', async () => {
|
||||
await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES', UoMGroupEntry: -1 });
|
||||
page().setLines([{ ...page().lines()[0], BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3, ExpiryDate: '2027-03-01' }, { BatchNumber: 'L2', Quantity: 2 }] }]);
|
||||
await tick();
|
||||
expect(el().querySelector('.detbtn')?.textContent?.trim()).toBe('2 lotes · 5');
|
||||
create();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.method === 'POST' && !r.url.includes('SeriesService'));
|
||||
expect(req.request.url).toBe('/api/sl/InventoryGenEntries');
|
||||
const body = req.request.body as { DocumentLines: Record<string, unknown>[] };
|
||||
expect(body.DocumentLines).toEqual([
|
||||
{
|
||||
ItemCode: 'SU01',
|
||||
ItemDescription: 'Sutura',
|
||||
Quantity: 5,
|
||||
WarehouseCode: '01',
|
||||
BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3, ExpiryDate: '2027-03-01' }, { BatchNumber: 'L2', Quantity: 2 }],
|
||||
},
|
||||
]);
|
||||
req.flush({ DocEntry: 160, DocNum: 160 });
|
||||
await tick();
|
||||
expect(navigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cambiar el artículo de la línea descarta los lotes del anterior', async () => {
|
||||
await typeItem({ ItemCode: 'SU01', ItemName: 'Sutura', ManageBatchNumbers: 'tYES' });
|
||||
page().setLines([{ ...page().lines()[0], BatchNumbers: [{ BatchNumber: 'L1', Quantity: 5 }] }]);
|
||||
await tick();
|
||||
const input = el().querySelector<HTMLInputElement>('input[data-r="0"][data-c="0"]')!;
|
||||
input.value = 'A1';
|
||||
input.dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
ctrl.expectOne((r) => r.url === '/api/sl/Items').flush({ value: [{ ItemCode: 'A1', ItemName: 'Pieza', ManageBatchNumbers: 'tNO' }], 'odata.count': '1' });
|
||||
await tick();
|
||||
expect(page().lines()[0]['BatchNumbers']).toBeUndefined();
|
||||
expect(el().querySelectorAll('.detbtn')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('una entrada contabilizada muestra sus lotes en solo lectura', async () => {
|
||||
TestBed.resetTestingModule();
|
||||
const screen = DOCUMENT_SCREENS.find((s) => s.path === '/inventario/entradas')!;
|
||||
const route = { snapshot: { data: { screen, title: 'Entrada' }, paramMap: convertToParamMap({ docEntry: '154' }) }, paramMap: of(convertToParamMap({ docEntry: '154' })) };
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DocumentFormPage],
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
|
||||
}).compileComponents();
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
fixture = TestBed.createComponent(DocumentFormPage);
|
||||
await tick();
|
||||
ctrl.match((r) => r.url.includes('SeriesService')).forEach((r) => r.flush({ value: [] }));
|
||||
ctrl.expectOne('/api/sl/InventoryGenEntries/154').flush({
|
||||
DocEntry: 154, DocNum: 154, DocDate: '2026-09-30T00:00:00Z', DocumentStatus: 'bost_Open', Cancelled: 'tNO',
|
||||
DocumentLines: [{ LineNum: 0, ItemCode: 'SU01', ItemDescription: 'Sutura', Quantity: 3, WarehouseCode: '01', BatchNumbers: [{ BatchNumber: 'LT001', Quantity: 3, SystemSerialNumber: 1 }] }],
|
||||
});
|
||||
await tick();
|
||||
expect(el().querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote · 3');
|
||||
(el().querySelector('.detbtn') as HTMLElement).click();
|
||||
await tick();
|
||||
expect(el().querySelector<HTMLInputElement>('sbo-line-detail-dialog tbody input')!.value).toBe('LT001');
|
||||
expect(el().querySelector('sbo-line-detail-dialog footer .btn.main')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@
|
||||
[newLine]="newLine"
|
||||
[recalc]="recalc"
|
||||
[multiAdd]="multi"
|
||||
[details]="layout.details ?? []"
|
||||
[detailHint]="detailHint"
|
||||
/>
|
||||
|
||||
<div class="sbo-hdr" style="margin-top: 14px">
|
||||
|
||||
@@ -32,6 +32,7 @@ import { type ComputedSource, type HeaderField, PARTNER_COLUMNS, WAREHOUSE_LOOKU
|
||||
import { type CopyOrigin, copyFromDocument, copySources, copyTargets } from './document-copy';
|
||||
import { DOCUMENT_SCREENS, type DocumentScreen, docNum, documentStatus, menuLabel } from './document-screens';
|
||||
import { OBJECT_TYPES } from './document-objects';
|
||||
import { detailHint, validateLineDetails } from './line-details';
|
||||
import { ITEMS_MULTI_ADD } from './line-columns';
|
||||
|
||||
const today = (): string => new Date().toISOString().slice(0, 10);
|
||||
@@ -316,6 +317,7 @@ export class DocumentFormPage {
|
||||
private readonly itemColumn = this.layout.columns.find((c) => c.key === 'ItemCode') ?? this.layout.columns[0];
|
||||
protected readonly newLine = () => this.layout.newLine(this.header());
|
||||
/** Recalcula la línea con la tasa de cada impuesto (precio con y sin impuesto, total). */
|
||||
protected readonly detailHint = detailHint;
|
||||
protected readonly recalc = this.layout.recalc ? (line: GridLine, key: string) => this.layout.recalc!(line, key, this.rates()) : undefined;
|
||||
protected readonly multi: GridMultiAdd = {
|
||||
...ITEMS_MULTI_ADD,
|
||||
@@ -411,6 +413,7 @@ export class DocumentFormPage {
|
||||
protected async save(): Promise<void> {
|
||||
if (this.saving() || this.readOnly()) return;
|
||||
const errors = validateLayout(this.header(), this.lines(), requiredHeaderOf(this.layout), this.layout.requiredLine);
|
||||
if (this.layout.details?.length) errors.push(...validateLineDetails(this.lines()));
|
||||
if (this.hasFolio && !/^\d*$/.test(text(this.header()['FolioNumber']).trim())) errors.push('El número de folio debe ser numérico.');
|
||||
if (errors.length) {
|
||||
this.message.set({ kind: 'err', text: errors[0] });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GridColumn, GridLine } from '../../shared/grid/grid.types';
|
||||
import type { GridColumn, GridDetail, GridLine } from '../../shared/grid/grid.types';
|
||||
import type { ListColumn } from '../../shared/list/list.types';
|
||||
import type { VhLookup } from '../../shared/value-help/value-help.types';
|
||||
import type { DocRecord, LinesSpec } from './document-model';
|
||||
@@ -73,6 +73,8 @@ export interface DocumentLayout {
|
||||
newLine: (header: DocRecord) => GridLine;
|
||||
/** Recalcula una línea tras un cambio (total = cantidad × precio…); recibe la tasa de cada código de impuesto. */
|
||||
recalc?: (line: GridLine, changedKey: string, rates?: ReadonlyMap<string, number>) => GridLine;
|
||||
/** Detalles de línea (lotes, series) que este documento permite indicar. */
|
||||
details?: readonly GridDetail[];
|
||||
/** Campos de cabecera que se ven pero no se editan (un cliente puede querer verlos sin cambiarlos). */
|
||||
lockedKeys?: readonly string[];
|
||||
/** Subtipo con que SAP etiqueta las series de este documento (`--` = ninguno, `IB` = boleta…). */
|
||||
@@ -131,7 +133,14 @@ export function itemColumnWithUom(item: GridColumn, side: 'purchase' | 'sales'):
|
||||
...item,
|
||||
lookup: {
|
||||
...item.lookup!,
|
||||
apply: (picked, line) => ({ ...item.lookup!.apply(picked, line), UoMEntry: uomEntryOf(picked, side) }),
|
||||
apply: (picked, line) => ({
|
||||
...item.lookup!.apply(picked, line),
|
||||
UoMEntry: uomEntryOf(picked, side),
|
||||
// Lotes y series: si el artículo se gestiona por ellos la línea los pide; al cambiar de artículo se descartan.
|
||||
_manageBatches: picked['ManageBatchNumbers'] === 'tYES',
|
||||
_manageSerials: picked['ManageSerialNumbers'] === 'tYES',
|
||||
...(picked['ItemCode'] === line['ItemCode'] ? {} : { BatchNumbers: undefined, SerialNumbers: undefined }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GridLine } from '../../shared/grid/grid.types';
|
||||
import { hasBaseRef } from './document-objects';
|
||||
import { DETAIL_KEYS, detailRowsPayload } from './line-details';
|
||||
|
||||
/** Documento tal como lo devuelve / recibe el Service Layer. */
|
||||
export type DocRecord = Record<string, unknown>;
|
||||
@@ -139,6 +140,11 @@ export function linesPayload(lines: readonly GridLine[], spec: LinesSpec = MARKE
|
||||
.map((l) => {
|
||||
const out: DocRecord = {};
|
||||
for (const f of spec.fields) {
|
||||
if (DETAIL_KEYS.includes(f)) {
|
||||
const rows = detailRowsPayload(f, l[f]);
|
||||
if (rows.length) out[f] = rows;
|
||||
continue;
|
||||
}
|
||||
if (isEmpty(l[f])) continue;
|
||||
if (f === 'UoMEntry' && !(Number(l[f]) > 0)) continue; // -1 = unidad manual: SAP no admite enviarla
|
||||
out[f] = l[f];
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
} from '../document-layouts';
|
||||
import type { GridColumn } from '../../../shared/grid/grid.types';
|
||||
import { ANALYTIC_COLUMNS, LINE_COLUMNS, lineFieldsOf } from '../line-columns';
|
||||
import { DETAIL_KEYS, LINE_DETAILS } from '../line-details';
|
||||
|
||||
/** Entradas y salidas de mercancía: artículo, cantidad, almacén y precio (opcional: si falta, SAP usa el costo). */
|
||||
const goodsColumns: readonly GridColumn[] = [itemColumn, descriptionColumn, quantityColumn, { ...warehouseColumn, required: true }, priceColumn, ...ANALYTIC_COLUMNS];
|
||||
export const goodsLayout: DocumentLayout = {
|
||||
lines: { key: 'DocumentLines', fields: [...lineFieldsOf(goodsColumns), 'UoMEntry'], base: false },
|
||||
lines: { key: 'DocumentLines', fields: [...lineFieldsOf(goodsColumns), 'UoMEntry', ...DETAIL_KEYS], base: false },
|
||||
details: LINE_DETAILS,
|
||||
header: {
|
||||
left: [FIELDS.docDate, FIELDS.taxDate, FIELDS.indicator, FIELDS.project],
|
||||
right: [FIELDS.docNumber, FIELDS.reference2, FIELDS.journalMemo],
|
||||
@@ -50,7 +52,9 @@ const transfer = (immutable: boolean): DocumentLayout => {
|
||||
];
|
||||
return {
|
||||
// `base: true`: una transferencia hecha desde una solicitud de traslado envía la referencia al origen.
|
||||
lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry'], base: true },
|
||||
lines: { key: 'StockTransferLines', fields: [...lineFieldsOf(columns), 'UoMEntry', ...(immutable ? DETAIL_KEYS : [])], base: true },
|
||||
// Las transferencias de stock indican lotes y series; la solicitud de traslado no.
|
||||
...(immutable ? { details: LINE_DETAILS } : {}),
|
||||
header: {
|
||||
left: [FIELDS.fromWarehouse, FIELDS.toWarehouse, FIELDS.indicator, FIELDS.project],
|
||||
right: [FIELDS.docNumber, FIELDS.docDate, FIELDS.taxDate, FIELDS.dueDate, FIELDS.reference2, FIELDS.journalMemo],
|
||||
|
||||
@@ -98,7 +98,7 @@ export const DOCUMENT_LINE_COLUMNS: readonly GridColumn[] = [
|
||||
title: 'Artículos',
|
||||
resource: 'Items',
|
||||
orderby: 'ItemCode',
|
||||
extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'DefaultSalesUoMEntry', 'InventoryUoMEntry'],
|
||||
extraFields: ['UoMGroupEntry', 'DefaultPurchasingUoMEntry', 'DefaultSalesUoMEntry', 'InventoryUoMEntry', 'ManageBatchNumbers', 'ManageSerialNumbers'],
|
||||
columns: [
|
||||
{ key: 'ItemCode', title: 'Código', width: '150px', mono: true },
|
||||
{ key: 'ItemName', title: 'Descripción' },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createPayload } from './document-model';
|
||||
import { DOCUMENT_SCREENS } from './document-screens';
|
||||
import { BATCH_DETAIL, SERIAL_DETAIL, detailHint, detailRowsPayload, validateLineDetails } from './line-details';
|
||||
|
||||
describe('lotes y series de una línea', () => {
|
||||
it('el lote aplica si el artículo se gestiona por lote o la línea ya trae lotes; no si no', () => {
|
||||
expect(BATCH_DETAIL.applies({ _manageBatches: true })).toBe(true);
|
||||
expect(BATCH_DETAIL.applies({ BatchNumbers: [{ BatchNumber: 'L1', Quantity: 2 }] })).toBe(true);
|
||||
expect(BATCH_DETAIL.applies({ _manageBatches: false, BatchNumbers: [] })).toBe(false);
|
||||
expect(BATCH_DETAIL.applies({})).toBe(false);
|
||||
expect(SERIAL_DETAIL.applies({ _manageSerials: true })).toBe(true);
|
||||
expect(SERIAL_DETAIL.applies({ _manageBatches: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('el botón resume lo indicado', () => {
|
||||
expect(BATCH_DETAIL.summary({ _manageBatches: true })).toBe('Indicar lotes');
|
||||
expect(BATCH_DETAIL.summary({ BatchNumbers: [{ BatchNumber: 'A', Quantity: 3 }, { BatchNumber: 'B', Quantity: 2 }] })).toBe('2 lotes · 5');
|
||||
expect(BATCH_DETAIL.summary({ BatchNumbers: [{ BatchNumber: 'A', Quantity: 1 }] })).toBe('1 lote · 1');
|
||||
expect(SERIAL_DETAIL.summary({ _manageSerials: true })).toBe('Indicar series');
|
||||
expect(SERIAL_DETAIL.summary({ SerialNumbers: [{ InternalSerialNumber: 'S1' }, { InternalSerialNumber: 'S2' }] })).toBe('2 series');
|
||||
});
|
||||
|
||||
it('el aviso compara las filas en edición con la cantidad de la línea', () => {
|
||||
expect(detailHint(BATCH_DETAIL, { Quantity: 10 }, [{ Quantity: 6 }, { Quantity: 4 }])).toEqual({ text: 'Cantidad de la línea: 10 · los lotes suman: 10', bad: false });
|
||||
expect(detailHint(BATCH_DETAIL, { Quantity: 10 }, [{ Quantity: 6 }]).bad).toBe(true);
|
||||
expect(detailHint(SERIAL_DETAIL, { Quantity: 2 }, [{ InternalSerialNumber: 'S1' }, { InternalSerialNumber: '' }])).toEqual({ text: 'Series: 1 de 2', bad: true });
|
||||
});
|
||||
|
||||
it('a SAP solo van los campos propios, sin vacíos ni filas sin lote, con fechas AAAA-MM-DD y cada serie con cantidad 1', () => {
|
||||
expect(
|
||||
detailRowsPayload('BatchNumbers', [
|
||||
{ BatchNumber: 'L1', Quantity: 4, ExpiryDate: '2027-01-31T00:00:00Z', Notes: 'x', SystemSerialNumber: 7, ManufacturingDate: '' },
|
||||
{ BatchNumber: ' ', Quantity: 9 },
|
||||
]),
|
||||
).toEqual([{ BatchNumber: 'L1', Quantity: 4, ExpiryDate: '2027-01-31' }]);
|
||||
expect(detailRowsPayload('SerialNumbers', [{ InternalSerialNumber: 'S1', ManufacturerSerialNumber: 'M1' }])).toEqual([
|
||||
{ InternalSerialNumber: 'S1', ManufacturerSerialNumber: 'M1', Quantity: 1 },
|
||||
]);
|
||||
expect(detailRowsPayload('Otro', [{ A: 1 }])).toEqual([]);
|
||||
expect(detailRowsPayload('BatchNumbers', undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
describe('validación', () => {
|
||||
it('un artículo por lote necesita lotes que sumen la cantidad', () => {
|
||||
expect(validateLineDetails([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }])).toEqual(['Línea 1: indique los lotes de SU01.']);
|
||||
expect(validateLineDetails([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true, BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }] }])).toEqual([
|
||||
'Línea 1: los lotes suman 3 y la cantidad es 5.',
|
||||
]);
|
||||
expect(validateLineDetails([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true, BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }, { BatchNumber: 'L2', Quantity: 2 }] }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('un artículo por serie necesita una serie por unidad', () => {
|
||||
expect(validateLineDetails([{ ItemCode: 'A6', Quantity: 2, _manageSerials: true, SerialNumbers: [{ InternalSerialNumber: 'S1' }] }])).toEqual([
|
||||
'Línea 2: indique 2 número(s) de serie de A6 (hay 1).'.replace('Línea 2', 'Línea 1'),
|
||||
]);
|
||||
expect(validateLineDetails([{ ItemCode: 'A6', Quantity: 2, _manageSerials: true, SerialNumbers: [{ InternalSerialNumber: 'S1' }, { InternalSerialNumber: 'S2' }] }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('las líneas sin lotes ni series, y las vacías, no se validan; se numera solo lo real', () => {
|
||||
expect(validateLineDetails([{ ItemCode: '', Quantity: 1 }, { ItemCode: 'X', Quantity: 1 }])).toEqual([]);
|
||||
expect(validateLineDetails([{ ItemCode: '', Quantity: 1 }, { ItemCode: 'SU01', Quantity: 1, _manageBatches: true }])).toEqual(['Línea 1: indique los lotes de SU01.']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('qué documentos los llevan', () => {
|
||||
const lines = (p: string) => DOCUMENT_SCREENS.find((s) => s.path === p)!.layout;
|
||||
it('entradas, salidas y transferencias de stock sí; solicitudes de traslado, de compra y ventas no', () => {
|
||||
for (const p of ['/inventario/entradas', '/inventario/salidas', '/inventario/transferencias']) {
|
||||
expect(lines(p).details?.map((d) => d.key), p).toEqual(['BatchNumbers', 'SerialNumbers']);
|
||||
expect(lines(p).lines.fields, p).toEqual(expect.arrayContaining(['BatchNumbers', 'SerialNumbers']));
|
||||
}
|
||||
for (const p of ['/inventario/solicitudes-traslado', '/compras/solicitudes', '/ventas/ordenes']) {
|
||||
expect(lines(p).details, p).toBeUndefined();
|
||||
expect(lines(p).lines.fields, p).not.toContain('BatchNumbers');
|
||||
}
|
||||
});
|
||||
|
||||
it('los lotes viajan dentro de la línea, en la colección que espera SAP', () => {
|
||||
const entry = lines('/inventario/entradas').lines;
|
||||
const body = createPayload({}, [{ ItemCode: 'SU01', Quantity: 5, WarehouseCode: '01', _manageBatches: true, BatchNumbers: [{ BatchNumber: 'L1', Quantity: 5, ExpiryDate: '2027-01-01' }] }], entry);
|
||||
expect(body['DocumentLines']).toEqual([
|
||||
{ ItemCode: 'SU01', Quantity: 5, WarehouseCode: '01', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 5, ExpiryDate: '2027-01-01' }] },
|
||||
]);
|
||||
const transfer = createPayload({}, [{ ItemCode: 'A6', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02', SerialNumbers: [{ InternalSerialNumber: 'S1' }] }], lines('/inventario/transferencias').lines);
|
||||
expect(transfer['StockTransferLines']).toEqual([
|
||||
{ ItemCode: 'A6', Quantity: 1, FromWarehouseCode: '01', WarehouseCode: '02', SerialNumbers: [{ InternalSerialNumber: 'S1', Quantity: 1 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('la marca interna de «gestiona lotes» nunca se envía', () => {
|
||||
const body = createPayload({}, [{ ItemCode: 'A', Quantity: 1, WarehouseCode: '01', _manageBatches: true, _manageSerials: false }], lines('/inventario/entradas').lines);
|
||||
expect(JSON.stringify(body)).not.toContain('_manage');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { DetailColumn, GridDetail, GridLine } from '../../shared/grid/grid.types';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const num = (v: unknown): number => {
|
||||
const n = Number(v ?? 0);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
};
|
||||
const rowsOf = (line: GridLine, key: string): Row[] => (Array.isArray(line[key]) ? (line[key] as Row[]) : []);
|
||||
const sum = (rows: readonly Row[]): number => rows.reduce((total, r) => total + num(r['Quantity']), 0);
|
||||
const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
const batchColumns: readonly DetailColumn[] = [
|
||||
{ key: 'BatchNumber', title: 'Lote', type: 'text', required: true, width: '170px' },
|
||||
{ key: 'Quantity', title: 'Cantidad', type: 'number', required: true, width: '100px' },
|
||||
{ key: 'ExpiryDate', title: 'Vencimiento', type: 'date', width: '130px' },
|
||||
{ key: 'ManufacturingDate', title: 'Fabricación', type: 'date', width: '130px' },
|
||||
];
|
||||
|
||||
const serialColumns: readonly DetailColumn[] = [
|
||||
{ key: 'InternalSerialNumber', title: 'Nº de serie', type: 'text', required: true, width: '170px' },
|
||||
{ key: 'ManufacturerSerialNumber', title: 'Serie del fabricante', type: 'text', width: '170px' },
|
||||
{ key: 'ExpiryDate', title: 'Vencimiento', type: 'date', width: '130px' },
|
||||
{ key: 'ManufacturingDate', title: 'Fabricación', type: 'date', width: '130px' },
|
||||
];
|
||||
|
||||
/** Lotes de una línea (`BatchNumbers`): aplica si el artículo se gestiona por lote o la línea ya trae lotes. */
|
||||
export const BATCH_DETAIL: GridDetail = {
|
||||
key: 'BatchNumbers',
|
||||
title: 'Lotes',
|
||||
applies: (l) => l['_manageBatches'] === true || rowsOf(l, 'BatchNumbers').length > 0,
|
||||
columns: batchColumns,
|
||||
summary: (l) => {
|
||||
const rows = rowsOf(l, 'BatchNumbers');
|
||||
return rows.length ? `${plural(rows.length, 'lote', 'lotes')} · ${sum(rows)}` : 'Indicar lotes';
|
||||
},
|
||||
};
|
||||
|
||||
/** Números de serie de una línea (`SerialNumbers`): uno por unidad. */
|
||||
export const SERIAL_DETAIL: GridDetail = {
|
||||
key: 'SerialNumbers',
|
||||
title: 'Números de serie',
|
||||
applies: (l) => l['_manageSerials'] === true || rowsOf(l, 'SerialNumbers').length > 0,
|
||||
columns: serialColumns,
|
||||
summary: (l) => {
|
||||
const rows = rowsOf(l, 'SerialNumbers');
|
||||
return rows.length ? plural(rows.length, 'serie', 'series') : 'Indicar series';
|
||||
},
|
||||
};
|
||||
|
||||
export const LINE_DETAILS: readonly GridDetail[] = [BATCH_DETAIL, SERIAL_DETAIL];
|
||||
export const DETAIL_KEYS: readonly string[] = LINE_DETAILS.map((d) => d.key);
|
||||
|
||||
/** Aviso bajo la tabla de la ventana de lotes o series, según lo que lleva la línea y las filas en edición. */
|
||||
export function detailHint(detail: GridDetail, line: GridLine, rows: readonly Row[]): { text: string; bad: boolean } {
|
||||
const expected = num(line['Quantity']);
|
||||
if (detail.key === 'SerialNumbers') {
|
||||
const filled = rows.filter((r) => String(r['InternalSerialNumber'] ?? '').trim() !== '').length;
|
||||
return { text: `Series: ${filled} de ${expected}`, bad: filled !== expected };
|
||||
}
|
||||
const total = sum(rows);
|
||||
return { text: `Cantidad de la línea: ${expected} · los lotes suman: ${total}`, bad: total !== expected };
|
||||
}
|
||||
|
||||
/** Campos de cada fila que se envían a SAP. */
|
||||
const DETAIL_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||
BatchNumbers: ['BatchNumber', 'Quantity', 'ExpiryDate', 'ManufacturingDate'],
|
||||
SerialNumbers: ['InternalSerialNumber', 'ManufacturerSerialNumber', 'ExpiryDate', 'ManufacturingDate'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Filas listas para SAP: solo los campos propios del detalle (no los que SAP agrega al leer, como
|
||||
* `SystemSerialNumber`), sin vacíos, y sin filas sin lote o sin serie. Cada serie lleva `Quantity: 1`.
|
||||
*/
|
||||
export function detailRowsPayload(key: string, rows: unknown): Row[] {
|
||||
const fields = DETAIL_FIELDS[key];
|
||||
if (!fields || !Array.isArray(rows)) return [];
|
||||
const id = key === 'SerialNumbers' ? 'InternalSerialNumber' : 'BatchNumber';
|
||||
return (rows as Row[])
|
||||
.filter((r) => String(r[id] ?? '').trim() !== '')
|
||||
.map((r) => {
|
||||
const out: Row = {};
|
||||
for (const f of fields) {
|
||||
const v = r[f];
|
||||
if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) continue;
|
||||
out[f] = typeof v === 'string' && /T00:00:00/.test(v) ? v.slice(0, 10) : v;
|
||||
}
|
||||
if (key === 'SerialNumbers') out['Quantity'] = 1;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Errores de lotes y series que se pueden detectar sin preguntarle a SAP: una línea con artículo por lote
|
||||
* necesita lotes que sumen su cantidad, y por serie, una serie por unidad.
|
||||
*/
|
||||
export function validateLineDetails(lines: readonly GridLine[]): string[] {
|
||||
const errors: string[] = [];
|
||||
lines
|
||||
.filter((l) => String(l['ItemCode'] ?? '').trim() !== '')
|
||||
.forEach((l, i) => {
|
||||
const quantity = num(l['Quantity']);
|
||||
if (BATCH_DETAIL.applies(l)) {
|
||||
const rows = detailRowsPayload('BatchNumbers', l['BatchNumbers']);
|
||||
if (!rows.length) errors.push(`Línea ${i + 1}: indique los lotes de ${String(l['ItemCode'])}.`);
|
||||
else if (sum(rows) !== quantity) errors.push(`Línea ${i + 1}: los lotes suman ${sum(rows)} y la cantidad es ${quantity}.`);
|
||||
}
|
||||
if (SERIAL_DETAIL.applies(l)) {
|
||||
const rows = detailRowsPayload('SerialNumbers', l['SerialNumbers']);
|
||||
if (rows.length !== quantity) errors.push(`Línea ${i + 1}: indique ${quantity} número(s) de serie de ${String(l['ItemCode'])} (hay ${rows.length}).`);
|
||||
}
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
@@ -43,3 +43,30 @@ export interface GridMultiAdd {
|
||||
lookup: VhLookup;
|
||||
toLine: (picked: ListRow) => GridLine;
|
||||
}
|
||||
|
||||
/** Una columna de las filas de un detalle de línea (lotes, series). */
|
||||
export interface DetailColumn {
|
||||
key: string;
|
||||
title: string;
|
||||
type: 'text' | 'number' | 'date';
|
||||
width?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detalle de una línea: una lista de filas propias de la línea (los lotes o los números de serie de un
|
||||
* artículo) que se edita en una ventana aparte, abierta desde un botón de la grilla.
|
||||
*/
|
||||
export interface GridDetail {
|
||||
/** Campo de la línea que guarda las filas (`BatchNumbers`, `SerialNumbers`). */
|
||||
key: string;
|
||||
/** Título de la ventana y de la columna de la grilla. */
|
||||
title: string;
|
||||
/** Si el detalle aplica a esta línea (p. ej. el artículo se gestiona por lote). */
|
||||
applies: (line: GridLine) => boolean;
|
||||
columns: readonly DetailColumn[];
|
||||
/** Texto del botón de la grilla ("2 lotes · 10"). */
|
||||
summary: (line: GridLine) => string;
|
||||
/** Aviso bajo la tabla ("La suma de las cantidades debe ser 10"). */
|
||||
hint?: (line: GridLine) => string;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, linkedSignal, output } from '@angular/core';
|
||||
import { formatNumber, parseDecimal } from '../../core/format';
|
||||
import type { DetailColumn } from './grid.types';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** `2026-01-05T00:00:00Z` → `2026-01-05` (el formato del selector de fecha). */
|
||||
const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10) : '');
|
||||
|
||||
/**
|
||||
* Ventana con las filas de un detalle de línea (lotes, números de serie): tabla editable con «Añadir fila»
|
||||
* y «Quitar». Solo emite `saved` al aceptar; cancelar descarta los cambios.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-line-detail-dialog',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="overlay">
|
||||
<div class="dlg" role="dialog" aria-modal="true" [attr.aria-label]="title()" (keydown.escape)="closed.emit()">
|
||||
<header>
|
||||
<h2>{{ title() }}</h2>
|
||||
@if (subtitle()) {
|
||||
<span class="sub">{{ subtitle() }}</span>
|
||||
}
|
||||
</header>
|
||||
<div class="body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="rn">#</th>
|
||||
@for (c of columns(); track c.key) {
|
||||
<th [style.width]="c.width" [class.req]="c.required">{{ c.title }}</th>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<th class="del"></th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows(); track $index; let i = $index) {
|
||||
<tr>
|
||||
<td class="rn">{{ i + 1 }}</td>
|
||||
@for (c of columns(); track c.key) {
|
||||
<td>
|
||||
<input
|
||||
[type]="c.type === 'date' ? 'date' : 'text'"
|
||||
autocomplete="off"
|
||||
[class.num]="c.type === 'number'"
|
||||
[readOnly]="readonly()"
|
||||
[attr.data-r]="i"
|
||||
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
|
||||
[value]="text(row, c)"
|
||||
(change)="set(i, c, $any($event.target))"
|
||||
/>
|
||||
</td>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<td class="del"><button type="button" [attr.aria-label]="'Quitar la fila ' + (i + 1)" (click)="remove(i)">×</button></td>
|
||||
}
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="none">
|
||||
<td [attr.colspan]="columns().length + 2">{{ readonly() ? 'Sin filas.' : 'Sin filas. Pulse «Añadir fila».' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (hint()) {
|
||||
<p class="hint" [class.bad]="bad()">{{ hint() }}</p>
|
||||
}
|
||||
<footer>
|
||||
@if (!readonly()) {
|
||||
<button class="btn" type="button" (click)="add()">+ Añadir fila</button>
|
||||
}
|
||||
<span class="sp"></span>
|
||||
<button class="btn" type="button" (click)="closed.emit()">{{ readonly() ? 'Cerrar' : 'Cancelar' }}</button>
|
||||
@if (!readonly()) {
|
||||
<button class="btn main" type="button" (click)="saved.emit(clean())">Aceptar</button>
|
||||
}
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.overlay { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; padding: 16px; background: var(--overlay); }
|
||||
.dlg { display: flex; flex-direction: column; width: min(720px, 100%); max-height: 80vh; background: var(--win); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; box-shadow: var(--shadow-pop); }
|
||||
header { display: flex; align-items: baseline; gap: 10px; padding: 8px 12px; background: var(--panel); border-bottom: 1px solid var(--line); }
|
||||
h2 { margin: 0; font-size: 14px; font-weight: 600; }
|
||||
.sub { font-size: 12px; color: var(--quiet); }
|
||||
.body { overflow: auto; padding: 8px 12px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
|
||||
th.req::after { content: ' *'; color: var(--err); }
|
||||
td { padding: 0 2px; height: 27px; border: 1px solid var(--line-soft); }
|
||||
td.rn, th.rn { width: 30px; text-align: center; color: var(--quiet); }
|
||||
td.del, th.del { width: 30px; text-align: center; }
|
||||
input { width: 100%; height: 22px; padding: 1px 4px; background: transparent; border: 1px solid transparent; border-radius: var(--radius); }
|
||||
input.num { text-align: right; }
|
||||
input:hover:not([readonly]) { border-color: var(--field-b); }
|
||||
input:focus { background: var(--field); border-color: var(--focus); outline: none; }
|
||||
td.del button { padding: 2px 6px; background: none; border: none; color: var(--quiet); font-size: 15px; line-height: 1; cursor: pointer; border-radius: var(--radius); }
|
||||
td.del button:hover { color: var(--err); background: var(--err-bg); }
|
||||
tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
|
||||
.hint { margin: 0; padding: 6px 12px; font-size: 12px; color: var(--quiet); border-top: 1px solid var(--line-soft); }
|
||||
.hint.bad { color: var(--err); }
|
||||
footer { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--line); background: var(--panel); }
|
||||
.sp { flex: 1; }
|
||||
`,
|
||||
})
|
||||
export class LineDetailDialog {
|
||||
readonly title = input.required<string>();
|
||||
readonly subtitle = input('');
|
||||
readonly columns = input.required<readonly DetailColumn[]>();
|
||||
readonly initial = input<readonly Row[]>([]);
|
||||
readonly readonly = input(false);
|
||||
/** Texto bajo la tabla, calculado por quien abre la ventana con las filas en edición. */
|
||||
readonly hintFor = input<((rows: readonly Row[]) => { text: string; bad: boolean }) | undefined>(undefined);
|
||||
|
||||
readonly saved = output<Row[]>();
|
||||
readonly closed = output<void>();
|
||||
|
||||
protected readonly rows = linkedSignal<Row[]>(() => this.initial().map((r) => ({ ...r })));
|
||||
protected readonly hintState = computed(() => this.hintFor()?.(this.rows()) ?? { text: '', bad: false });
|
||||
protected readonly hint = computed(() => this.hintState().text);
|
||||
protected readonly bad = computed(() => this.hintState().bad);
|
||||
|
||||
protected text(row: Row, c: DetailColumn): string {
|
||||
const v = row[c.key];
|
||||
if (c.type === 'date') return dateOnly(v);
|
||||
if (c.type === 'number') return v === null || v === undefined || v === '' ? '' : formatNumber(v, undefined);
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
}
|
||||
|
||||
protected add(): void {
|
||||
this.rows.update((rs) => [...rs, {}]);
|
||||
}
|
||||
|
||||
protected remove(index: number): void {
|
||||
this.rows.update((rs) => rs.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
protected set(index: number, c: DetailColumn, input: HTMLInputElement): void {
|
||||
const raw = input.value;
|
||||
let value: unknown = raw;
|
||||
if (c.type === 'number') {
|
||||
const parsed = raw.trim() === '' ? null : parseDecimal(raw);
|
||||
if (raw.trim() !== '' && parsed === null) {
|
||||
input.value = this.text(this.rows()[index], c); // no es un número: se conserva el anterior
|
||||
return;
|
||||
}
|
||||
value = parsed;
|
||||
}
|
||||
this.rows.update((rs) => rs.map((r, i) => (i === index ? { ...r, [c.key]: value } : r)));
|
||||
}
|
||||
|
||||
/** Las filas sin ningún dato no se guardan. */
|
||||
protected clean(): Row[] {
|
||||
return this.rows().filter((r) => this.columns().some((c) => r[c.key] !== null && r[c.key] !== undefined && r[c.key] !== ''));
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,26 @@ td.ro {
|
||||
.pick button:hover {
|
||||
background: var(--btn-h);
|
||||
}
|
||||
th.det,
|
||||
td.det {
|
||||
width: 130px;
|
||||
padding: 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.detbtn {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
padding: 0 6px;
|
||||
background: var(--btn);
|
||||
border: 1px solid var(--field-b);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detbtn:hover {
|
||||
background: var(--btn-h);
|
||||
}
|
||||
td.del,
|
||||
th.del {
|
||||
width: 30px;
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
@for (c of columns(); track c.key) {
|
||||
<th sboColResize [style.width]="c.width" [style.text-align]="alignOf(c)" [class.req]="c.required">{{ c.title }}</th>
|
||||
}
|
||||
@if (hasDetails()) {
|
||||
<th class="det">Lotes / Series</th>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<th class="del"><span class="sr">Quitar</span></th>
|
||||
}
|
||||
@@ -67,6 +70,13 @@
|
||||
}
|
||||
</td>
|
||||
}
|
||||
@if (hasDetails()) {
|
||||
<td class="det">
|
||||
@for (d of detailsOf(line); track d.key) {
|
||||
<button type="button" class="detbtn" [attr.aria-label]="d.title + ' de la línea ' + (i + 1)" (click)="openDetail(i, d)">{{ d.summary(line) }}</button>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
@if (!readonly()) {
|
||||
<td class="del">
|
||||
<button type="button" [attr.aria-label]="'Quitar la línea ' + (i + 1)" title="Quitar línea" (click)="remove(i)">×</button>
|
||||
@@ -75,7 +85,7 @@
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr class="none">
|
||||
<td [attr.colspan]="columns().length + (readonly() ? 1 : 2)">
|
||||
<td [attr.colspan]="columns().length + (readonly() ? 1 : 2) + (hasDetails() ? 1 : 0)">
|
||||
{{ readonly() ? 'El documento no tiene líneas.' : 'No hay líneas. Pulse «Añadir línea» para comenzar.' }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -84,6 +94,19 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (detailTarget(); as t) {
|
||||
<sbo-line-detail-dialog
|
||||
[title]="t.detail.title"
|
||||
[subtitle]="'Línea ' + (t.row + 1)"
|
||||
[columns]="t.detail.columns"
|
||||
[initial]="detailInitial()"
|
||||
[readonly]="readonly()"
|
||||
[hintFor]="detailHintFn()"
|
||||
(saved)="detailSaved($event)"
|
||||
(closed)="detailTarget.set(null)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (lookup(); as cfg) {
|
||||
<sbo-value-help
|
||||
[open]="true"
|
||||
|
||||
@@ -490,3 +490,93 @@ describe('LinesGrid: alineación de las columnas', () => {
|
||||
expect(cells).toEqual(['left', 'right', 'right', 'center', 'left']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinesGrid: detalles de línea (lotes)', () => {
|
||||
const BATCHES = {
|
||||
key: 'BatchNumbers',
|
||||
title: 'Lotes',
|
||||
applies: (l: GridLine) => l['_manageBatches'] === true || (Array.isArray(l['BatchNumbers']) && l['BatchNumbers'].length > 0),
|
||||
columns: [
|
||||
{ key: 'BatchNumber', title: 'Lote', type: 'text' as const },
|
||||
{ key: 'Quantity', title: 'Cantidad', type: 'number' as const },
|
||||
],
|
||||
summary: (l: GridLine) => (Array.isArray(l['BatchNumbers']) && l['BatchNumbers'].length ? `${l['BatchNumbers'].length} lote(s)` : 'Indicar lotes'),
|
||||
};
|
||||
|
||||
@Component({
|
||||
imports: [LinesGrid],
|
||||
template: `<sbo-lines-grid [columns]="columns" [(lines)]="lines" [readonly]="readonly()" [newLine]="newLine" [details]="details" />`,
|
||||
})
|
||||
class DetailHost {
|
||||
columns = [{ key: 'ItemCode', title: 'Artículo' }, { key: 'Quantity', title: 'Cantidad', type: 'number' as const }];
|
||||
lines = signal<readonly GridLine[]>([]);
|
||||
readonly = signal(false);
|
||||
newLine = () => ({});
|
||||
details = [BATCHES];
|
||||
}
|
||||
|
||||
async function make(lines: GridLine[], readonly = false) {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({ imports: [DetailHost], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
|
||||
const fixture = TestBed.createComponent(DetailHost);
|
||||
fixture.componentInstance.lines.set(lines);
|
||||
fixture.componentInstance.readonly.set(readonly);
|
||||
const tick = async () => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
await tick();
|
||||
return { fixture, el: fixture.nativeElement as HTMLElement, tick };
|
||||
}
|
||||
|
||||
it('solo las líneas a las que aplica el detalle muestran su botón', async () => {
|
||||
const { el } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }, { ItemCode: 'A1', Quantity: 1 }]);
|
||||
const buttons = [...el.querySelectorAll<HTMLButtonElement>('.detbtn')];
|
||||
expect(buttons.map((b) => b.textContent?.trim())).toEqual(['Indicar lotes']);
|
||||
expect(el.querySelector('th.det')?.textContent).toContain('Lotes / Series');
|
||||
});
|
||||
|
||||
it('el botón abre la ventana, se añaden filas y al aceptar quedan en la línea', async () => {
|
||||
const { fixture, el, tick } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }]);
|
||||
(el.querySelector('.detbtn') as HTMLElement).click();
|
||||
await tick();
|
||||
expect(el.querySelector('sbo-line-detail-dialog [role=dialog]')).toBeTruthy();
|
||||
(el.querySelector('sbo-line-detail-dialog footer .btn') as HTMLElement).click(); // + Añadir fila
|
||||
await tick();
|
||||
const inputs = el.querySelectorAll<HTMLInputElement>('sbo-line-detail-dialog tbody input');
|
||||
inputs[0].value = 'LT-9';
|
||||
inputs[0].dispatchEvent(new Event('change'));
|
||||
inputs[1].value = '5';
|
||||
inputs[1].dispatchEvent(new Event('change'));
|
||||
await tick();
|
||||
(el.querySelector('sbo-line-detail-dialog footer .btn.main') as HTMLElement).click(); // Aceptar
|
||||
await tick();
|
||||
expect(el.querySelector('sbo-line-detail-dialog')).toBeNull();
|
||||
expect(fixture.componentInstance.lines()[0]['BatchNumbers']).toEqual([{ BatchNumber: 'LT-9', Quantity: 5 }]);
|
||||
expect(el.querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote(s)');
|
||||
});
|
||||
|
||||
it('cancelar descarta los cambios', async () => {
|
||||
const { fixture, el, tick } = await make([{ ItemCode: 'SU01', Quantity: 5, _manageBatches: true }]);
|
||||
(el.querySelector('.detbtn') as HTMLElement).click();
|
||||
await tick();
|
||||
(el.querySelector('sbo-line-detail-dialog footer .btn') as HTMLElement).click();
|
||||
await tick();
|
||||
const cancel = [...el.querySelectorAll<HTMLElement>('sbo-line-detail-dialog footer .btn')].find((b) => b.textContent?.trim() === 'Cancelar')!;
|
||||
cancel.click();
|
||||
await tick();
|
||||
expect(fixture.componentInstance.lines()[0]['BatchNumbers']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('en solo lectura se ven los lotes pero no se editan', async () => {
|
||||
const { el, tick } = await make([{ ItemCode: 'SU01', Quantity: 3, BatchNumbers: [{ BatchNumber: 'LT001', Quantity: 3 }] }], true);
|
||||
expect(el.querySelector('.detbtn')?.textContent?.trim()).toBe('1 lote(s)');
|
||||
(el.querySelector('.detbtn') as HTMLElement).click();
|
||||
await tick();
|
||||
const input = el.querySelector<HTMLInputElement>('sbo-line-detail-dialog tbody input')!;
|
||||
expect(input.value).toBe('LT001');
|
||||
expect(input.readOnly).toBe(true);
|
||||
expect(el.querySelector('sbo-line-detail-dialog footer .btn.main')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,8 @@ import { SlLookupApi } from '../../core/sl/sl-lookup';
|
||||
import type { ListRow } from '../list/list.types';
|
||||
import { ColResize } from '../table/col-resize';
|
||||
import { ValueHelp } from '../value-help/value-help';
|
||||
import type { GridColumn, GridLine, GridMultiAdd } from './grid.types';
|
||||
import type { GridColumn, GridDetail, GridLine, GridMultiAdd } from './grid.types';
|
||||
import { LineDetailDialog } from './line-detail-dialog';
|
||||
|
||||
interface LookupTarget {
|
||||
row: number;
|
||||
@@ -31,7 +32,7 @@ interface LookupTarget {
|
||||
@Component({
|
||||
selector: 'sbo-lines-grid',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ValueHelp, ColResize],
|
||||
imports: [ValueHelp, ColResize, LineDetailDialog],
|
||||
templateUrl: './lines-grid.html',
|
||||
styleUrl: './lines-grid.css',
|
||||
})
|
||||
@@ -45,6 +46,10 @@ export class LinesGrid {
|
||||
/** Recalcula la línea tras un cambio (ej. total = cantidad × precio). */
|
||||
readonly recalc = input<((line: GridLine, changedKey: string) => GridLine) | undefined>(undefined);
|
||||
readonly multiAdd = input<GridMultiAdd>();
|
||||
/** Detalles de línea (lotes, series): una columna de botones que abre su ventana de edición. */
|
||||
readonly details = input<readonly GridDetail[]>([]);
|
||||
/** Dado el detalle y las filas en edición, el aviso bajo la tabla de la ventana (ej. «suman 8 de 10»). */
|
||||
readonly detailHint = input<((detail: GridDetail, line: GridLine, rows: readonly Record<string, unknown>[]) => { text: string; bad: boolean }) | undefined>(undefined);
|
||||
readonly addLabel = input('Añadir línea');
|
||||
/** Avisos de la grilla (ej. un código escrito que no existe), para la barra de estado del formulario. */
|
||||
readonly notice = output<string>();
|
||||
@@ -53,8 +58,22 @@ export class LinesGrid {
|
||||
private readonly injector = inject(Injector);
|
||||
|
||||
protected readonly lookupTarget = signal<LookupTarget | null>(null);
|
||||
protected readonly detailTarget = signal<{ row: number; detail: GridDetail } | null>(null);
|
||||
protected readonly detailHintFn = computed(() => {
|
||||
const t = this.detailTarget();
|
||||
const hint = this.detailHint();
|
||||
const line = t ? this.lines()[t.row] : undefined;
|
||||
return t && hint && line ? (rows: readonly Record<string, unknown>[]) => hint(t.detail, line, rows) : undefined;
|
||||
});
|
||||
protected readonly multiOpen = signal(false);
|
||||
protected readonly lookup = computed(() => this.lookupTarget()?.column.lookup?.config);
|
||||
protected readonly hasDetails = computed(() => this.details().length > 0);
|
||||
/** Filas con que se abre la ventana (estable mientras no cambie la línea: la ventana edita una copia). */
|
||||
protected readonly detailInitial = computed<readonly Record<string, unknown>[]>(() => {
|
||||
const t = this.detailTarget();
|
||||
const line = t ? this.lines()[t.row] : undefined;
|
||||
return (line?.[t?.detail.key ?? ''] as Record<string, unknown>[] | undefined) ?? [];
|
||||
});
|
||||
|
||||
/**
|
||||
* Ancho mínimo de la tabla: la suma de los anchos de sus columnas (las que no declaran uno, como la
|
||||
@@ -66,7 +85,7 @@ export class LinesGrid {
|
||||
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));
|
||||
return this.columns().reduce((sum, c) => sum + px(c), 34 + (this.readonly() ? 0 : 30) + (this.hasDetails() ? 130 : 0));
|
||||
});
|
||||
protected readonly lookupMultiple = computed(() => !!this.lookupTarget()?.column.lookup?.multiple);
|
||||
|
||||
@@ -231,6 +250,24 @@ export class LinesGrid {
|
||||
if (!focus()) afterNextRender(() => void focus(), { injector: this.injector });
|
||||
}
|
||||
|
||||
// --- detalles de línea (lotes, series) ----------------------------------------------------
|
||||
|
||||
/** Los detalles que aplican a una línea. */
|
||||
protected detailsOf(line: GridLine): readonly GridDetail[] {
|
||||
return this.details().filter((d) => d.applies(line));
|
||||
}
|
||||
|
||||
protected openDetail(row: number, detail: GridDetail): void {
|
||||
this.detailTarget.set({ row, detail });
|
||||
}
|
||||
|
||||
protected detailSaved(rows: Record<string, unknown>[]): void {
|
||||
const t = this.detailTarget();
|
||||
if (!t) return;
|
||||
this.lines.update((ls) => ls.map((l, i) => (i === t.row ? { ...l, [t.detail.key]: rows } : l)));
|
||||
this.detailTarget.set(null);
|
||||
}
|
||||
|
||||
// --- listas de valores -----------------------------------------------------------------
|
||||
|
||||
protected openLookup(row: number, column: GridColumn): void {
|
||||
|
||||
Reference in new issue
Block a user