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

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