feat(tagumedica): flujo de lotes (inmovilizar, liberar y confirmar la liberación) con solicitudes de traslado, consultas a HANA y campos de usuario por su definición en SAP

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 15:52:01 -05:00
1 parent 72b3cd1d43
commit 5728fc8e5b
15 files changed
+1325 -13

No files matched your search

+8
View File
@@ -62,6 +62,14 @@ Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): e
El mini-erp mostraba el nombre del campo de la base junto a cada casillero (`dbField`). Aquí es general: el botón **Campos** de la barra superior (`FieldNames`, se recuerda en `localStorage` `erp_fields`) muestra, debajo de cada etiqueta y en la cabecera de las tablas (listas, líneas de documento, tablas de filas), el nombre con el que el Service Layer lee y guarda el campo (`ItemCode`, `U_SIS_ESTLOT`); sin activarlo, el nombre sale siempre como ayuda al pasar el cursor. Los campos lo reciben con `dbName` (`FieldBase`/`SboField`); los formularios de maestros y documentos lo pasan solos desde su configuración declarativa. Cubre los formularios de maestros, de documentos (incluidas las filas con varios controles: `FolioPrefixString · FolioNumber`, `DocCurrency · DocRate`…, con `fieldDbName` en `document-layouts.ts`), de pagos y de asientos (sus campos van escritos a mano en el HTML con `dbName="…"`: un campo nuevo debe llevarlo) y las cabeceras de tablas. Solo se muestran como campos de SAP los nombres que empiezan con mayúscula (`isSapField`): las columnas internas de la interfaz (`docNum`, `_Name`) no. El mini-erp mostraba el nombre del campo de la base junto a cada casillero (`dbField`). Aquí es general: el botón **Campos** de la barra superior (`FieldNames`, se recuerda en `localStorage` `erp_fields`) muestra, debajo de cada etiqueta y en la cabecera de las tablas (listas, líneas de documento, tablas de filas), el nombre con el que el Service Layer lee y guarda el campo (`ItemCode`, `U_SIS_ESTLOT`); sin activarlo, el nombre sale siempre como ayuda al pasar el cursor. Los campos lo reciben con `dbName` (`FieldBase`/`SboField`); los formularios de maestros y documentos lo pasan solos desde su configuración declarativa. Cubre los formularios de maestros, de documentos (incluidas las filas con varios controles: `FolioPrefixString · FolioNumber`, `DocCurrency · DocRate`…, con `fieldDbName` en `document-layouts.ts`), de pagos y de asientos (sus campos van escritos a mano en el HTML con `dbName="…"`: un campo nuevo debe llevarlo) y las cabeceras de tablas. Solo se muestran como campos de SAP los nombres que empiezan con mayúscula (`isSapField`): las columnas internas de la interfaz (`docNum`, `_Name`) no.
## TAGUMEDICA: flujo de lotes (solo en `cliente/tagumedica`)
Tres pantallas del menú Inventario, migradas del mini-erp (`features/client/lotes/`, registradas con los puntos de extensión de cliente): **Inmovilizar lotes**, **Liberar lotes** y **Confirmar liberación de lote**. Crean y modifican **solicitudes de traslado** (`InventoryTransferRequests`) con campos de usuario de la tabla `OWTQ`. Valores del negocio en `lotes-flujo.config.ts` (almacén de inmovilizados `302`, aprobados `201`, `U_SIS_MOTALM` 107 «INMOVILIZADO DT» / 108 «LIBERACIÓN DT», movimiento `XTD`, operación `11`, despacho `N`); lógica pura y probada en `lotes-flujo.ts`.
- **Inmovilizar / Liberar** (`lote-transfer.page`, una sola pantalla con `data.flow`): se busca el lote por su número (consulta `lot-stock` a HANA: artículo + almacén con stock), se marcan los almacenes con la cantidad (por defecto todo el stock; no se puede superar), se completa la solicitud y se crea con una línea por almacén de origen y el lote en cada una (`BatchNumbers`). Inmovilizar: destino 302 y motivo, comentario y acción obligatorios, no se puede volver a mover lo que ya está en 302. Liberar: origen 302 (editable), destino 201, motivo 108 y solo el motivo obligatorio. La serie es opcional (sin elegir, SAP usa la por defecto); un error de SAP deja el panel con lo escrito.
- **Confirmar liberación** (`lote-release-confirm.page`): lista las solicitudes abiertas con `U_SIS_MOTALM = 108`; al elegir una se pide completa y, por cada línea, la consulta `lot-sources` (transferencias `OWTR` que llevaron el lote al almacén de inmovilizados, `OITL`/`ITL1`) da de qué almacenes vino; `allocateByOrigin` reparte la cantidad a prorrata (enteros; el último almacén absorbe el redondeo) y el usuario lo corrige. Al confirmar se reemplazan las líneas (`B1S-ReplaceCollectionsOnPatch`, la primera conserva su `LineNum`, las demás van sin él, una línea sin filas se quita). **Diferencia con el mini-erp:** aquí el reparto de cada línea debe sumar su cantidad.
- Los catálogos (movimiento de inventario, tipo de operación, despacho, motivo de almacén) salen de la definición de esos campos en SAP (`/api/sapdb/user-fields/OWTQ/…`: tablas de usuario `@SIS_MOVINV` y `@EXX_TIPOOPER` enlazadas, valores válidos de `SIS_TIPDESP` y `SIS_MOTALM`); no hay SQL propio para ellos. Permisos: las tres rutas de menú leen y modifican `InventoryTransferRequests` (`sl-resources.extensions.ts`) y ejecutan las consultas de `sapdb.extensions.ts`.
- **No se ha escrito nada en SAP desde estas pantallas** (las pruebas usan un Service Layer simulado): `SBO_TAGUMEDICA_21` es del cliente y no se escribe sin preguntar; `SBODemoCL` no tiene estos campos de usuario.
## Cómo verificar cambios de interfaz ## Cómo verificar cambios de interfaz
- Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP. - Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP.
@@ -0,0 +1,27 @@
import { SL_RESOURCE_MAP } from '../sl/sl-resources.js';
import { SAPDB_QUERIES } from './sapdb-queries.js';
import { bindNamed } from './sapdb-sql.js';
describe('consultas de TAGUMEDICA', () => {
it('registra las del flujo de lotes y cada una es de solo lectura (el registro lo valida al cargar)', () => {
for (const name of ['lot-stock', 'lot-sources']) {
expect(SAPDB_QUERIES.has(name), name).toBe(true);
}
});
it('el origen de un lote se busca con artículo, lote y almacén como parámetros', () => {
const q = SAPDB_QUERIES.get('lot-sources')!;
const b = bindNamed(q.sql.hana!, { item: 'A1', lot: 'L1', warehouse: '302' });
expect(b.args).toEqual(['A1', 'L1', '302']);
expect(b.sql).not.toContain(':');
});
it('las tres pantallas del flujo pueden leer y modificar las solicitudes de traslado', () => {
const r = SL_RESOURCE_MAP.get('InventoryTransferRequests')!;
for (const p of ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote']) {
expect(r.read as string[], p).toContain(p);
expect(r.write as string[], p).toContain(p);
}
expect(r.write as string[]).toContain('/inventario/solicitudes-traslado');
});
});
+49 -3
View File
@@ -1,7 +1,53 @@
import type { SapDbQuery } from './sapdb.types.js'; import type { SapDbQuery } from './sapdb.types.js';
const FLOWS = ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes'] as const;
/** /**
* Consultas a la base de SAP que agrega la rama de cada cliente (vacío en `main`): el SQL depende de sus tablas y de * Consultas a la base de SAP de TAGUMEDICA (HANA): el flujo de lotes (inmovilizar, liberar y confirmar la liberación).
* sus campos de usuario. Cada una declara su SQL por motor (`hana`), sus parámetros con nombre y el permiso de menú. * Dependen de sus tablas, por eso viven solo en esta rama. Los valores de los campos de usuario de la solicitud (movimiento,
* tipo de operación, despacho, motivo) no necesitan consulta propia: salen de `/api/sapdb/user-fields`.
*/ */
export const CLIENT_SAPDB_QUERIES: readonly SapDbQuery[] = []; export const CLIENT_SAPDB_QUERIES: readonly SapDbQuery[] = [
{
// Stock de un lote por número, en cada artículo y almacén (un mismo número de lote puede repetirse en varios artículos).
name: 'lot-stock',
title: 'Stock de un lote por número, artículo y almacén',
sql: {
hana: `SELECT T1."WhsCode" AS "WarehouseCode", T2."WhsName" AS "WarehouseName", T1."Quantity" AS "Quantity",
T1."CommitQty" AS "Committed", T1."ItemCode" AS "ItemCode", T3."ItemName" AS "ItemDescription"
FROM "OBTN" T0
INNER JOIN "OBTQ" T1 ON T1."MdAbsEntry" = T0."AbsEntry"
INNER JOIN "OWHS" T2 ON T2."WhsCode" = T1."WhsCode"
INNER JOIN "OITM" T3 ON T3."ItemCode" = T1."ItemCode"
WHERE T0."DistNumber" = :lot AND T1."Quantity" > 0
ORDER BY T3."ItemName", T2."WhsName"`,
},
params: { lot: { type: 'string' } },
menu: FLOWS,
maxRows: 500,
},
{
// De qué almacenes vino un lote hasta el almacén donde está hoy: transferencias ejecutadas (OWTR, DocType 67) que lo
// ingresaron, con el almacén del que salió cada una. El reparto a prorrata lo hace el frontend.
name: 'lot-sources',
title: 'Almacenes de origen de un lote que llegó a un almacén',
sql: {
hana: `WITH LOT AS (SELECT "AbsEntry" FROM "OBTN" WHERE "ItemCode" = :item AND "DistNumber" = :lot)
SELECT O."Whs" AS "WarehouseCode", SUM(I."Qty") AS "Quantity" FROM
(SELECT T1."DocEntry" AS "DocEntry", MAX(T0."Quantity") AS "Qty"
FROM "ITL1" T0 INNER JOIN "OITL" T1 ON T1."LogEntry" = T0."LogEntry"
WHERE T0."MdAbsEntry" IN (SELECT "AbsEntry" FROM LOT) AND T1."LocCode" = :warehouse
AND T1."StockEff" = 1 AND T0."Quantity" > 0 AND T1."DocType" = 67
GROUP BY T1."DocEntry") I
INNER JOIN
(SELECT T1."DocEntry" AS "DocEntry", MIN(T1."LocCode") AS "Whs"
FROM "ITL1" T0 INNER JOIN "OITL" T1 ON T1."LogEntry" = T0."LogEntry"
WHERE T0."MdAbsEntry" IN (SELECT "AbsEntry" FROM LOT) AND T1."DocType" = 67 AND T0."Quantity" < 0
GROUP BY T1."DocEntry") O ON O."DocEntry" = I."DocEntry"
GROUP BY O."Whs" ORDER BY O."Whs"`,
},
params: { item: { type: 'string' }, lot: { type: 'string' }, warehouse: { type: 'string' } },
menu: '/inventario/confirmar-liberacion-lote',
maxRows: 100,
},
];
+7 -1
View File
@@ -10,6 +10,12 @@ import type { SlResource } from './sl-resources.js';
* *
* `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`. * `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`.
*/ */
export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {}; export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {
// Inmovilizar y liberar crean la solicitud de traslado; confirmar la liberación la lee y la modifica.
InventoryTransferRequests: {
read: ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote'],
write: ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote'],
},
};
export const CLIENT_SL_RESOURCES: readonly SlResource[] = []; export const CLIENT_SL_RESOURCES: readonly SlResource[] = [];
+5 -1
View File
@@ -17,7 +17,11 @@ export const clientRoute = (path: string, title: string, loadComponent: NonNulla
* *
* clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/inmovilizar.page').then((m) => m.InmovilizarPage)), * clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/inmovilizar.page').then((m) => m.InmovilizarPage)),
*/ */
export const CLIENT_ROUTES: Routes = []; export const CLIENT_ROUTES: Routes = [
{ ...clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/lotes/lote-transfer.page').then((m) => m.LoteTransferPage)), data: { path: '/inventario/inmovilizar-lotes', title: 'Inmovilizar lotes', flow: 'inmovilizar' } },
{ ...clientRoute('/inventario/liberar-lotes', 'Liberar lotes', () => import('./features/client/lotes/lote-transfer.page').then((m) => m.LoteTransferPage)), data: { path: '/inventario/liberar-lotes', title: 'Liberar lotes', flow: 'liberar' } },
clientRoute('/inventario/confirmar-liberacion-lote', 'Confirmar liberación de lote', () => import('./features/client/lotes/lote-release-confirm.page').then((m) => m.LoteReleaseConfirmPage)),
];
/** Las rutas de menú que ya tienen pantalla de cliente (no se muestran como «en construcción»). */ /** Las rutas de menú que ya tienen pantalla de cliente (no se muestran como «en construcción»). */
export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`); export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`);
@@ -0,0 +1,123 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it } from 'vitest';
import { LoteReleaseConfirmPage } from './lote-release-confirm.page';
const PENDING = [{ DocEntry: 480, DocNum: 4123, DocDate: '2026-10-19T00:00:00Z', U_SIS_MOTEST: 'Aprobado por calidad', U_SIS_COMEST: '', U_SIS_ACCTOM: '' }];
const REQUEST = {
'odata.etag': 'W/"5"',
DocEntry: 480,
DocNum: 4123,
StockTransferLines: [
{ LineNum: 0, ItemCode: 'A1', Quantity: 3, WarehouseCode: '201', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 3 }] },
{ LineNum: 1, ItemCode: 'B2', Quantity: 2, WarehouseCode: '201', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L2', Quantity: 2 }] },
],
};
describe('LoteReleaseConfirmPage', () => {
let fixture: ComponentFixture<LoteReleaseConfirmPage>;
let ctrl: HttpTestingController;
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 isList = (r: { method: string; url: string }) => r.method === 'GET' && r.url === '/api/sl/InventoryTransferRequests';
async function open() {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({ imports: [LoteReleaseConfirmPage], providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(LoteReleaseConfirmPage);
await tick();
ctrl.expectOne(isList).flush({ value: PENDING });
await tick();
}
/** Elige la solicitud: se pide completa y, por cada línea, de qué almacenes vino su lote. */
async function select(origins: Record<string, { WarehouseCode: string; Quantity: string }[]> = { A1: [{ WarehouseCode: '207', Quantity: '2.000000' }, { WarehouseCode: '208', Quantity: '1.000000' }], B2: [] }) {
(el().querySelector('.grid tbody tr') as HTMLElement).click();
await tick();
ctrl.expectOne('/api/sl/InventoryTransferRequests/480').flush(REQUEST);
await tick();
for (const item of ['A1', 'B2']) {
const req = ctrl.expectOne((r) => r.url === '/api/sapdb/queries/lot-sources' && r.body.params.item === item);
expect(req.request.body.params).toMatchObject({ warehouse: '302' });
req.flush({ rows: origins[item] ?? [], truncated: false });
}
await tick();
}
beforeEach(open);
it('lista las solicitudes de liberación abiertas (motivo 108), las más recientes primero', async () => {
expect(el().querySelector('.grid tbody tr')?.textContent).toContain('4123');
expect(el().querySelector('.grid tbody tr')?.textContent).toContain('19/10/2026');
await tick();
});
it('al elegir una solicitud propone el reparto por los almacenes de los que vino el lote', async () => {
await select();
const d = page().drafts();
expect(d[0].allocations).toEqual([{ WarehouseCode: '207', Quantity: 2 }, { WarehouseCode: '208', Quantity: 1 }]);
expect(el().textContent).toContain('lote L1');
// la línea de la que no se encontró origen se avisa para agregarla a mano
expect(d[1].allocations).toEqual([]);
expect(el().textContent).toContain('no se encontró de qué almacén vino');
});
it('no deja confirmar si el reparto no suma la cantidad de la línea', async () => {
await select();
expect(page().canConfirm()).toBe(true); // la línea sin filas se quita a propósito
page().setAllocations(0, [{ WarehouseCode: '207', Quantity: 1 }]);
await tick();
expect(page().canConfirm()).toBe(false);
expect(el().textContent).toContain('suman 1 y la línea es de 3');
page().addAllocation(0);
await tick();
expect(page().canConfirm()).toBe(false); // la fila nueva está incompleta
expect(el().textContent).toContain('Complete el almacén y la cantidad de cada fila');
});
it('confirma reemplazando las líneas: la primera de cada una con su LineNum, las demás sin él', async () => {
await select();
page().setAllocations(1, [{ WarehouseCode: '201', Quantity: 2 }]);
await tick();
page().confirm();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.url).toBe('/api/sl/InventoryTransferRequests/480');
expect(req.request.headers.get('If-Match')).toBe('W/"5"');
expect(req.request.headers.get('B1S-ReplaceCollectionsOnPatch')).toBe('true');
expect(req.request.body).toEqual({
StockTransferLines: [
{ LineNum: 0, ItemCode: 'A1', Quantity: 2, WarehouseCode: '207', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 2 }] },
{ ItemCode: 'A1', Quantity: 1, WarehouseCode: '208', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 1 }] },
{ LineNum: 1, ItemCode: 'B2', Quantity: 2, WarehouseCode: '201', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L2', Quantity: 2 }] },
],
});
req.flush(null);
await tick();
expect(el().querySelector('.status')?.textContent).toContain('Solicitud 4123 actualizada');
// la solicitud confirmada sale de la lista pendiente
ctrl.expectOne(isList).flush({ value: [] });
await tick();
expect(el().textContent).toContain('No hay solicitudes de liberación pendientes');
});
it('un error de SAP al confirmar se muestra y deja el reparto para corregirlo', async () => {
await select();
page().setAllocations(1, [{ WarehouseCode: '201', Quantity: 2 }]);
page().confirm();
await tick();
ctrl.expectOne((r) => r.method === 'PATCH').flush({ message: 'El almacén está bloqueado' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.status')?.textContent).toContain('El almacén está bloqueado');
expect(page().drafts()).not.toBeNull();
});
});
@@ -0,0 +1,186 @@
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { apiErrorMessage } from '../../../core/api';
import { formatDate } from '../../../core/format';
import type { DetailColumn } from '../../../shared/grid/grid.types';
import { RowsTable } from '../../../shared/grid/rows-table';
import { SboWindow, type StatusMessage } from '../../../shared/window';
import { WAREHOUSE_LOOKUP } from '../../documents/document-layouts';
import { PENDING_LIBERATION } from './lotes-flujo.config';
import { type DraftLine, allocateByOrigin, confirmLines, draftLineError } from './lotes-flujo';
import { LotesFlujoApi } from './lotes-flujo-api';
type Row = Record<string, unknown>;
const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v));
/**
* «Confirmar liberación de lote» (TAGUMEDICA): las solicitudes de traslado que dejó «Liberar lotes» (motivo 108) salen del
* almacén de inmovilizados; aquí se confirma a qué almacenes vuelve cada lote. Por cada línea se averigua en la base de SAP
* de qué almacenes vino el lote y se propone el reparto a prorrata; el usuario lo corrige (agrega, quita o edita filas) y
* al confirmar se reemplazan las líneas de la solicitud.
*/
@Component({
selector: 'sbo-lote-release-confirm-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RowsTable, SboWindow],
template: `
<sbo-window title="Confirmar liberación de lote" [status]="status()">
<p class="hint">Solicitudes de traslado de liberación abiertas. Elija una para indicar a qué almacenes vuelve cada lote.</p>
<table class="grid">
<thead><tr><th>Solicitud</th><th>Fecha</th><th>Motivo del estado</th><th>Comentario</th><th>Acción a tomar</th></tr></thead>
<tbody>
@for (r of pending.value() ?? []; track r['DocEntry']) {
<tr tabindex="0" [class.sel]="r['DocEntry'] === docEntry()" (click)="select(+r['DocEntry']!)" (keydown.enter)="select(+r['DocEntry']!)">
<td class="code">{{ r['DocNum'] }}</td><td>{{ date(r['DocDate']) }}</td><td>{{ r['U_SIS_MOTEST'] }}</td><td>{{ r['U_SIS_COMEST'] }}</td><td>{{ r['U_SIS_ACCTOM'] }}</td>
</tr>
} @empty {
<tr class="none"><td colspan="5">{{ pending.isLoading() ? 'Cargando…' : 'No hay solicitudes de liberación pendientes.' }}</td></tr>
}
</tbody>
</table>
@if (drafts(); as lines) {
<h3>Solicitud {{ request()?.['DocNum'] }}: almacenes destino</h3>
@for (line of lines; track line.LineNum; let i = $index) {
<div class="line">
<p class="head">
<strong class="code">{{ line.ItemCode }}</strong> · lote <strong class="code">{{ line.Lot }}</strong> · {{ line.Quantity }} unidades salen del almacén {{ line.FromWarehouseCode }}
@if (noOrigin(i)) {
<span class="muted"> · no se encontró de qué almacén vino: agréguelo a mano</span>
}
</p>
<sbo-rows-table [columns]="columns" [rows]="rowsOf(line)" (rowsChange)="setAllocations(i, $event)" emptyText="Sin almacenes: la línea se quita de la solicitud." />
<p class="row">
<button type="button" class="btn" (click)="addAllocation(i)">+ Añadir almacén</button>
<span class="sum">Reparto: {{ sum(line) }} de {{ line.Quantity }}</span>
@if (lineError(line); as e) {
<span class="err" role="alert">{{ e }}</span>
}
</p>
</div>
}
} @else if (resolving()) {
<p class="hint">Buscando de qué almacenes vino cada lote…</p>
}
@if (drafts()) {
<button sboActions class="btn main" type="button" [disabled]="confirming() || !canConfirm()" (click)="confirm()">Confirmar liberación</button>
}
</sbo-window>
`,
styles: `
:host { display: block; }
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
.hint { margin: 0 0 8px; color: var(--quiet); }
.grid { width: 100%; border-collapse: collapse; }
.grid th { padding: 4px 8px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; text-align: left; }
.grid td { padding: 3px 8px; border: 1px solid var(--line-soft); }
.grid tbody tr { cursor: pointer; }
.grid tbody tr.sel td { background: var(--row-sel); }
.grid tr.none td { padding: 14px; text-align: center; color: var(--quiet); cursor: default; }
.code { font-family: var(--mono, monospace); }
.line { margin: 8px 0 14px; }
.head { margin: 0 0 4px; }
.row { display: flex; align-items: center; gap: 12px; margin: 4px 0 0; }
.sum { color: var(--quiet); }
.err { color: var(--err); }
.muted { color: var(--quiet); }
`,
})
export class LoteReleaseConfirmPage {
private readonly api = inject(LotesFlujoApi);
protected readonly pending = resource({ loader: () => this.api.pending(PENDING_LIBERATION) });
protected readonly docEntry = signal<number | null>(null);
protected readonly request = signal<Row | null>(null);
protected readonly drafts = signal<DraftLine[] | null>(null);
protected readonly resolving = signal(false);
protected readonly confirming = signal(false);
private readonly message = signal<StatusMessage | null>(null);
/** Líneas para las que la base no encontró de qué almacén vino el lote. */
private readonly withoutOrigin = signal<Set<number>>(new Set());
protected readonly date = formatDate;
protected readonly columns: readonly DetailColumn[] = [
{ key: 'WarehouseCode', title: 'Almacén destino', type: 'lookup', lookup: WAREHOUSE_LOOKUP, width: '180px' },
{ key: 'WarehouseName', title: 'Nombre', type: 'text', width: '260px', nameOf: { codeKey: 'WarehouseCode', resource: 'Warehouses', valueField: 'WarehouseCode', labelField: 'WarehouseName' } },
{ key: 'Quantity', title: 'Cantidad', type: 'number', decimals: 0, width: '110px' },
];
protected rowsOf = (line: DraftLine): Row[] => line.allocations as Row[];
protected noOrigin = (i: number): boolean => this.withoutOrigin().has(i) && !(this.drafts()?.[i]?.allocations.length ?? 0);
protected sum = (line: DraftLine): number => line.allocations.reduce((s, a) => s + (Number(a.Quantity) || 0), 0);
protected lineError = draftLineError;
/** Al elegir una solicitud: se pide completa (con los lotes de cada línea) y se propone el reparto con lo que dice la base. */
protected async select(docEntry: number): Promise<void> {
if (this.resolving()) return;
this.docEntry.set(docEntry);
this.drafts.set(null);
this.request.set(null);
this.message.set({ kind: 'info', text: 'Cargando la solicitud…' });
this.resolving.set(true);
try {
const req = await this.api.request(docEntry);
this.request.set(req);
const lines = (req['StockTransferLines'] as Row[]) ?? [];
const drafts: DraftLine[] = await Promise.all(
lines.map(async (l) => {
const lot = text((l['BatchNumbers'] as Row[] | undefined)?.[0]?.['BatchNumber']);
const quantity = Number(l['Quantity']) || 0;
const from = text(l['FromWarehouseCode']);
const origins = lot ? await this.api.sources(text(l['ItemCode']), lot, from) : {};
return {
LineNum: Number(l['LineNum']),
ItemCode: text(l['ItemCode']),
Lot: lot,
FromWarehouseCode: from,
Quantity: quantity,
allocations: allocateByOrigin(origins, quantity).map((a) => ({ WarehouseCode: a.WarehouseCode, Quantity: a.Quantity })),
};
}),
);
this.withoutOrigin.set(new Set(drafts.flatMap((d, i) => (d.allocations.length ? [] : [i]))));
this.drafts.set(drafts);
this.message.set(null);
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.resolving.set(false);
}
}
protected setAllocations(index: number, rows: Row[]): void {
this.drafts.update((d) => d?.map((line, i) => (i === index ? { ...line, allocations: rows as DraftLine['allocations'] } : line)) ?? null);
this.message.set(null);
}
protected addAllocation(index: number): void {
this.setAllocations(index, [...(this.drafts()?.[index]?.allocations ?? []), { WarehouseCode: '', Quantity: '' }]);
}
protected readonly canConfirm = computed(() => {
const d = this.drafts();
return !!d && d.some((l) => l.allocations.length > 0) && d.every((l) => !draftLineError(l));
});
protected async confirm(): Promise<void> {
const req = this.request();
const drafts = this.drafts();
if (!req || !drafts || !this.canConfirm() || this.confirming()) return;
this.confirming.set(true);
try {
await this.api.replaceLines(Number(req['DocEntry']), confirmLines(drafts), text(req['odata.etag']) || undefined);
this.message.set({ kind: 'ok', text: `Solicitud ${req['DocNum']} actualizada con los almacenes destino.` });
this.drafts.set(null);
this.request.set(null);
this.docEntry.set(null);
this.pending.reload();
} catch (e) {
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.confirming.set(false);
}
}
protected readonly status = computed<StatusMessage>(() => this.message() ?? { kind: 'info', text: this.drafts() ? 'Revise el reparto de cada línea y confirme.' : '' });
}
@@ -0,0 +1,164 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, provideRouter } from '@angular/router';
import { beforeEach, describe, expect, it } from 'vitest';
import { LoteTransferPage } from './lote-transfer.page';
import type { LotFlow } from './lotes-flujo.config';
const field = (alias: string, over: object) => ({ table: 'OWTQ', alias, description: alias, type: 'A', size: 10, validValues: [], linkedTable: null, linkedRows: [], linkedTruncated: false, ...over });
const FIELDS: Record<string, object> = {
SIS_MOVINV: field('SIS_MOVINV', { linkedTable: 'SIS_MOVINV', linkedRows: [{ code: 'XTD', name: 'TRANSFERENCIA' }] }),
EXX_TIPOOPER: field('EXX_TIPOOPER', { linkedTable: 'EXX_TIPOOPER', linkedRows: [{ code: '11', name: '11 - TRANSFERENCIA ENTRE ALMACENES' }] }),
SIS_TIPDESP: field('SIS_TIPDESP', { validValues: [{ value: 'N', description: 'LOCAL' }] }),
SIS_MOTALM: field('SIS_MOTALM', { validValues: [{ value: '107', description: 'INMOVILIZADO DT' }, { value: '108', description: 'LIBERACIÓN DT' }] }),
};
const STOCK = [
{ WarehouseCode: '201', WarehouseName: 'APROBADOS DRO', Quantity: '10.000000', Committed: '0.000000', ItemCode: 'A1', ItemDescription: 'SUTURA' },
{ WarehouseCode: '202', WarehouseName: 'CONTRAMUESTRA', Quantity: '4.000000', Committed: '1.000000', ItemCode: 'A1', ItemDescription: 'SUTURA' },
{ WarehouseCode: '302', WarehouseName: 'INMOVILIZADOS', Quantity: '3.000000', Committed: '0.000000', ItemCode: 'A1', ItemDescription: 'SUTURA' },
];
describe('LoteTransferPage', () => {
let fixture: ComponentFixture<LoteTransferPage>;
let ctrl: HttpTestingController;
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();
};
async function open(flow: LotFlow) {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({
imports: [LoteTransferPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: { snapshot: { data: { flow } } } }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(LoteTransferPage);
await tick();
// catálogos de la solicitud (campos de usuario de SAP) y series
for (const alias of Object.keys(FIELDS)) ctrl.expectOne(`/api/sapdb/user-fields/OWTQ/${alias}`).flush(FIELDS[alias]);
ctrl.expectOne('/api/sl/SeriesService_GetDocumentSeries').flush({ value: [{ Series: 7, Name: 'TR-01', Locked: 'tNO' }, { Series: 8, Name: 'OLD', Locked: 'tYES' }] });
await tick();
}
async function search(lot: string, rows: object[] = STOCK) {
page().lot.set(lot);
await tick();
(el().querySelector('.row .btn.main') as HTMLButtonElement).click();
await tick();
const req = ctrl.expectOne('/api/sapdb/queries/lot-stock');
expect(req.request.body).toEqual({ params: { lot } });
req.flush({ rows, truncated: false });
await tick();
}
const rowOf = (code: string) => [...el().querySelectorAll('.stock tbody tr')].find((r) => r.querySelectorAll('td')[3]?.textContent?.trim() === code) as HTMLElement;
const check = (code: string) => (rowOf(code).querySelector('input[type=checkbox]') as HTMLInputElement).click();
describe('inmovilizar', () => {
beforeEach(() => open('inmovilizar'));
it('muestra todos los almacenes del lote y no deja mover lo que ya está inmovilizado', async () => {
await search('2090456');
expect([...el().querySelectorAll('.stock tbody tr')].map((r) => r.querySelectorAll('td')[3]?.textContent?.trim())).toEqual(['201', '202', '302']);
expect((rowOf('302').querySelector('input[type=checkbox]') as HTMLInputElement).disabled).toBe(true);
expect(rowOf('302').textContent).toContain('Ya inmovilizado');
});
it('al marcar un almacén se precarga todo el stock y no se puede superar', async () => {
await search('2090456');
check('201');
await tick();
const qty = rowOf('201').querySelector('input.qty') as HTMLInputElement;
expect(qty.value).toBe('10');
qty.value = '99';
qty.dispatchEvent(new Event('input'));
await tick();
expect((rowOf('201').querySelector('input.qty') as HTMLInputElement).value).toBe('10');
});
it('no se puede generar sin motivo, comentario y acción; con todo, crea la solicitud con una línea por almacén', async () => {
await search('2090456');
check('201');
check('202');
await tick();
expect(page().problems()).toEqual(expect.arrayContaining(['Indique el motivo del estado', 'Indique el comentario y la acción a tomar']));
page().set({ motivo: 'Queja', comentario: 'Reclamo del cliente', accion: 'Retener' });
page().setQuantity(page().visibleRows()[1], '2');
await tick();
expect(page().problems()).toEqual([]);
(el().querySelector('button[class~="main"][title], button.btn.main:not(.row .btn)') as HTMLButtonElement | null)?.click();
page().confirming.set(true);
await tick();
expect(el().textContent).toContain('¿Crear la solicitud de traslado de 2 almacenes del lote 2090456 al almacén 302?');
page().create();
await tick();
const req = ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/InventoryTransferRequests');
expect(req.request.body).toMatchObject({
U_SIS_MOVINV: 'XTD', U_EXX_TIPOOPER: '11', U_SIS_TIPDESP: 'N', U_SIS_MOTALM: '107',
U_SIS_MOTEST: 'Queja', U_SIS_COMEST: 'Reclamo del cliente', U_SIS_ACCTOM: 'Retener',
StockTransferLines: [
{ ItemCode: 'A1', Quantity: 10, WarehouseCode: '302', FromWarehouseCode: '201', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 10 }] },
{ ItemCode: 'A1', Quantity: 2, WarehouseCode: '302', FromWarehouseCode: '202', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 2 }] },
],
});
expect(req.request.body).not.toHaveProperty('Series');
req.flush({ DocEntry: 480, DocNum: 4123 });
await tick();
// vuelve a la búsqueda con el aviso de éxito
expect(el().querySelector('.stock')).toBeNull();
expect(el().querySelector('.status')?.textContent).toContain('Doc. 4123');
});
it('un error de SAP deja el panel con lo completado para corregir y reintentar', async () => {
await search('2090456');
check('201');
page().set({ motivo: 'Queja', comentario: 'c', accion: 'a' });
await tick();
page().confirming.set(true);
page().create();
await tick();
ctrl.expectOne((r) => r.method === 'POST' && r.url === '/api/sl/InventoryTransferRequests').flush({ message: 'Stock insuficiente' }, { status: 400, statusText: 'Bad Request' });
await tick();
expect(el().querySelector('.stock')).not.toBeNull();
expect(page().form().motivo).toBe('Queja');
expect(el().querySelector('.status')?.textContent).toContain('Stock insuficiente');
});
it('un lote sin stock se avisa', async () => {
await search('NOEXISTE', []);
expect(el().querySelector('.status')?.textContent).toContain('No se encontró stock para el lote «NOEXISTE»');
expect(el().querySelector('.stock')).toBeNull();
});
});
describe('liberar', () => {
beforeEach(() => open('liberar'));
it('muestra solo el stock del almacén de origen (302 por defecto) y propone el destino 201 y el motivo 108', async () => {
await search('2090456');
expect([...el().querySelectorAll('.stock tbody tr')].map((r) => r.querySelectorAll('td')[3]?.textContent?.trim())).toEqual(['302']);
expect(page().form()).toMatchObject({ toWarehouse: '201', movimientoAlmacen: '108' });
});
it('solo exige el motivo del estado', async () => {
await search('2090456');
check('302');
page().set({ motivo: 'Aprobado por calidad' });
await tick();
expect(page().problems()).toEqual([]);
});
it('un lote que no tiene stock en el almacén de origen se avisa', async () => {
await search('2090456', [STOCK[0]]);
expect(el().querySelector('.status')?.textContent).toContain('no tiene stock en el almacén 302');
});
});
});
@@ -0,0 +1,258 @@
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { apiErrorMessage } from '../../../core/api';
import { todayIso } from '../../../core/format';
import { DateField, LookupField, SelectField, TextField } from '../../../shared/form';
import type { VhLookup } from '../../../shared/value-help/value-help.types';
import { SboWindow, type StatusMessage } from '../../../shared/window';
import { WAREHOUSE_LOOKUP } from '../../documents/document-layouts';
import { LOT_FLOWS, type LotFlow, MOTIVO_MAX, TEXT_MAX } from './lotes-flujo.config';
import {
type LotStockRow,
type TransferForm,
clampQuantity,
initialForm,
quantityError,
rowKey,
transferErrors,
transferPayload,
} from './lotes-flujo';
import { LotesFlujoApi } from './lotes-flujo-api';
const WAREHOUSE: VhLookup = { ...WAREHOUSE_LOOKUP, nameKey: 'WarehouseName' };
/**
* «Inmovilizar lotes» y «Liberar lotes» (TAGUMEDICA): se busca un lote por su número, se marcan los almacenes con stock y
* las cantidades, y se crea la solicitud de traslado en SAP con una línea por almacén de origen y el lote en cada una. La
* pantalla es la misma; el flujo (`data.flow`) fija el almacén de origen y de destino por defecto y qué textos son obligatorios.
*/
@Component({
selector: 'sbo-lote-transfer-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DateField, LookupField, SboWindow, SelectField, TextField],
template: `
<sbo-window [title]="config.title" [status]="status()">
<p class="hint">{{ config.description }}</p>
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-text-field label="Número de lote" [value]="lot()" (valueChange)="lot.set($event)" (keydown.enter)="search()" />
</div>
<div class="sbo-col">
@if (config.pickFrom) {
<sbo-lookup-field label="Almacén de origen" [lookup]="warehouse" [value]="fromWarehouse()" (valueChange)="fromWarehouse.set($event)" resolveOnLeave />
}
</div>
</div>
<p class="row"><button type="button" class="btn main" [disabled]="searching() || !lot().trim()" (click)="search()">Buscar</button></p>
@if (result(); as r) {
<h3>Stock del lote {{ r.lot }}</h3>
<div class="wrap">
<table class="stock">
<thead>
<tr>
<th class="chk"></th><th>Artículo</th><th>Descripción</th><th>Almacén</th><th>Nombre</th>
<th class="num">En stock</th><th class="num">Comprometido</th><th class="num">Cantidad a mover</th>
</tr>
</thead>
<tbody>
@for (row of visibleRows(); track key(row)) {
<tr [class.locked]="isLocked(row)">
<td class="chk"><input type="checkbox" [checked]="isSelected(row)" [disabled]="isLocked(row)" [attr.aria-label]="'Mover ' + row.ItemCode + ' del almacén ' + row.WarehouseCode" (change)="toggle(row)" /></td>
<td class="code">{{ row.ItemCode }}</td>
<td>{{ row.ItemDescription }}</td>
<td class="code">{{ row.WarehouseCode }}</td>
<td>{{ row.WarehouseName }}</td>
<td class="num">{{ row.Quantity }}</td>
<td class="num">{{ row.Committed }}</td>
<td class="num">
@if (isSelected(row)) {
<input class="qty" inputmode="decimal" [attr.aria-label]="'Cantidad de ' + row.ItemCode + ' del almacén ' + row.WarehouseCode" [value]="quantities()[key(row)]" (input)="setQuantity(row, $any($event.target))" />
@if (qtyError(row); as e) {
<span class="err" role="alert">{{ e }}</span>
}
} @else if (isLocked(row)) {
<span class="muted">Ya inmovilizado</span>
}
</td>
</tr>
} @empty {
<tr class="none"><td colspan="8">El lote no tiene stock en el almacén {{ fromWarehouse() }}.</td></tr>
}
</tbody>
</table>
</div>
<h3>Solicitud de traslado</h3>
<div class="sbo-hdr">
<div class="sbo-col">
<sbo-date-field label="Fecha" required [value]="form().docDate" (valueChange)="set({ docDate: $event })" />
<sbo-select-field label="Serie" [options]="series.value() ?? []" placeholder="(por defecto)" [value]="form().series" (valueChange)="set({ series: $event === null || $event === '' ? null : +$event })" />
<sbo-lookup-field label="Almacén destino" required [lookup]="warehouse" [value]="form().toWarehouse" (valueChange)="set({ toWarehouse: $event })" resolveOnLeave />
</div>
<div class="sbo-col">
<sbo-select-field label="Movimiento de inventario" required [options]="options().movimientoInventario" placeholder="" [value]="form().movimientoInventario" (valueChange)="set({ movimientoInventario: '' + ($event ?? '') })" />
<sbo-select-field label="Tipo de operación" required [options]="options().tipoOperacion" placeholder="" [value]="form().tipoOperacion" (valueChange)="set({ tipoOperacion: '' + ($event ?? '') })" />
<sbo-select-field label="Tipo de despacho" required [options]="options().tipoDespacho" placeholder="" [value]="form().tipoDespacho" (valueChange)="set({ tipoDespacho: '' + ($event ?? '') })" />
<sbo-select-field label="Motivo de almacén" required [options]="options().movimientoAlmacen" placeholder="" [value]="form().movimientoAlmacen" (valueChange)="set({ movimientoAlmacen: '' + ($event ?? '') })" />
</div>
</div>
<sbo-text-field label="Motivo del estado" required [value]="form().motivo" (valueChange)="set({ motivo: $event })" [error]="over(form().motivo, motivoMax)" />
<sbo-text-field label="Comentario del estado" [required]="config.requireDetail" [value]="form().comentario" (valueChange)="set({ comentario: $event })" [error]="over(form().comentario, textMax)" />
<sbo-text-field label="Acción a tomar" [required]="config.requireDetail" [value]="form().accion" (valueChange)="set({ accion: $event })" [error]="over(form().accion, textMax)" />
@if (confirming()) {
<p class="ask" role="alert">
¿Crear la solicitud de traslado de {{ selected().length }} {{ selected().length === 1 ? 'almacén' : 'almacenes' }} del lote {{ r.lot }} al almacén {{ form().toWarehouse }}?
<button type="button" class="btn main" [disabled]="saving()" (click)="create()">Sí, crear</button>
<button type="button" class="btn" [disabled]="saving()" (click)="confirming.set(false)">No</button>
</p>
}
}
@if (result() && !confirming()) {
<button sboActions class="btn main" type="button" [disabled]="saving() || problems().length > 0" [attr.title]="problems()[0] ?? null" (click)="confirming.set(true)">Generar solicitud de traslado</button>
}
</sbo-window>
`,
styles: `
:host { display: block; }
h3 { margin: 14px 0 4px; font-size: 13px; font-weight: 600; }
.hint { margin: 0 0 8px; color: var(--quiet); }
.row { margin: 4px 0 0; }
.wrap { overflow-x: auto; }
.stock { width: 100%; border-collapse: collapse; }
.stock th { padding: 4px 8px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; text-align: left; white-space: nowrap; }
.stock td { padding: 3px 8px; border: 1px solid var(--line-soft); }
.stock .num { text-align: right; }
.stock .chk { width: 28px; text-align: center; }
.stock .code { font-family: var(--mono, monospace); }
.stock tr.locked td { background: var(--field-ro); color: var(--quiet); }
.stock tr.none td { padding: 14px; text-align: center; color: var(--quiet); }
.qty { width: 90px; text-align: right; }
.err { display: block; color: var(--err); font-size: 11.5px; }
.muted { color: var(--quiet); }
.ask { margin: 12px 0 0; padding: 6px 10px; background: var(--warn-bg, var(--panel)); border: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
`,
})
export class LoteTransferPage {
private readonly api = inject(LotesFlujoApi);
private readonly flow = inject(ActivatedRoute).snapshot.data['flow'] as LotFlow;
protected readonly config = LOT_FLOWS[this.flow];
protected readonly warehouse = WAREHOUSE;
protected readonly motivoMax = MOTIVO_MAX;
protected readonly textMax = TEXT_MAX;
// --- búsqueda ------------------------------------------------------------------------------------------------------
protected readonly lot = signal('');
protected readonly fromWarehouse = signal(this.config.fromWarehouse ?? '');
protected readonly searching = signal(false);
protected readonly result = signal<{ lot: string; rows: LotStockRow[] } | null>(null);
protected readonly quantities = signal<Record<string, string>>({});
private readonly message = signal<StatusMessage | null>(null);
/** Las filas que se ven: con origen elegido (liberar), solo las de ese almacén; si no, todas. */
protected readonly visibleRows = computed(() => {
const r = this.result();
if (!r) return [];
const from = this.config.pickFrom ? this.fromWarehouse() : null;
return from ? r.rows.filter((x) => x.WarehouseCode === from) : r.rows;
});
protected readonly key = rowKey;
protected async search(): Promise<void> {
const lot = this.lot().trim();
if (!lot || this.searching()) return;
this.searching.set(true);
this.message.set({ kind: 'info', text: 'Buscando el lote…' });
this.result.set(null);
this.quantities.set({});
this.confirming.set(false);
try {
const rows = await this.api.stock(lot);
if (!rows.length) throw new Error(`No se encontró stock para el lote «${lot}»`);
if (this.config.pickFrom && !rows.some((r) => r.WarehouseCode === this.fromWarehouse())) throw new Error(`El lote «${lot}» no tiene stock en el almacén ${this.fromWarehouse()}`);
this.result.set({ lot, rows });
this.form.set(initialForm(this.config, todayIso()));
this.message.set(null);
} catch (e) {
this.message.set({ kind: 'err', text: e instanceof Error && !('status' in e) ? e.message : apiErrorMessage(e) });
} finally {
this.searching.set(false);
}
}
// --- selección -----------------------------------------------------------------------------------------------------
protected isLocked(row: LotStockRow): boolean {
return row.WarehouseCode === this.config.lockedWarehouse;
}
protected isSelected(row: LotStockRow): boolean {
return rowKey(row) in this.quantities();
}
protected toggle(row: LotStockRow): void {
if (this.isLocked(row)) return;
const key = rowKey(row);
this.quantities.update((q) => {
if (key in q) {
const { [key]: _removed, ...rest } = q;
return rest;
}
// al marcar la fila se precarga con todo el stock (se puede bajar, no superar)
return { ...q, [key]: String(row.Quantity) };
});
}
protected setQuantity(row: LotStockRow, source: string | HTMLInputElement): void {
const clamped = clampQuantity(row, typeof source === 'string' ? source : source.value);
// si se recortó, el casillero debe mostrar el valor corregido aunque el valor guardado no haya cambiado
if (typeof source !== 'string') source.value = clamped;
this.quantities.update((q) => ({ ...q, [rowKey(row)]: clamped }));
}
protected qtyError(row: LotStockRow): string {
return quantityError(row, this.quantities()[rowKey(row)] ?? '');
}
protected readonly selected = computed(() => this.visibleRows().filter((r) => this.isSelected(r)).map((row) => ({ row, quantity: this.quantities()[rowKey(row)] ?? '' })));
// --- solicitud -----------------------------------------------------------------------------------------------------
protected readonly form = signal<TransferForm>(initialForm(this.config, todayIso()));
protected set(change: Partial<TransferForm>): void {
this.form.update((f) => ({ ...f, ...change }));
}
protected over = (text: string, max: number): string => (text.length > max ? `Máximo ${max} caracteres` : '');
protected readonly catalogs = resource({ loader: () => this.api.catalogs() });
protected readonly options = computed(() => this.catalogs.value() ?? { movimientoInventario: [], tipoOperacion: [], tipoDespacho: [], movimientoAlmacen: [] });
protected readonly series = resource({ loader: () => this.api.series().catch(() => []) });
protected readonly problems = computed(() => {
const f = this.form();
const errors = transferErrors(this.config, f, this.selected());
if (f.motivo.length > MOTIVO_MAX || f.comentario.length > TEXT_MAX || f.accion.length > TEXT_MAX) errors.push('Algún texto es demasiado largo');
return errors;
});
protected readonly confirming = signal(false);
protected readonly saving = signal(false);
protected async create(): Promise<void> {
const r = this.result();
if (!r || this.saving() || this.problems().length) return;
this.saving.set(true);
try {
const doc = await this.api.create(transferPayload(r.lot, this.form(), this.selected()));
// éxito: se vuelve a la búsqueda y el aviso queda arriba (el panel del lote desaparece)
this.result.set(null);
this.lot.set('');
this.quantities.set({});
this.message.set({ kind: 'ok', text: `Solicitud de traslado creada en SAP (Doc. ${doc['DocNum'] ?? doc['DocEntry']}).` });
} catch (e) {
// el error deja el panel con todo lo completado para corregir y reintentar
this.message.set({ kind: 'err', text: apiErrorMessage(e) });
} finally {
this.confirming.set(false);
this.saving.set(false);
}
}
protected readonly status = computed<StatusMessage>(() => this.message() ?? { kind: 'info', text: this.result() ? 'Marque los almacenes, complete la solicitud y genérela.' : 'Escriba el número de lote y pulse «Buscar».' });
}
@@ -0,0 +1,97 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import type { SlListResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../../../core/api';
import { SapDbApi } from '../../../core/sl/sapdb-api';
import { SlMasterApi } from '../../../core/sl/sl-master';
import { UserFieldsApi } from '../../../core/sl/user-fields';
import { type LotStockRow, toStockRow } from './lotes-flujo';
export interface Option {
value: string;
label: string;
}
/** Catálogos de la solicitud de traslado: cada uno sale de la definición del campo de usuario en SAP. */
export interface TransferCatalogs {
movimientoInventario: Option[];
tipoOperacion: Option[];
tipoDespacho: Option[];
movimientoAlmacen: Option[];
}
const RESOURCE = 'InventoryTransferRequests';
/** Objeto de SAP de la solicitud de traslado (`BoObjectTypes`), para pedir sus series. */
const OBJECT_TYPE = '1250000001';
type Row = Record<string, unknown>;
/** Habla con SAP para los flujos de lotes: la base (stock y orígenes), los campos de usuario y la solicitud de traslado. */
@Injectable({ providedIn: 'root' })
export class LotesFlujoApi {
private readonly http = inject(HttpClient);
private readonly sapdb = inject(SapDbApi);
private readonly userFields = inject(UserFieldsApi);
private readonly master = inject(SlMasterApi);
/** El stock de un lote por número, en cada artículo y almacén. */
async stock(lot: string): Promise<LotStockRow[]> {
return (await this.sapdb.run('lot-stock', { lot })).rows.map(toStockRow);
}
/** De qué almacenes vino un lote hasta el almacén donde está: almacén → cantidad histórica. */
async sources(item: string, lot: string, warehouse: string): Promise<Record<string, number>> {
const rows = (await this.sapdb.run('lot-sources', { item, lot, warehouse })).rows;
return Object.fromEntries(rows.map((r) => [String(r['WarehouseCode']), Number(r['Quantity'])]));
}
/** Un campo de usuario de la solicitud como lista: su tabla enlazada o sus valores válidos. */
private async options(alias: string): Promise<Option[]> {
const info = await this.userFields.get('OWTQ', alias);
if (!info) return [];
if (info.validValues.length) return info.validValues.map((v) => ({ value: v.value, label: v.description ? `${v.value} · ${v.description}` : v.value }));
return info.linkedRows.map((r) => ({ value: r.code, label: r.name.startsWith(r.code) ? r.name : `${r.code} · ${r.name}` }));
}
async catalogs(): Promise<TransferCatalogs> {
const [movimientoInventario, tipoOperacion, tipoDespacho, movimientoAlmacen] = await Promise.all([
this.options('SIS_MOVINV'),
this.options('EXX_TIPOOPER'),
this.options('SIS_TIPDESP'),
this.options('SIS_MOTALM'),
]);
return { movimientoInventario, tipoOperacion, tipoDespacho, movimientoAlmacen };
}
/** Las series de la solicitud de traslado que no están bloqueadas. */
async series(): Promise<{ value: number; label: string }[]> {
const res = await firstValueFrom(this.http.post<SlListResponse<Row>>(`${API_URL}/sl/SeriesService_GetDocumentSeries`, { DocumentTypeParams: { Document: OBJECT_TYPE } }));
return res.value.filter((r) => r['Locked'] !== 'tYES').map((r) => ({ value: Number(r['Series']), label: String(r['Name'] ?? r['Series']) }));
}
create(body: Row): Promise<Row> {
return this.master.create(RESOURCE, body);
}
/** Las solicitudes abiertas pendientes de confirmar (las más recientes primero). */
async pending(motive: string): Promise<Row[]> {
const params = {
$select: 'DocEntry,DocNum,DocDate,U_SIS_MOTEST,U_SIS_COMEST,U_SIS_ACCTOM',
$filter: `DocumentStatus eq 'bost_Open' and U_SIS_MOTALM eq '${motive}'`,
$orderby: 'DocEntry desc',
$top: 200,
};
return (await firstValueFrom(this.http.get<SlListResponse<Row>>(`${API_URL}/sl/${RESOURCE}`, { params }))).value;
}
/** La solicitud completa (el Service Layer solo trae los lotes de las líneas al pedirla por su clave). */
request(docEntry: number): Promise<Row> {
return firstValueFrom(this.http.get<Row>(`${API_URL}/sl/${RESOURCE}/${docEntry}`));
}
/** Reemplaza las líneas de la solicitud (se pueden agregar y quitar al repartir entre almacenes). */
replaceLines(docEntry: number, lines: Row[], etag: string | undefined): Promise<void> {
return this.master.update(RESOURCE, String(docEntry), { StockTransferLines: lines }, etag, true);
}
}
@@ -0,0 +1,64 @@
/**
* Valores de los flujos de lotes de TAGUMEDICA (almacenes y códigos de los campos de usuario de la solicitud de
* traslado). Son decisiones del negocio, no del código: aquí se cambian sin tocar las pantallas.
*/
/** Los dos flujos que crean una solicitud de traslado desde un lote. */
export type LotFlow = 'inmovilizar' | 'liberar';
export interface LotFlowConfig {
title: string;
description: string;
/** Almacén de origen fijo de la búsqueda: `null` = todos los del lote. */
fromWarehouse: string | null;
/** El origen se puede elegir (liberar) o se muestran todos los almacenes del lote (inmovilizar). */
pickFrom: boolean;
/** Almacén destino por defecto (se puede cambiar). */
toWarehouse: string;
/** El stock de este almacén no se puede volver a mover en este flujo (ya está inmovilizado). */
lockedWarehouse: string | null;
/** `U_SIS_MOTALM` por defecto: 107 «INMOVILIZADO DT», 108 «LIBERACIÓN DT». */
movimientoAlmacen: string;
/** Comentario y acción a tomar obligatorios (inmovilizar); en liberar solo el motivo. */
requireDetail: boolean;
}
/** Almacén de inmovilizados (302 - INMOVILIZADOS DRO). */
export const WAREHOUSE_IMMOBILIZED = '302';
/** Almacén de aprobados (201 - APROBADOS DRO). */
export const WAREHOUSE_APPROVED = '201';
export const LOT_FLOWS: Readonly<Record<LotFlow, LotFlowConfig>> = {
inmovilizar: {
title: 'Inmovilizar lotes',
description: 'Consulta el stock de un lote por almacén y genera la solicitud de traslado al almacén de inmovilizados.',
fromWarehouse: null,
pickFrom: false,
toWarehouse: WAREHOUSE_IMMOBILIZED,
lockedWarehouse: WAREHOUSE_IMMOBILIZED,
movimientoAlmacen: '107',
requireDetail: true,
},
liberar: {
title: 'Liberar lotes',
description: 'Consulta el stock inmovilizado de un lote en un almacén y genera la solicitud de traslado de liberación.',
fromWarehouse: WAREHOUSE_IMMOBILIZED,
pickFrom: true,
toWarehouse: WAREHOUSE_APPROVED,
lockedWarehouse: null,
movimientoAlmacen: '108',
requireDetail: false,
},
};
/** Valores por defecto de `U_SIS_MOVINV`, `U_EXX_TIPOOPER` y `U_SIS_TIPDESP` en las solicitudes de los dos flujos. */
export const DEFAULT_MOVIMIENTO_INVENTARIO = 'XTD';
export const DEFAULT_TIPO_OPERACION = '11';
export const DEFAULT_TIPO_DESPACHO = 'N';
/** `U_SIS_MOTALM` de las solicitudes que esperan la confirmación de la liberación (las que crea «Liberar lotes»). */
export const PENDING_LIBERATION = '108';
/** Largo máximo de los textos (`U_SIS_MOTEST` es de 100 caracteres, los otros dos de 254). */
export const MOTIVO_MAX = 100;
export const TEXT_MAX = 254;
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import { LOT_FLOWS } from './lotes-flujo.config';
import {
type DraftLine,
type LotStockRow,
allocateByOrigin,
clampQuantity,
confirmLines,
draftLineError,
groupByItem,
initialForm,
quantityError,
rowKey,
toStockRow,
transferErrors,
transferPayload,
} from './lotes-flujo';
const row = (over: Partial<LotStockRow> = {}): LotStockRow => ({ ItemCode: 'A1', ItemDescription: 'Sutura', WarehouseCode: '201', WarehouseName: 'APROBADOS', Quantity: 10, Committed: 0, ...over });
describe('stock de un lote', () => {
it('convierte la fila de HANA (los decimales llegan como texto)', () => {
expect(toStockRow({ ItemCode: 'A1', ItemDescription: 'S', WarehouseCode: '100', WarehouseName: 'C', Quantity: '7.500000', Committed: '0.000000' })).toEqual({
ItemCode: 'A1', ItemDescription: 'S', WarehouseCode: '100', WarehouseName: 'C', Quantity: 7.5, Committed: 0,
});
});
it('agrupa por artículo y cada fila es artículo + almacén', () => {
const g = groupByItem([row(), row({ WarehouseCode: '202' }), row({ ItemCode: 'B2' })]);
expect(g.map((x) => [x.ItemCode, x.rows.length])).toEqual([['A1', 2], ['B2', 1]]);
expect(rowKey(row())).toBe('A1::201');
});
it('la cantidad debe ser mayor a 0 y no pasar del stock; lo escrito se recorta al stock', () => {
expect(quantityError(row(), '')).toBe('');
expect(quantityError(row(), '0')).toMatch(/mayor a 0/);
expect(quantityError(row(), '11')).toMatch(/superar el stock/);
expect(quantityError(row(), '10')).toBe('');
expect(clampQuantity(row(), '99')).toBe('10');
expect(clampQuantity(row(), '3')).toBe('3');
});
});
describe('solicitud de inmovilización', () => {
const cfg = LOT_FLOWS.inmovilizar;
const form = { ...initialForm(cfg, '2026-10-20'), motivo: 'Queja', comentario: 'Cliente reclama', accion: 'Retener' };
const sel = [{ row: row(), quantity: '4' }];
it('parte de los valores por defecto del flujo', () => {
expect(initialForm(cfg, '2026-10-20')).toMatchObject({ toWarehouse: '302', movimientoAlmacen: '107', movimientoInventario: 'XTD', tipoOperacion: '11', tipoDespacho: 'N', series: null });
expect(initialForm(LOT_FLOWS.liberar, '2026-10-20')).toMatchObject({ toWarehouse: '201', movimientoAlmacen: '108' });
});
it('pide motivo, comentario y acción, al menos un almacén y un destino distinto del origen', () => {
expect(transferErrors(cfg, form, sel)).toEqual([]);
expect(transferErrors(cfg, { ...form, comentario: '', accion: '' }, sel)).toContain('Indique el comentario y la acción a tomar');
expect(transferErrors(cfg, { ...form, motivo: ' ' }, sel)).toContain('Indique el motivo del estado');
expect(transferErrors(cfg, form, [])).toContain('Marque al menos un almacén con stock');
expect(transferErrors(cfg, { ...form, toWarehouse: '201' }, sel)).toContain('El almacén destino debe ser distinto al almacén de origen');
expect(transferErrors(cfg, form, [{ row: row(), quantity: '' }])[0]).toMatch(/Revise la cantidad/);
});
it('liberar solo exige el motivo', () => {
expect(transferErrors(LOT_FLOWS.liberar, { ...initialForm(LOT_FLOWS.liberar, '2026-10-20'), motivo: 'Aprobado' }, [{ row: row({ WarehouseCode: '302' }), quantity: '2' }])).toEqual([]);
});
it('arma la solicitud con una línea por almacén de origen y el lote en cada una', () => {
expect(transferPayload('2090456', { ...form, series: 5 }, [...sel, { row: row({ WarehouseCode: '202' }), quantity: '2' }])).toEqual({
DocDate: '2026-10-20',
Series: 5,
U_SIS_MOVINV: 'XTD',
U_EXX_TIPOOPER: '11',
U_SIS_TIPDESP: 'N',
U_SIS_MOTALM: '107',
U_SIS_MOTEST: 'Queja',
U_SIS_COMEST: 'Cliente reclama',
U_SIS_ACCTOM: 'Retener',
StockTransferLines: [
{ ItemCode: 'A1', Quantity: 4, WarehouseCode: '302', FromWarehouseCode: '201', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 4 }] },
{ ItemCode: 'A1', Quantity: 2, WarehouseCode: '302', FromWarehouseCode: '202', BatchNumbers: [{ BatchNumber: '2090456', Quantity: 2 }] },
],
});
});
it('sin serie elegida no se envía (SAP usa la por defecto) y los textos opcionales vacíos tampoco', () => {
const p = transferPayload('L', { ...form, comentario: '', accion: '' }, sel);
expect(p).not.toHaveProperty('Series');
expect(p).not.toHaveProperty('U_SIS_COMEST');
expect(p).not.toHaveProperty('U_SIS_ACCTOM');
});
});
describe('confirmar la liberación', () => {
it('reparte la cantidad entre los almacenes de origen a prorrata y el último absorbe el redondeo', () => {
expect(allocateByOrigin({ '207': 2, '208': 1 }, 3)).toEqual([{ WarehouseCode: '207', Quantity: 2 }, { WarehouseCode: '208', Quantity: 1 }]);
expect(allocateByOrigin({ '207': 5, '208': 1, '303': 3 }, 9)).toEqual([{ WarehouseCode: '207', Quantity: 5 }, { WarehouseCode: '208', Quantity: 1 }, { WarehouseCode: '303', Quantity: 3 }]);
const parts = allocateByOrigin({ A: 1, B: 1, C: 1 }, 10);
expect(parts.reduce((s, p) => s + p.Quantity, 0)).toBe(10);
expect(allocateByOrigin({}, 5)).toEqual([]);
expect(allocateByOrigin({ A: 0 }, 5)).toEqual([]);
});
const line = (over: Partial<DraftLine> = {}): DraftLine => ({
LineNum: 0, ItemCode: 'A1', Lot: 'L1', FromWarehouseCode: '302', Quantity: 3,
allocations: [{ WarehouseCode: '207', Quantity: 2 }, { WarehouseCode: '208', Quantity: '1' }],
...over,
});
it('una línea es válida si cada fila está completa y suman la cantidad; sin filas se quita a propósito', () => {
expect(draftLineError(line())).toBe('');
expect(draftLineError(line({ allocations: [] }))).toBe('');
expect(draftLineError(line({ allocations: [{ WarehouseCode: '', Quantity: 3 }] }))).toMatch(/Complete/);
expect(draftLineError(line({ allocations: [{ WarehouseCode: '207', Quantity: 2 }] }))).toMatch(/suman 2 y la línea es de 3/);
});
it('reemplaza las líneas: la primera conserva su LineNum, las otras van sin él, y una línea sin filas no se envía', () => {
expect(confirmLines([line(), line({ LineNum: 1, ItemCode: 'B2', allocations: [] })])).toEqual([
{ LineNum: 0, ItemCode: 'A1', Quantity: 2, WarehouseCode: '207', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 2 }] },
{ ItemCode: 'A1', Quantity: 1, WarehouseCode: '208', FromWarehouseCode: '302', BatchNumbers: [{ BatchNumber: 'L1', Quantity: 1 }] },
]);
});
});
@@ -0,0 +1,198 @@
import type { LotFlowConfig } from './lotes-flujo.config';
import { DEFAULT_MOVIMIENTO_INVENTARIO, DEFAULT_TIPO_DESPACHO, DEFAULT_TIPO_OPERACION } from './lotes-flujo.config';
/** Una fila de stock de un lote: un artículo en un almacén. */
export interface LotStockRow {
ItemCode: string;
ItemDescription: string;
WarehouseCode: string;
WarehouseName: string;
Quantity: number;
Committed: number;
}
const num = (v: unknown): number => {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
/** La fila de la consulta `lot-stock` (HANA entrega los decimales como texto) como fila de stock. */
export function toStockRow(raw: Record<string, unknown>): LotStockRow {
return {
ItemCode: String(raw['ItemCode'] ?? ''),
ItemDescription: String(raw['ItemDescription'] ?? ''),
WarehouseCode: String(raw['WarehouseCode'] ?? ''),
WarehouseName: String(raw['WarehouseName'] ?? ''),
Quantity: num(raw['Quantity']),
Committed: num(raw['Committed']),
};
}
/** Un mismo número de lote puede repetirse en varios artículos, y un almacén en varios artículos: la fila es artículo + almacén. */
export const rowKey = (r: Pick<LotStockRow, 'ItemCode' | 'WarehouseCode'>): string => `${r.ItemCode}::${r.WarehouseCode}`;
/** Por qué una cantidad escrita no vale, o `''` si vale (o está vacía y todavía no se valida). */
export function quantityError(row: LotStockRow, raw: string): string {
if (raw === '') return '';
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return 'La cantidad debe ser mayor a 0';
if (n > row.Quantity) return `La cantidad no puede superar el stock disponible en el almacén (${row.Quantity})`;
return '';
}
/** Lo escrito no puede pasar del stock de la fila. */
export function clampQuantity(row: LotStockRow, raw: string): string {
const n = Number(raw);
return raw !== '' && Number.isFinite(n) && n > row.Quantity ? String(row.Quantity) : raw;
}
export interface ItemGroup {
ItemCode: string;
ItemDescription: string;
rows: LotStockRow[];
}
/** Las filas agrupadas por artículo, en el orden en que llegan. */
export function groupByItem(rows: readonly LotStockRow[]): ItemGroup[] {
const groups: ItemGroup[] = [];
for (const r of rows) {
let g = groups.find((x) => x.ItemCode === r.ItemCode);
if (!g) groups.push((g = { ItemCode: r.ItemCode, ItemDescription: r.ItemDescription, rows: [] }));
g.rows.push(r);
}
return groups;
}
/** Los datos de la solicitud que escribe el usuario. */
export interface TransferForm {
docDate: string;
series: number | null;
movimientoInventario: string;
tipoOperacion: string;
tipoDespacho: string;
movimientoAlmacen: string;
toWarehouse: string;
motivo: string;
comentario: string;
accion: string;
}
/** Valores iniciales de la solicitud de un flujo. */
export const initialForm = (config: LotFlowConfig, today: string): TransferForm => ({
docDate: today,
series: null,
movimientoInventario: DEFAULT_MOVIMIENTO_INVENTARIO,
tipoOperacion: DEFAULT_TIPO_OPERACION,
tipoDespacho: DEFAULT_TIPO_DESPACHO,
movimientoAlmacen: config.movimientoAlmacen,
toWarehouse: config.toWarehouse,
motivo: '',
comentario: '',
accion: '',
});
/** Lo que falta o está mal para poder crear la solicitud (vacío = se puede). */
export function transferErrors(config: LotFlowConfig, form: TransferForm, selected: readonly { row: LotStockRow; quantity: string }[]): string[] {
const errors: string[] = [];
if (!selected.length) errors.push('Marque al menos un almacén con stock');
for (const s of selected) {
if (s.quantity === '' || quantityError(s.row, s.quantity)) errors.push(`Revise la cantidad de ${s.row.ItemCode} en el almacén ${s.row.WarehouseCode}`);
if (s.row.WarehouseCode === form.toWarehouse) errors.push('El almacén destino debe ser distinto al almacén de origen');
}
if (!form.toWarehouse) errors.push('Indique el almacén destino');
if (!form.docDate) errors.push('Indique la fecha');
if (!form.movimientoInventario || !form.tipoOperacion || !form.tipoDespacho || !form.movimientoAlmacen) errors.push('Complete los datos de la solicitud');
if (!form.motivo.trim()) errors.push('Indique el motivo del estado');
if (config.requireDetail && (!form.comentario.trim() || !form.accion.trim())) errors.push('Indique el comentario y la acción a tomar');
return [...new Set(errors)];
}
/** El documento que se crea en SAP: la solicitud de traslado con una línea por almacén de origen y el lote en cada una. */
export function transferPayload(
lot: string,
form: TransferForm,
selected: readonly { row: LotStockRow; quantity: string }[],
): Record<string, unknown> {
return {
DocDate: form.docDate,
...(form.series !== null ? { Series: form.series } : {}),
U_SIS_MOVINV: form.movimientoInventario,
U_EXX_TIPOOPER: form.tipoOperacion,
U_SIS_TIPDESP: form.tipoDespacho,
U_SIS_MOTALM: form.movimientoAlmacen,
U_SIS_MOTEST: form.motivo.trim(),
...(form.comentario.trim() ? { U_SIS_COMEST: form.comentario.trim() } : {}),
...(form.accion.trim() ? { U_SIS_ACCTOM: form.accion.trim() } : {}),
StockTransferLines: selected.map(({ row, quantity }) => ({
ItemCode: row.ItemCode,
Quantity: Number(quantity),
WarehouseCode: form.toWarehouse,
FromWarehouseCode: row.WarehouseCode,
BatchNumbers: [{ BatchNumber: lot, Quantity: Number(quantity) }],
})),
};
}
// --- confirmar la liberación ---------------------------------------------------------------------------------------
export interface Allocation {
WarehouseCode: string;
Quantity: number;
}
/**
* Reparte la cantidad de una línea entre los almacenes de los que vino el lote, a prorrata de lo que trajo cada uno
* (`origins`: almacén → cantidad histórica). Cada parte es un entero (los artículos se mueven por unidades completas) y
* el último almacén absorbe el redondeo para que la suma sea exactamente la cantidad.
*/
export function allocateByOrigin(origins: Readonly<Record<string, number>>, quantity: number): Allocation[] {
const entries = Object.entries(origins);
const total = entries.reduce((s, [, q]) => s + q, 0);
if (total <= 0) return [];
const out: Allocation[] = [];
let done = 0;
entries.forEach(([warehouse, historical], i) => {
const last = i === entries.length - 1;
const part = Math.max(last ? Math.round(quantity - done) : Math.round(quantity * (historical / total)), 0);
done += part;
if (part > 0) out.push({ WarehouseCode: warehouse, Quantity: part });
});
return out;
}
/** Una línea de la solicitud pendiente con los almacenes a los que vuelve el lote (editables). */
export interface DraftLine {
LineNum: number;
ItemCode: string;
Lot: string;
FromWarehouseCode: string;
Quantity: number;
/** Las filas de la tabla editable: almacén y cantidad (la tabla guarda número o texto). */
allocations: { WarehouseCode: string; Quantity: number | string }[];
}
/** Por qué una línea no se puede confirmar (vacío = bien). Una línea sin filas se quita del documento a propósito. */
export function draftLineError(line: DraftLine): string {
if (!line.allocations.length) return '';
if (line.allocations.some((a) => !a.WarehouseCode || String(a.Quantity).trim() === '' || !(Number(a.Quantity) > 0))) return 'Complete el almacén y la cantidad de cada fila';
const sum = line.allocations.reduce((s, a) => s + Number(a.Quantity), 0);
if (sum !== line.Quantity) return `Las cantidades suman ${sum} y la línea es de ${line.Quantity}`;
return '';
}
/**
* Las líneas con que se reemplazan las de la solicitud: una por cada fila de reparto. La primera de cada línea conserva su
* `LineNum`; las demás van sin él para que SAP las agregue. Una línea sin filas no se envía (se quita del documento).
*/
export function confirmLines(lines: readonly DraftLine[]): Record<string, unknown>[] {
return lines.flatMap((line) =>
line.allocations.map((a, i) => ({
...(i === 0 ? { LineNum: line.LineNum } : {}),
ItemCode: line.ItemCode,
Quantity: Number(a.Quantity),
WarehouseCode: a.WarehouseCode,
FromWarehouseCode: line.FromWarehouseCode,
BatchNumbers: [{ BatchNumber: line.Lot, Quantity: Number(a.Quantity) }],
})),
);
}
@@ -1,13 +1,14 @@
import { udfField } from '../../documents/document-layouts';
import type { MasterExtensions } from '../master-extensions'; import type { MasterExtensions } from '../master-extensions';
/** Estado del lote en calidad (`U_SIS_ESTLOT`, valores válidos de la tabla de usuario del lote en SAP). */ /** Etiquetas de las columnas de la lista (el formulario lee los valores válidos de SAP): `U_SIS_ESTLOT`. */
const ESTADO_LOTE = [ const ESTADO_LOTE = [
{ value: '101', label: 'Cuarentena' }, { value: '101', label: 'Cuarentena' },
{ value: '102', label: 'Aprobado' }, { value: '102', label: 'Aprobado' },
{ value: '103', label: 'Rechazado' }, { value: '103', label: 'Rechazado' },
{ value: '104', label: 'Inmovilizado' }, { value: '104', label: 'Inmovilizado' },
]; ];
/** Estado de liberación (`U_SIS_ESTLIB`). */ /** Etiquetas de la lista para `U_SIS_ESTLIB`. */
const ESTADO_LIBERACION = [ const ESTADO_LIBERACION = [
{ value: '101', label: 'Liberado' }, { value: '101', label: 'Liberado' },
{ value: '102', label: 'No liberado' }, { value: '102', label: 'No liberado' },
@@ -20,8 +21,9 @@ const label = (options: readonly { value: string; label: string }[], value: unkn
* están en esta rama; `main` lleva solo campos estándar. * están en esta rama; `main` lleva solo campos estándar.
* *
* Notas verificadas en la base: `U_SIS_HORAPROB` guarda la hora como entero `hhmm` (1223 = 12:23) y la pone el proceso de * Notas verificadas en la base: `U_SIS_HORAPROB` guarda la hora como entero `hhmm` (1223 = 12:23) y la pone el proceso de
* aprobación, por eso va de solo lectura; `U_SIS_REGSAN` es el código de la tabla de usuario `@SIS_REGSAN` (357
* registros; hoy se escribe el código, la ventana de selección llega con el registro de tablas de usuario). Los * aprobación, por eso va de solo lectura. Los estados y el registro sanitario salen de su definición en SAP (`udfField`:
* valores válidos y la tabla de usuario `@SIS_REGSAN` enlazada, 357 registros). Los
* campos `U_SIS_MOTEST`, `U_SIS_COMEST` y `U_SIS_ACCTOM` no son del lote sino de la solicitud de traslado (`OWTQ`). * campos `U_SIS_MOTEST`, `U_SIS_COMEST` y `U_SIS_ACCTOM` no son del lote sino de la solicitud de traslado (`OWTQ`).
*/ */
export const LOTES_EXTENSIONS: MasterExtensions = { export const LOTES_EXTENSIONS: MasterExtensions = {
@@ -31,13 +33,13 @@ export const LOTES_EXTENSIONS: MasterExtensions = {
id: 'calidad', id: 'calidad',
label: 'Calidad', label: 'Calidad',
left: [ left: [
{ type: 'select', key: 'U_SIS_ESTLOT', label: 'Estado del lote', options: ESTADO_LOTE }, udfField('OBTN', 'SIS_ESTLOT', 'Estado del lote'),
{ type: 'date', key: 'U_SIS_FECAPROB', label: 'Fecha de aprobación' }, { type: 'date', key: 'U_SIS_FECAPROB', label: 'Fecha de aprobación' },
{ type: 'text', key: 'U_SIS_HORAPROB', label: 'Hora de aprobación (hhmm)', readonly: true }, { type: 'text', key: 'U_SIS_HORAPROB', label: 'Hora de aprobación (hhmm)', readonly: true },
{ type: 'text', key: 'U_SIS_RESPAPROB', label: 'Responsable de aprobación' }, { type: 'text', key: 'U_SIS_RESPAPROB', label: 'Responsable de aprobación' },
], ],
right: [ right: [
{ type: 'select', key: 'U_SIS_ESTLIB', label: 'Estado de liberación', options: ESTADO_LIBERACION }, udfField('OBTN', 'SIS_ESTLIB', 'Estado de liberación'),
{ type: 'date', key: 'U_SIS_FECLIB', label: 'Fecha de liberación' }, { type: 'date', key: 'U_SIS_FECLIB', label: 'Fecha de liberación' },
{ type: 'text', key: 'U_SIS_OBSER', label: 'Observaciones' }, { type: 'text', key: 'U_SIS_OBSER', label: 'Observaciones' },
], ],
@@ -46,7 +48,7 @@ export const LOTES_EXTENSIONS: MasterExtensions = {
id: 'registro', id: 'registro',
label: 'Registro y ensayos', label: 'Registro y ensayos',
left: [ left: [
{ type: 'text', key: 'U_SIS_REGSAN', label: 'Registro sanitario' }, udfField('OBTN', 'SIS_REGSAN', 'Registro sanitario'),
{ type: 'text', key: 'U_INT_NUMPRO', label: 'Número de protocolo' }, { type: 'text', key: 'U_INT_NUMPRO', label: 'Número de protocolo' },
{ type: 'text', key: 'U_SIS_PROCED', label: 'Procedencia' }, { type: 'text', key: 'U_SIS_PROCED', label: 'Procedencia' },
], ],
+8 -1
View File
@@ -9,4 +9,11 @@ import type { MenuItem } from './menu.js';
* *
* inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }], * inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }],
*/ */
export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {}; export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {
// Flujo de lotes de TAGUMEDICA: inmovilizar, liberar y confirmar la liberación (solicitudes de traslado)
inventario: [
{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' },
{ path: '/inventario/liberar-lotes', label: 'Liberar lotes' },
{ path: '/inventario/confirmar-liberacion-lote', label: 'Confirmar liberación de lote' },
],
};