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:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 12:25:01 -05:00
1 parent a5592fac76
commit abc1cc4ac6
16 files changed
+752 -10

No files matched your search

@@ -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;
}