From 5728fc8e5b1b87bee237fd66b8fde08e6fe780b0 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 20 Oct 2026 15:52:01 -0500 Subject: [PATCH] =?UTF-8?q?feat(tagumedica):=20flujo=20de=20lotes=20(inmov?= =?UTF-8?q?ilizar,=20liberar=20y=20confirmar=20la=20liberaci=C3=B3n)=20con?= =?UTF-8?q?=20solicitudes=20de=20traslado,=20consultas=20a=20HANA=20y=20ca?= =?UTF-8?q?mpos=20de=20usuario=20por=20su=20definici=C3=B3n=20en=20SAP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CLAUDE.md | 8 + apps/api/src/sapdb/sapdb.extensions.spec.ts | 27 ++ apps/api/src/sapdb/sapdb.extensions.ts | 52 +++- apps/api/src/sl/sl-resources.extensions.ts | 8 +- apps/web/src/app/client-routes.ts | 6 +- .../lotes/lote-release-confirm.page.spec.ts | 123 +++++++++ .../client/lotes/lote-release-confirm.page.ts | 186 +++++++++++++ .../client/lotes/lote-transfer.page.spec.ts | 164 +++++++++++ .../client/lotes/lote-transfer.page.ts | 258 ++++++++++++++++++ .../features/client/lotes/lotes-flujo-api.ts | 97 +++++++ .../client/lotes/lotes-flujo.config.ts | 64 +++++ .../features/client/lotes/lotes-flujo.spec.ts | 122 +++++++++ .../app/features/client/lotes/lotes-flujo.ts | 198 ++++++++++++++ .../masters/lotes/lotes.extensions.ts | 16 +- libs/sap-types/src/menu.extensions.ts | 9 +- 15 files changed, 1325 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/sapdb/sapdb.extensions.spec.ts create mode 100644 apps/web/src/app/features/client/lotes/lote-release-confirm.page.spec.ts create mode 100644 apps/web/src/app/features/client/lotes/lote-release-confirm.page.ts create mode 100644 apps/web/src/app/features/client/lotes/lote-transfer.page.spec.ts create mode 100644 apps/web/src/app/features/client/lotes/lote-transfer.page.ts create mode 100644 apps/web/src/app/features/client/lotes/lotes-flujo-api.ts create mode 100644 apps/web/src/app/features/client/lotes/lotes-flujo.config.ts create mode 100644 apps/web/src/app/features/client/lotes/lotes-flujo.spec.ts create mode 100644 apps/web/src/app/features/client/lotes/lotes-flujo.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1882d42..af53bcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. +## 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 - 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. diff --git a/apps/api/src/sapdb/sapdb.extensions.spec.ts b/apps/api/src/sapdb/sapdb.extensions.spec.ts new file mode 100644 index 0000000..166ad66 --- /dev/null +++ b/apps/api/src/sapdb/sapdb.extensions.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/sapdb/sapdb.extensions.ts b/apps/api/src/sapdb/sapdb.extensions.ts index 4ea44cc..230a42f 100644 --- a/apps/api/src/sapdb/sapdb.extensions.ts +++ b/apps/api/src/sapdb/sapdb.extensions.ts @@ -1,7 +1,53 @@ 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 - * sus campos de usuario. Cada una declara su SQL por motor (`hana`), sus parámetros con nombre y el permiso de menú. + * Consultas a la base de SAP de TAGUMEDICA (HANA): el flujo de lotes (inmovilizar, liberar y confirmar la liberación). + * 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, + }, +]; diff --git a/apps/api/src/sl/sl-resources.extensions.ts b/apps/api/src/sl/sl-resources.extensions.ts index 65a8289..1d034ab 100644 --- a/apps/api/src/sl/sl-resources.extensions.ts +++ b/apps/api/src/sl/sl-resources.extensions.ts @@ -10,6 +10,12 @@ import type { SlResource } from './sl-resources.js'; * * `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`. */ -export const CLIENT_RESOURCE_PATHS: Readonly> = {}; +export const CLIENT_RESOURCE_PATHS: Readonly> = { + // 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[] = []; diff --git a/apps/web/src/app/client-routes.ts b/apps/web/src/app/client-routes.ts index fc043b3..eabaec0 100644 --- a/apps/web/src/app/client-routes.ts +++ b/apps/web/src/app/client-routes.ts @@ -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)), */ -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»). */ export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`); diff --git a/apps/web/src/app/features/client/lotes/lote-release-confirm.page.spec.ts b/apps/web/src/app/features/client/lotes/lote-release-confirm.page.spec.ts new file mode 100644 index 0000000..c39a183 --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lote-release-confirm.page.spec.ts @@ -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; + 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 = { 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(); + }); +}); diff --git a/apps/web/src/app/features/client/lotes/lote-release-confirm.page.ts b/apps/web/src/app/features/client/lotes/lote-release-confirm.page.ts new file mode 100644 index 0000000..31a9168 --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lote-release-confirm.page.ts @@ -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; + +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: ` + +

