Compare commits

..
Author SHA1 Message Date
George 336f4c1875 Merge branch 'main' into cliente/tagumedica 2026-10-20 17:08:54 -05:00
GeorgeandClaude Sonnet 5.5 67fbcdcfbf feat(tagumedica): el parámetro Papel de las etiquetas se muestra como Carta y no se puede cambiar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:57:46 -05:00
George b7c684d39e Merge branch 'main' into cliente/tagumedica 2026-10-20 16:57:33 -05:00
GeorgeandClaude Sonnet 5.5 0252717206 feat(tagumedica): etiquetas de bultos solo en papel Carta precortado (sin parámetro de papel) y vista HTML con los márgenes en pantalla
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:54:29 -05:00
George 0534becc9d Merge branch 'main' into cliente/tagumedica 2026-10-20 16:53:24 -05:00
GeorgeandClaude Sonnet 5.5 3fbfe9328e feat(tagumedica): reporte HTML de etiquetas de bultos (plantilla migrada de Jinja2, consulta a HANA y parámetros entrega, cantidad y papel)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 16:49:01 -05:00
George 63dc9b10ca Merge branch 'main' into cliente/tagumedica 2026-10-20 16:47:18 -05:00
GeorgeandClaude Sonnet 5.5 0d49129bcd feat(tagumedica): la serie por defecto de SAP queda elegida en las solicitudes de inmovilizar y liberar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:57:34 -05:00
George 3eb3269561 Merge branch 'main' into cliente/tagumedica 2026-10-20 15:57:00 -05:00
GeorgeandClaude Sonnet 5.5 51fbae34f5 feat(tagumedica): el botón «Campos» funciona en las pantallas del flujo de lotes (campos y cabeceras de tablas)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:55:51 -05:00
GeorgeandClaude Sonnet 5.5 2d25b3b880 feat(tagumedica): motivo, comentario y acción a tomar en áreas de 3 filas
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 15:54:53 -05:00
GeorgeandClaude Sonnet 5.5 5728fc8e5b 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>
2026-10-20 15:52:01 -05:00
George 72b3cd1d43 Merge branch 'main' into cliente/tagumedica 2026-10-20 15:46:41 -05:00
George 6cb5d3fce3 Merge branch 'main' into cliente/tagumedica 2026-10-20 15:46:00 -05:00
George de5964606c Merge branch 'main' into cliente/tagumedica 2026-10-20 15:39:18 -05:00
George 074eed6d81 Merge branch 'main' into cliente/tagumedica 2026-10-20 09:09:45 -05:00
George ac4fdea372 Merge branch 'main' into cliente/tagumedica 2026-10-20 08:59:58 -05:00
GeorgeandClaude Sonnet 5.5 58eafa72ec feat(tagumedica): campos de usuario de los lotes (calidad, liberación, registro sanitario y ensayos)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 08:42:43 -05:00
22 changed files with 1751 additions and 22 deletions

No files matched your search

