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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user