Solicitudes de traslado de liberación abiertas. Elija una para indicar a qué almacenes vuelve cada lote.

+ + + + @for (r of pending.value() ?? []; track r['DocEntry']) { + + + + } @empty { + + } + +
SolicitudFechaMotivo del estadoComentarioAcción a tomar
{{ r['DocNum'] }}{{ date(r['DocDate']) }}{{ r['U_SIS_MOTEST'] }}{{ r['U_SIS_COMEST'] }}{{ r['U_SIS_ACCTOM'] }}
{{ pending.isLoading() ? 'Cargando…' : 'No hay solicitudes de liberación pendientes.' }}
+ + @if (drafts(); as lines) { +

Solicitud {{ request()?.['DocNum'] }}: almacenes destino

+ @for (line of lines; track line.LineNum; let i = $index) { +
+

+ {{ line.ItemCode }} · lote {{ line.Lot }} · {{ line.Quantity }} unidades salen del almacén {{ line.FromWarehouseCode }} + @if (noOrigin(i)) { + · no se encontró de qué almacén vino: agréguelo a mano + } +

+ +

+ + Reparto: {{ sum(line) }} de {{ line.Quantity }} + @if (lineError(line); as e) { + {{ e }} + } +

+
+ } + } @else if (resolving()) { +

Buscando de qué almacenes vino cada lote…

+ } + @if (drafts()) { + + } +
+ `, + 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(null); + protected readonly request = signal(null); + protected readonly drafts = signal(null); + protected readonly resolving = signal(false); + protected readonly confirming = signal(false); + private readonly message = signal(null); + /** Líneas para las que la base no encontró de qué almacén vino el lote. */ + private readonly withoutOrigin = signal>(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 { + 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 { + 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(() => this.message() ?? { kind: 'info', text: this.drafts() ? 'Revise el reparto de cada línea y confirme.' : '' }); +} diff --git a/apps/web/src/app/features/client/lotes/lote-transfer.page.spec.ts b/apps/web/src/app/features/client/lotes/lote-transfer.page.spec.ts new file mode 100644 index 0000000..49ff920 --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lote-transfer.page.spec.ts @@ -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 = { + 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; + 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'); + }); + }); +}); diff --git a/apps/web/src/app/features/client/lotes/lote-transfer.page.ts b/apps/web/src/app/features/client/lotes/lote-transfer.page.ts new file mode 100644 index 0000000..d8cabda --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lote-transfer.page.ts @@ -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: ` + +

{{ config.description }}

+ +
+
+ +
+
+ @if (config.pickFrom) { + + } +
+
+