+12
View File
@@ -62,10 +62,22 @@ Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): e
Menú **Reportes HTML** (`/reportes-html/ejecutar`): reportes que cada cliente define como plantillas **Nunjucks** (la sintaxis de Jinja2: `{{ x }}`, `{% for %}`) rellenadas con datos de la base de SAP y que se **imprimen desde el navegador**. El motor es genérico y vive en `main` (`apps/api/src/htmlreports/`); los reportes los declara la rama del cliente en `html-reports.extensions.ts` (vacío en `main`) y sus plantillas van en `apps/api/templates/<cliente>/` (`HTML_REPORTS_DIR`; ver `templates/README.md`). Cada `HtmlReportDef` trae `code`, `title`, `category`, `params` (los mismos `GatewayParam` de los reportes del Gateway: texto, número, fecha, lista fija o ventana de selección de documentos/socios/artículos…, con valor por defecto), `template` y `data(values, ctx)`, que arma lo que ve la plantilla con `ctx.query(nombre, params)` (consultas registradas de `sapdb`, cuyo permiso de menú debe incluir `/reportes-html/ejecutar`). La plantilla recibe esos datos y `params`; los valores se escapan solos y `{{ asset('<cliente>/fonts/F.ttf') }}` entrega un archivo de la carpeta (fuente, logo) como `data:` URI, sin salir de ella. API: `GET /api/html-reports` y `POST /api/html-reports/:code/render {values}` (HTML); permiso de menú `/reportes-html/ejecutar`; auditoría `html_report` (solo los nombres de los parámetros). **Pantalla:** reutiliza la lista y el formulario de «Reportes» (`data.source = 'html'`); el resultado se ve en un marco (`sandbox`) con botón **Imprimir** y un recordatorio: *Márgenes predeterminados, encabezados y pies de página desactivados, escala 100 %* (Chrome y Edge respetan `@page`; conviene fijar `line-height`). **Verificado** (etiquetas de bultos de TAGUMEDICA, WeasyPrint frente al PDF de Chrome con la misma plantilla): mismas páginas, márgenes, rejilla y espacios; con `line-height` fijo el texto difiere 0,3 mm de media. **PDF desde el servidor** (botón principal **Generar PDF** de la pantalla; «Ver como HTML» queda como vista previa para imprimir desde el navegador): `POST /api/html-reports/:code/pdf` pasa el mismo HTML por Chrome o Chromium sin pantalla (`--print-to-pdf`, sin librerías nuevas ni descargas: usa el instalado, `CHROME_BIN` o los lugares habituales) y devuelve el PDF con el papel y los márgenes de la plantilla fijos, sin depender del cuadro de impresión; cola propia (`HTML_PDF_MAX_CONCURRENCY` 1, `HTML_PDF_TIMEOUT` 30 s); sin Chrome responde 503 con el aviso. **En el despliegue (Docker) hay que instalar Chromium** y, si corre como administrador, Chrome se lanza con `--no-sandbox` solo. **Al imprimir el PDF hay que elegir «Tamaño real / 100 %»** (no «Ajustar»), sobre todo con papel precortado. Pendiente: el botón PDF de los documentos aún no ofrece los reportes HTML.
## TAGUMEDICA: etiquetas de bultos (solo en `cliente/tagumedica`)
Primer reporte HTML del cliente (`/reportes-html/ejecutar` → «Etiquetas de bultos»), migrado de `mini-erp` (`templates/reports/stickers.html`, Jinja2 + WeasyPrint): plantilla `apps/api/templates/tagumedica/etiquetas-bultos.njk` (la misma, con `line-height: 1.2` y la fuente Orbitron incrustada con `asset()`), definición en `htmlreports/etiquetas-bultos.ts` y consulta `delivery-labels` (HANA: `ODLN`/`DLN12`/`OCST`, reducida a los 4 datos que usa la plantilla: cliente, dirección, lugar —departamento, provincia y distrito— y documento: el `NumAtCard` o el folio). **Parámetros:** la entrega (ventana de selección de documentos, valor = `DocEntry`) y la cantidad de etiquetas (entero de 1 a 500). **El papel es siempre Carta precortado**: el parámetro «Papel» se muestra con «Carta» pero es de solo lectura (`readOnly`, el servidor ignora lo que llegue): los márgenes de la plantilla (`1.6cm 1.5cm 1.8cm 2.7cm`) y la rejilla están ajustados a los recortes de la hoja, así que no se tocan sin probar con papel; la salida buena es el **PDF** (botón «Generar PDF»), que se imprime en **«Tamaño real / 100 %»**. Sale en hojas horizontales de 4 etiquetas (2 × 2) numeradas `1/6`… Verificado con la entrega 144153 contra `SBO_TAGUMEDICA_21` (consulta + plantilla + `HtmlPdfService`: PDF de 2 páginas Carta de 792 × 612 pt en 0,6 s; la primera etiqueta cae a 76,5 pt del borde izquierdo y 45,4 pt del superior, más el relleno). La vista HTML lleva los mismos márgenes en pantalla (`@media screen`). Los usuarios necesitan el permiso de menú `/reportes-html/*` (o `/reportes-html/ejecutar`). Pendiente: ofrecerlo también en el botón PDF de Entregas.
## Ver el nombre del campo de SAP («Campos»)
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.
@@ -0,0 +1,78 @@
import { resolve } from 'node:path';
import { BadRequestException } from '@nestjs/common';
import { SAPDB_QUERIES } from '../sapdb/sapdb-queries.js';
import { bindNamed } from '../sapdb/sapdb-sql.js';
import { createRenderer } from './html-render.js';
import { ETIQUETAS_BULTOS, MAX_LABELS, labelsData } from './etiquetas-bultos.js';
import { parseHtmlValues } from './html-reports.service.js';
import { HTML_REPORTS } from './html-reports.js';
const ROW = { CardName: 'CLÍNICA <PLASTIC> & CIA S.A.C.', Guia: 'T001-37070', Direccion: 'AV. DOMINGO ORUE NRO. 469', Departamento: 'LIMA', Provincia: 'LIMA', Distrito: 'SURQUILLO' };
const render = createRenderer(resolve(import.meta.dirname, '../../templates')).render;
const html = (n: number) => render(ETIQUETAS_BULTOS.template, labelsData(ROW, n));
const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
describe('etiquetas de bultos', () => {
it('pide la entrega y la cantidad; el papel se muestra como Carta (precortado) pero no se puede cambiar', () => {
expect(HTML_REPORTS.get('etiquetas-bultos')).toBe(ETIQUETAS_BULTOS);
expect(ETIQUETAS_BULTOS.params.map((p) => [p.name, p.kind, p.required])).toEqual([['docEntry', 'lookup', true], ['cantidad', 'number', true], ['papel', 'select', true]]);
expect(ETIQUETAS_BULTOS.params[0].lookup).toBe('doc:DeliveryNotes');
expect(ETIQUETAS_BULTOS.params[2]).toMatchObject({ defaultValue: 'Carta', readOnly: true });
expect(parseHtmlValues(ETIQUETAS_BULTOS, { docEntry: '144153', cantidad: '6' })).toEqual({ docEntry: '144153', cantidad: '6', papel: 'Carta' });
// aunque el cliente mande otro papel, el servidor usa Carta
expect(parseHtmlValues(ETIQUETAS_BULTOS, { docEntry: '144153', cantidad: '6', papel: 'A4' })).toMatchObject({ papel: 'Carta' });
});
it('reparte las etiquetas de a 4 por hoja y las numera 1/6… 6/6', () => {
const d = labelsData(ROW, 6) as { paginas: { numero: number; total: number }[][] };
expect(d.paginas.map((p) => p.length)).toEqual([4, 2]);
expect(d.paginas.flat().map((l) => `${l.numero}/${l.total}`)).toEqual(['1/6', '2/6', '3/6', '4/6', '5/6', '6/6']);
expect((labelsData(ROW, 8) as { paginas: unknown[] }).paginas).toHaveLength(2);
});
it('une departamento, provincia y distrito en el lugar, sin partes vacías', () => {
expect((labelsData({ ...ROW, Provincia: null }, 1) as { datos: { lugar: string } }).datos.lugar).toBe('LIMA - SURQUILLO');
});
it('la plantilla dibuja una etiqueta por cada una, con sus datos, y escapa el HTML del cliente', () => {
const out = html(6);
expect(count(out, /class="sticker"/g)).toBe(6);
expect(count(out, /class="pagina"/g)).toBe(2);
expect(out).toContain('1/6');
expect(out).toContain('6/6');
expect(out).toContain('T001-37070');
expect(out).toContain('LIMA - LIMA - SURQUILLO');
expect(out).toContain('CLÍNICA &lt;PLASTIC&gt; &amp; CIA S.A.C.');
expect(out).not.toContain('<PLASTIC>');
});
it('la hoja es Carta horizontal con los márgenes ajustados a las etiquetas precortadas; la fuente va incrustada', () => {
expect(html(1)).toMatch(/size: letter landscape/);
expect(html(1)).toContain('margin: 1.6cm 1.5cm 1.8cm 2.7cm;');
expect(html(1)).toContain('18.19cm');
expect(html(1)).toContain('data:font/ttf;base64,');
expect(html(1)).toContain('line-height: 1.2');
});
it('en pantalla el cuerpo lleva los mismos márgenes para ver las etiquetas como saldrán', () => {
expect(html(1)).toMatch(/@media screen\s*{\s*body { margin: 1\.6cm 1\.5cm 1\.8cm 2\.7cm !important; }/);
});
it('pide la guía a la consulta delivery-labels y rechaza cantidades que no valen o una entrega que no existe', async () => {
const query = vi.fn(async () => [ROW]);
const values = { docEntry: '144153', cantidad: '3' };
const data = (await ETIQUETAS_BULTOS.data(values, { query })) as { paper_size: string };
expect(query).toHaveBeenCalledWith('delivery-labels', { docEntry: 144153 });
expect(data.paper_size).toBe('letter');
for (const cantidad of ['0', '2.5', String(MAX_LABELS + 1)]) await expect(ETIQUETAS_BULTOS.data({ ...values, cantidad }, { query })).rejects.toThrow(BadRequestException);
await expect(ETIQUETAS_BULTOS.data(values, { query: async () => [] })).rejects.toThrow(/No se encontró la entrega 144153/);
});
it('la consulta delivery-labels está registrada para este menú y recibe la entrega como argumento', () => {
const q = SAPDB_QUERIES.get('delivery-labels')!;
expect(q.menu).toBe('/reportes-html/ejecutar');
const b = bindNamed(q.sql.hana!, { docEntry: 144153 });
expect(b.args).toEqual([144153]);
expect(b.sql).not.toContain(':');
});
});
@@ -0,0 +1,59 @@
import { BadRequestException } from '@nestjs/common';
import type { GatewayParam } from '@basic-erp/sap-types';
import type { SapDbRow } from '../sapdb/sapdb.types.js';
import type { HtmlReportDef } from './html-reports.types.js';
/**
* El papel de las etiquetas es siempre Carta precortado: tamaño para `@page` y alto útil de la hoja horizontal (papel menos
* los márgenes superior e inferior de la plantilla). Los márgenes de la plantilla están ajustados a los recortes de la hoja:
* no se tocan sin probar con papel.
*/
const PAPER = { size: 'letter', height: '18.19cm' } as const;
/** Tope de etiquetas por pedido (evita un documento enorme por un error de tecleo). */
export const MAX_LABELS = 500;
const PER_PAGE = 4;
/**
* Lo que ve la plantilla: los datos de la guía y las etiquetas numeradas (`1/6`…), de a 4 por hoja. El lugar une
* departamento, provincia y distrito.
*/
export function labelsData(row: SapDbRow, quantity: number): Record<string, unknown> {
const text = (k: string): string => (row[k] === null || row[k] === undefined ? '' : String(row[k]).trim());
const labels = Array.from({ length: quantity }, (_, i) => ({ numero: i + 1, total: quantity }));
return {
datos: {
cliente: text('CardName'),
direccion: text('Direccion'),
lugar: [text('Departamento'), text('Provincia'), text('Distrito')].filter(Boolean).join(' - '),
documento: text('Guia'),
},
paginas: Array.from({ length: Math.ceil(labels.length / PER_PAGE) }, (_, i) => labels.slice(i * PER_PAGE, (i + 1) * PER_PAGE)),
paper_size: PAPER.size,
pagina_alto: PAPER.height,
};
}
const param = (p: Partial<GatewayParam> & Pick<GatewayParam, 'name' | 'label' | 'kind'>): GatewayParam => ({ type: 'xsd:string', required: false, ...p });
/** Etiquetas de bultos de una guía de remisión (entrega): recibe la entrega y la cantidad de etiquetas a imprimir (papel Carta precortado). */
export const ETIQUETAS_BULTOS: HtmlReportDef = {
code: 'etiquetas-bultos',
title: 'Etiquetas de bultos',
category: 'Ventas',
description: 'Etiquetas de los bultos de una guía de remisión: cliente, dirección, lugar y documento, numeradas (1/6, 2/6…), 4 por hoja.',
template: 'tagumedica/etiquetas-bultos.njk',
params: [
param({ name: 'docEntry', label: 'Entrega', kind: 'lookup', required: true, lookup: 'doc:DeliveryNotes', type: 'xsd:decimal' }),
param({ name: 'cantidad', label: 'Cantidad de etiquetas', kind: 'number', required: true, type: 'xsd:decimal' }),
// el papel es siempre Carta precortado: se muestra pero no se edita
param({ name: 'papel', label: 'Papel', kind: 'select', required: true, defaultValue: 'Carta', readOnly: true, options: [{ value: 'Carta', label: 'Carta' }] }),
],
async data(values, ctx) {
const quantity = Number(values['cantidad']);
if (!Number.isInteger(quantity) || quantity < 1 || quantity > MAX_LABELS) throw new BadRequestException(`La cantidad de etiquetas debe ser un entero entre 1 y ${MAX_LABELS}`);
const rows = await ctx.query('delivery-labels', { docEntry: Number(values['docEntry']) });
if (!rows[0]) throw new BadRequestException(`No se encontró la entrega ${values['docEntry']}`);
return labelsData(rows[0], quantity);
},
};
@@ -1,7 +1,8 @@
import { ETIQUETAS_BULTOS } from './etiquetas-bultos.js';
import type { HtmlReportDef } from './html-reports.types.js';
/**
* Reportes HTML que agrega la rama de un cliente (vacío en `main`): cada uno con su plantilla Nunjucks (en la carpeta de
* plantillas del cliente), sus parámetros y la función que arma sus datos con consultas registradas de `sapdb`.
*/
export const CLIENT_HTML_REPORTS: readonly HtmlReportDef[] = [];
export const CLIENT_HTML_REPORTS: readonly HtmlReportDef[] = [ETIQUETAS_BULTOS];
@@ -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');
});
});
+67 -3
View File
@@ -1,7 +1,71 @@
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[] = [
{
// Datos de una guía de remisión (entrega) para sus etiquetas de bultos: cliente, dirección de entrega, lugar y documento
// (el número de la guía, o su folio si no lo tiene).
name: 'delivery-labels',
title: 'Datos de una entrega para las etiquetas de bultos',
sql: {
hana: `SELECT TOP 1 T0."CardName" AS "CardName",
CASE WHEN IFNULL(T0."NumAtCard", '') <> '' THEN T0."NumAtCard" ELSE T0."FolioPref" || '-' || T0."FolioNum" END AS "Guia",
T7."StreetS" AS "Direccion", T7."CountyS" AS "Provincia", T14."Name" AS "Departamento", T7."ZipCodeS" AS "Distrito"
FROM "ODLN" T0
LEFT JOIN "DLN12" T7 ON T0."DocEntry" = T7."DocEntry"
LEFT JOIN "OCST" T14 ON T14."Code" = T7."StateS" AND T14."Country" = T7."CountryS"
WHERE T0."DocEntry" = :docEntry`,
},
params: { docEntry: { type: 'number' } },
menu: '/reportes-html/ejecutar',
maxRows: 1,
},
{
// 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`.
*/
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[] = [];
@@ -0,0 +1,146 @@
{#-
Etiquetas de bultos de una guía de remisión (TAGUMEDICA): 4 etiquetas horizontales por hoja (2 × 2).
Datos: datos.cliente, datos.direccion, datos.lugar, datos.documento · paginas (listas de 4 etiquetas con numero y total) ·
paper_size (letter | A4) · pagina_alto (alto útil de la hoja).
Migrada de mini-erp (templates/reports/stickers.html, Jinja2): misma plantilla con el interlineado fijado (line-height)
para que Chrome imprima igual que WeasyPrint, y la fuente incrustada con asset().
-#}
<!DOCTYPE html>
<html>
<head>
<style>
@font-face {
font-family: 'Orbitron';
src: url('{{ asset("tagumedica/fonts/Orbitron-Bold.ttf") }}') format('truetype');
font-weight: bold;
}
@page {
size: {{ paper_size }} landscape;
margin: 1.6cm 1.5cm 1.8cm 2.7cm;
}
/* En pantalla no hay hoja: el cuerpo lleva los mismos márgenes del @page para ver las etiquetas como saldrán. */
@media screen {
body { margin: 1.6cm 1.5cm 1.8cm 2.7cm !important; }
.pagina { margin-bottom: 0.6cm; }
}
body {
line-height: 1.2;
font-family: Arial, Helvetica, sans-serif;
margin: 0;
padding: 0;
}
.pagina {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
column-gap: calc(0.15in + 0.3cm);
row-gap: 0.15in;
height: {{ pagina_alto }};
page-break-after: always;
}
.pagina:last-child {
page-break-after: auto;
}
.sticker {
height: 100%;
border: 2px solid #000;
padding: 0.15in 0.35in;
box-sizing: border-box;
position: relative;
display: flex;
flex-direction: column;
}
.titulo {
color: #E8781E;
font-family: 'Orbitron', 'Arial Black', Arial, sans-serif;
font-size: 20pt;
font-weight: bold;
letter-spacing: 1px;
margin-bottom: 10px;
}
.campo {
margin-bottom: 8px;
}
.campo-direccion {
min-height: 0.5in;
}
.campo-label {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: normal;
}
.campo-valor {
font-family: Arial, Helvetica, sans-serif;
font-size: 13pt;
font-weight: bold;
}
.contador {
font-family: Arial, Helvetica, sans-serif;
font-size: 38pt;
font-weight: bold;
}
.documento-linea {
position: absolute;
left: 0.35in;
right: 0.35in;
bottom: 0.15in;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.documento-linea .campo {
margin-bottom: 0;
}
</style>
</head>
<body>
{% for pagina in paginas %}
<div class="pagina">
{% for sticker in pagina %}
<div class="sticker">
<div class="titulo">TAGUMEDICA</div>
<div class="campo">
<div class="campo-label">DATOS DEL CLIENTE:</div>
<div class="campo-valor">{{ datos.cliente }}</div>
</div>
<div class="campo campo-direccion">
<div class="campo-label">DIRECCIÓN:</div>
<div class="campo-valor">{{ datos.direccion }}</div>
</div>
<div class="campo">
<div class="campo-label">LUGAR:</div>
<div class="campo-valor">{{ datos.lugar }}</div>
</div>
<div class="documento-linea">
<div class="campo">
<div class="campo-label">DOCUMENTO:</div>
<div class="campo-valor">{{ datos.documento }}</div>
</div>
<div class="contador">{{ sticker.numero }}/{{ sticker.total }}</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
</body>
</html>
+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)),
*/
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}`);
@@ -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,189 @@
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { apiErrorMessage } from '../../../core/api';
import { FieldNames } from '../../../core/field-names';
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 [attr.title]="'DocNum'">Solicitud@if (names.show()) {<span class="db">DocNum</span>}</th><th [attr.title]="'DocDate'">Fecha@if (names.show()) {<span class="db">DocDate</span>}</th><th [attr.title]="'U_SIS_MOTEST'">Motivo del estado@if (names.show()) {<span class="db">U_SIS_MOTEST</span>}</th><th [attr.title]="'U_SIS_COMEST'">Comentario@if (names.show()) {<span class="db">U_SIS_COMEST</span>}</th><th [attr.title]="'U_SIS_ACCTOM'">Acción a tomar@if (names.show()) {<span class="db">U_SIS_ACCTOM</span>}</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 .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
.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 names = inject(FieldNames);
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,179 @@
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_GetDefaultSeries').flush({ Series: 7 });
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('deja elegida la serie por defecto de SAP', async () => {
await search('2090456');
expect(page().form().series).toBe(7);
expect((el().querySelector('sbo-select-field select') as HTMLSelectElement).selectedOptions[0]?.textContent?.trim()).toBe('TR-01');
});
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 }] },
],
});
// la serie por defecto de SAP viaja elegida
expect(req.request.body).toHaveProperty('Series', 7);
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('cada campo de la solicitud lleva el nombre de su campo de SAP (botón «Campos»)', async () => {
await search('2090456');
const titles = [...el().querySelectorAll('label')].map((l) => l.getAttribute('title'));
expect(titles).toEqual(expect.arrayContaining(['BatchNumber', 'DocDate', 'Series', 'U_SIS_MOVINV', 'U_EXX_TIPOOPER', 'U_SIS_TIPDESP', 'U_SIS_MOTALM', 'U_SIS_MOTEST', 'U_SIS_COMEST', 'U_SIS_ACCTOM']));
expect([...el().querySelectorAll('.stock th')].map((t) => t.getAttribute('title'))).toEqual(expect.arrayContaining(['ItemCode', 'WarehouseCode', 'Quantity']));
});
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,263 @@
import { ChangeDetectionStrategy, Component, computed, inject, resource, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { apiErrorMessage } from '../../../core/api';
import { FieldNames } from '../../../core/field-names';
import { todayIso } from '../../../core/format';
import { DateField, LookupField, SelectField, TextField, TextareaField } 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, TextareaField],
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" dbName="BatchNumber" [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" dbName="FromWarehouseCode" [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 [attr.title]="'ItemCode'">Artículo@if (names.show()) {<span class="db">ItemCode</span>}</th><th [attr.title]="'ItemDescription'">Descripción@if (names.show()) {<span class="db">ItemDescription</span>}</th><th [attr.title]="'WarehouseCode'">Almacén@if (names.show()) {<span class="db">WarehouseCode</span>}</th><th [attr.title]="'WarehouseName'">Nombre@if (names.show()) {<span class="db">WarehouseName</span>}</th>
<th class="num" [attr.title]="'Quantity'">En stock@if (names.show()) {<span class="db">Quantity</span>}</th><th class="num" [attr.title]="'CommitQty'">Comprometido@if (names.show()) {<span class="db">CommitQty</span>}</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" dbName="DocDate" required [value]="form().docDate" (valueChange)="set({ docDate: $event })" />
<sbo-select-field label="Serie" dbName="Series" [options]="series.value() ?? []" placeholder="(por defecto)" [value]="form().series" (valueChange)="set({ series: $event === null || $event === '' ? null : +$event })" />
<sbo-lookup-field label="Almacén destino" dbName="WarehouseCode" required [lookup]="warehouse" [value]="form().toWarehouse" (valueChange)="set({ toWarehouse: $event })" resolveOnLeave />
</div>
<div class="sbo-col">
<sbo-select-field label="Movimiento de inventario" dbName="U_SIS_MOVINV" required [options]="options().movimientoInventario" placeholder="" [value]="form().movimientoInventario" (valueChange)="set({ movimientoInventario: '' + ($event ?? '') })" />
<sbo-select-field label="Tipo de operación" dbName="U_EXX_TIPOOPER" required [options]="options().tipoOperacion" placeholder="" [value]="form().tipoOperacion" (valueChange)="set({ tipoOperacion: '' + ($event ?? '') })" />
<sbo-select-field label="Tipo de despacho" dbName="U_SIS_TIPDESP" required [options]="options().tipoDespacho" placeholder="" [value]="form().tipoDespacho" (valueChange)="set({ tipoDespacho: '' + ($event ?? '') })" />
<sbo-select-field label="Motivo de almacén" dbName="U_SIS_MOTALM" required [options]="options().movimientoAlmacen" placeholder="" [value]="form().movimientoAlmacen" (valueChange)="set({ movimientoAlmacen: '' + ($event ?? '') })" />
</div>
</div>
<sbo-textarea-field label="Motivo del estado" dbName="U_SIS_MOTEST" [rows]="3" required [value]="form().motivo" (valueChange)="set({ motivo: $event })" [error]="over(form().motivo, motivoMax)" />
<sbo-textarea-field label="Comentario del estado" dbName="U_SIS_COMEST" [rows]="3" [required]="config.requireDetail" [value]="form().comentario" (valueChange)="set({ comentario: $event })" [error]="over(form().comentario, textMax)" />
<sbo-textarea-field label="Acción a tomar" dbName="U_SIS_ACCTOM" [rows]="3" [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 .db { display: block; font: 10.5px/1.2 var(--mono, monospace); font-weight: 400; color: var(--quiet); }
.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);
protected readonly names = inject(FieldNames);
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.defaultSeries.value() ?? null));
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(() => []) });
/** La serie por defecto de SAP: queda elegida al buscar el lote (se puede cambiar o dejar en blanco). */
private readonly defaultSeries = resource({ loader: () => this.api.defaultSeries().catch(() => null) });
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,103 @@
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']) }));
}
/** La serie que SAP usa por defecto para las solicitudes de traslado, o `null` si no se pudo saber. */
async defaultSeries(): Promise<number | null> {
const res = await firstValueFrom(this.http.post<{ Series?: number }>(`${API_URL}/sl/SeriesService_GetDefaultSeries`, { DocumentTypeParams: { Document: OBJECT_TYPE } }));
return typeof res.Series === 'number' ? res.Series : null;
}
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,123 @@
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' });
expect(initialForm(cfg, '2026-10-20', 7).series).toBe(7);
});
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 (`series`: la que SAP usa por defecto, si se conoce). */
export const initialForm = (config: LotFlowConfig, today: string, series: number | null = null): TransferForm => ({
docDate: today,
series,
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) }],
})),
);
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { masterKeysOf } from '../master-model';
import { MASTER_SCREENS } from '../master-screens';
describe('Lotes de TAGUMEDICA', () => {
const screen = MASTER_SCREENS.find((s) => s.path === '/inventario/lotes')!;
it('suma sus campos de usuario en dos pestañas nuevas, después de las del base', () => {
expect(screen.layout.tabs.map((t) => t.id)).toEqual(['general', 'stock', 'detalles', 'calidad', 'registro']);
const keys = masterKeysOf(screen.layout);
for (const k of ['U_SIS_ESTLOT', 'U_SIS_ESTLIB', 'U_SIS_FECAPROB', 'U_SIS_FECLIB', 'U_SIS_RESPAPROB', 'U_SIS_OBSER', 'U_SIS_REGSAN', 'U_INT_NUMPRO', 'U_SIS_ACTMUE', 'U_SIS_INFENS', 'U_SIS_PROCED', 'U_INT_UBIAPT']) {
expect(keys, k).toContain(k);
}
});
it('la hora de aprobación se ve pero no se edita (la pone el proceso de aprobación)', () => {
const hora = screen.layout.tabs.flatMap((t) => [...t.left, ...t.right]).find((f) => 'key' in f && f.key === 'U_SIS_HORAPROB');
expect(hora && 'readonly' in hora && hora.readonly).toBe(true);
});
it('la lista muestra el estado de calidad y de liberación con su texto', () => {
const cols = Object.fromEntries(screen.columns.map((c) => [c.key, c]));
expect(cols['U_SIS_ESTLOT'].format?.({ U_SIS_ESTLOT: '102' })).toBe('Aprobado');
expect(cols['U_SIS_ESTLIB'].format?.({ U_SIS_ESTLIB: '102' })).toBe('No liberado');
});
});
@@ -1,17 +1,73 @@
import { udfField } from '../../documents/document-layouts';
import type { MasterExtensions } from '../master-extensions';
/** 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' },
];
/** Etiquetas de la lista para `U_SIS_ESTLIB`. */
const ESTADO_LIBERACION = [
{ value: '101', label: 'Liberado' },
{ value: '102', label: 'No liberado' },
];
const label = (options: readonly { value: string; label: string }[], value: unknown): string => options.find((o) => o.value === String(value))?.label ?? String(value ?? '');
/**
* Campos que este cliente agrega a la pantalla de lotes. En `main` va vacío: el proyecto base solo lleva campos
* estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos estándar adicionales o de
* usuario (`U_*`) y no toca ningún otro archivo.
* Campos de usuario de TAGUMEDICA en los lotes (`OBTN`, verificados en `CUFD`/`UFD1` de `SBO_TAGUMEDICA_21`). Solo
* están en esta rama; `main` lleva solo campos estándar.
*
* Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura
* (`readOnlyFields`). Un nombre que no exista, o ocultar un campo obligatorio, lanza un error al arrancar.
*
* Ejemplo (los campos son de ejemplo):
*
* '/inventario/lotes': {
* newTabs: [{ id: 'calidad', label: 'Calidad', left: [{ type: 'text', key: 'U_Ubicacion', label: 'Ubicación' }], right: [] }],
* },
* 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. 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 = {};
export const LOTES_EXTENSIONS: MasterExtensions = {
'/inventario/lotes': {
newTabs: [
{
id: 'calidad',
label: 'Calidad',
left: [
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: [
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' },
],
},
{
id: 'registro',
label: 'Registro y ensayos',
left: [
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' },
],
right: [
{ type: 'text', key: 'U_SIS_ACTMUE', label: 'Acta de muestreo' },
{ type: 'text', key: 'U_SIS_INFENS', label: 'Informe de ensayo' },
{ type: 'text', key: 'U_INT_UBIAPT', label: 'Ubicación APT' },
],
},
],
listColumns: [
{
key: 'U_SIS_ESTLOT',
title: 'Calidad',
width: '110px',
format: (r) => label(ESTADO_LOTE, r['U_SIS_ESTLOT']),
tone: (r) => (r['U_SIS_ESTLOT'] === '102' ? 'ok' : r['U_SIS_ESTLOT'] === '101' ? 'info' : r['U_SIS_ESTLOT'] === '103' ? 'err' : 'warn'),
},
{ key: 'U_SIS_ESTLIB', title: 'Liberación', width: '110px', format: (r) => label(ESTADO_LIBERACION, r['U_SIS_ESTLIB']), tone: (r) => (r['U_SIS_ESTLIB'] === '101' ? 'ok' : 'muted') },
],
},
};
@@ -15,9 +15,10 @@ const EXT: MasterExtensions = {
};
describe('extensiones por cliente de los maestros', () => {
it('en main no agregan nada', () => {
expect(MASTER_EXTENSIONS).toEqual({});
expect(MASTER_SCREENS).toEqual(BASE_MASTER_SCREENS);
// En `main` esta prueba exige `{}` (el base no lleva campos de cliente); en la rama del cliente se lista lo que agrega.
it('en esta rama solo se extienden las pantallas del cliente', () => {
expect(Object.keys(MASTER_EXTENSIONS)).toEqual(['/inventario/lotes']);
expect(MASTER_SCREENS.filter((s) => s.path !== '/inventario/lotes')).toEqual(BASE_MASTER_SCREENS.filter((s) => s.path !== '/inventario/lotes'));
});
it('agregan campos al final de la cabecera y de las pestañas, una pestaña nueva y columnas de lista', () => {
+8 -1
View File
@@ -9,4 +9,11 @@ import type { MenuItem } from './menu.js';
*
* 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' },
],
};