+ + @if (result(); as r) { +

Stock del lote {{ r.lot }}

+
+ + + + + + + + + @for (row of visibleRows(); track key(row)) { + + + + + + + + + + + } @empty { + + } + +
ArtículoDescripciónAlmacénNombreEn stockComprometidoCantidad a mover
{{ row.ItemCode }}{{ row.ItemDescription }}{{ row.WarehouseCode }}{{ row.WarehouseName }}{{ row.Quantity }}{{ row.Committed }} + @if (isSelected(row)) { + + @if (qtyError(row); as e) { + {{ e }} + } + } @else if (isLocked(row)) { + Ya inmovilizado + } +
El lote no tiene stock en el almacén {{ fromWarehouse() }}.
+
+ +

Solicitud de traslado

+
+
+ + + +
+
+ + + + +
+
+ + + + + @if (confirming()) { + + } + } + @if (result() && !confirming()) { + + } +
+ `, + 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>({}); + private readonly message = signal(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 { + 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(initialForm(this.config, todayIso())); + protected set(change: Partial): 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 { + 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(() => 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».' }); +} diff --git a/apps/web/src/app/features/client/lotes/lotes-flujo-api.ts b/apps/web/src/app/features/client/lotes/lotes-flujo-api.ts new file mode 100644 index 0000000..4544d09 --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lotes-flujo-api.ts @@ -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; + +/** 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 { + 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> { + 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 { + 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 { + 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>(`${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 { + return this.master.create(RESOURCE, body); + } + + /** Las solicitudes abiertas pendientes de confirmar (las más recientes primero). */ + async pending(motive: string): Promise { + 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>(`${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 { + return firstValueFrom(this.http.get(`${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 { + return this.master.update(RESOURCE, String(docEntry), { StockTransferLines: lines }, etag, true); + } +} diff --git a/apps/web/src/app/features/client/lotes/lotes-flujo.config.ts b/apps/web/src/app/features/client/lotes/lotes-flujo.config.ts new file mode 100644 index 0000000..eb20fb7 --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lotes-flujo.config.ts @@ -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> = { + 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; diff --git a/apps/web/src/app/features/client/lotes/lotes-flujo.spec.ts b/apps/web/src/app/features/client/lotes/lotes-flujo.spec.ts new file mode 100644 index 0000000..6a2ca50 --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lotes-flujo.spec.ts @@ -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 => ({ 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 => ({ + 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 }] }, + ]); + }); +}); diff --git a/apps/web/src/app/features/client/lotes/lotes-flujo.ts b/apps/web/src/app/features/client/lotes/lotes-flujo.ts new file mode 100644 index 0000000..6bec3fc --- /dev/null +++ b/apps/web/src/app/features/client/lotes/lotes-flujo.ts @@ -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): 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): 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 { + 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>, 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[] { + 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) }], + })), + ); +} diff --git a/apps/web/src/app/features/masters/lotes/lotes.extensions.ts b/apps/web/src/app/features/masters/lotes/lotes.extensions.ts index 2c6c45f..0a7466c 100644 --- a/apps/web/src/app/features/masters/lotes/lotes.extensions.ts +++ b/apps/web/src/app/features/masters/lotes/lotes.extensions.ts @@ -1,13 +1,14 @@ +import { udfField } from '../../documents/document-layouts'; 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 = [ { value: '101', label: 'Cuarentena' }, { value: '102', label: 'Aprobado' }, { value: '103', label: 'Rechazado' }, { value: '104', label: 'Inmovilizado' }, ]; -/** Estado de liberación (`U_SIS_ESTLIB`). */ +/** Etiquetas de la lista para `U_SIS_ESTLIB`. */ const ESTADO_LIBERACION = [ { value: '101', label: '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. * * 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`). */ export const LOTES_EXTENSIONS: MasterExtensions = { @@ -31,13 +33,13 @@ export const LOTES_EXTENSIONS: MasterExtensions = { id: 'calidad', label: 'Calidad', 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: 'text', key: 'U_SIS_HORAPROB', label: 'Hora de aprobación (hhmm)', readonly: true }, { type: 'text', key: 'U_SIS_RESPAPROB', label: 'Responsable de aprobación' }, ], 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: 'text', key: 'U_SIS_OBSER', label: 'Observaciones' }, ], @@ -46,7 +48,7 @@ export const LOTES_EXTENSIONS: MasterExtensions = { id: 'registro', label: 'Registro y ensayos', 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_SIS_PROCED', label: 'Procedencia' }, ], diff --git a/libs/sap-types/src/menu.extensions.ts b/libs/sap-types/src/menu.extensions.ts index 7d84eb6..3aa9ec1 100644 --- a/libs/sap-types/src/menu.extensions.ts +++ b/libs/sap-types/src/menu.extensions.ts @@ -9,4 +9,11 @@ import type { MenuItem } from './menu.js'; * * inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }], */ -export const CLIENT_MENU_ITEMS: Readonly> = {}; +export const CLIENT_MENU_ITEMS: Readonly> = { + // 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' }, + ], +};