Compare commits
1
Commits
67fbcdcfbf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5e15a8f79 |
No files matched your search
@@ -62,22 +62,10 @@ 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.
|
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»)
|
## 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.
|
El mini-erp mostraba el nombre del campo de la base junto a cada casillero (`dbField`). Aquí es general: el botón **Campos** de la barra superior (`FieldNames`, se recuerda en `localStorage` `erp_fields`) muestra, debajo de cada etiqueta y en la cabecera de las tablas (listas, líneas de documento, tablas de filas), el nombre con el que el Service Layer lee y guarda el campo (`ItemCode`, `U_SIS_ESTLOT`); sin activarlo, el nombre sale siempre como ayuda al pasar el cursor. Los campos lo reciben con `dbName` (`FieldBase`/`SboField`); los formularios de maestros y documentos lo pasan solos desde su configuración declarativa. Cubre los formularios de maestros, de documentos (incluidas las filas con varios controles: `FolioPrefixString · FolioNumber`, `DocCurrency · DocRate`…, con `fieldDbName` en `document-layouts.ts`), de pagos y de asientos (sus campos van escritos a mano en el HTML con `dbName="…"`: un campo nuevo debe llevarlo) y las cabeceras de tablas. Solo se muestran como campos de SAP los nombres que empiezan con mayúscula (`isSapField`): las columnas internas de la interfaz (`docNum`, `_Name`) no.
|
||||||
|
|
||||||
## TAGUMEDICA: flujo de lotes (solo en `cliente/tagumedica`)
|
|
||||||
|
|
||||||
Tres pantallas del menú Inventario, migradas del mini-erp (`features/client/lotes/`, registradas con los puntos de extensión de cliente): **Inmovilizar lotes**, **Liberar lotes** y **Confirmar liberación de lote**. Crean y modifican **solicitudes de traslado** (`InventoryTransferRequests`) con campos de usuario de la tabla `OWTQ`. Valores del negocio en `lotes-flujo.config.ts` (almacén de inmovilizados `302`, aprobados `201`, `U_SIS_MOTALM` 107 «INMOVILIZADO DT» / 108 «LIBERACIÓN DT», movimiento `XTD`, operación `11`, despacho `N`); lógica pura y probada en `lotes-flujo.ts`.
|
|
||||||
- **Inmovilizar / Liberar** (`lote-transfer.page`, una sola pantalla con `data.flow`): se busca el lote por su número (consulta `lot-stock` a HANA: artículo + almacén con stock), se marcan los almacenes con la cantidad (por defecto todo el stock; no se puede superar), se completa la solicitud y se crea con una línea por almacén de origen y el lote en cada una (`BatchNumbers`). Inmovilizar: destino 302 y motivo, comentario y acción obligatorios, no se puede volver a mover lo que ya está en 302. Liberar: origen 302 (editable), destino 201, motivo 108 y solo el motivo obligatorio. La serie es opcional (sin elegir, SAP usa la por defecto); un error de SAP deja el panel con lo escrito.
|
|
||||||
- **Confirmar liberación** (`lote-release-confirm.page`): lista las solicitudes abiertas con `U_SIS_MOTALM = 108`; al elegir una se pide completa y, por cada línea, la consulta `lot-sources` (transferencias `OWTR` que llevaron el lote al almacén de inmovilizados, `OITL`/`ITL1`) da de qué almacenes vino; `allocateByOrigin` reparte la cantidad a prorrata (enteros; el último almacén absorbe el redondeo) y el usuario lo corrige. Al confirmar se reemplazan las líneas (`B1S-ReplaceCollectionsOnPatch`, la primera conserva su `LineNum`, las demás van sin él, una línea sin filas se quita). **Diferencia con el mini-erp:** aquí el reparto de cada línea debe sumar su cantidad.
|
|
||||||
- Los catálogos (movimiento de inventario, tipo de operación, despacho, motivo de almacén) salen de la definición de esos campos en SAP (`/api/sapdb/user-fields/OWTQ/…`: tablas de usuario `@SIS_MOVINV` y `@EXX_TIPOOPER` enlazadas, valores válidos de `SIS_TIPDESP` y `SIS_MOTALM`); no hay SQL propio para ellos. Permisos: las tres rutas de menú leen y modifican `InventoryTransferRequests` (`sl-resources.extensions.ts`) y ejecutan las consultas de `sapdb.extensions.ts`.
|
|
||||||
- **No se ha escrito nada en SAP desde estas pantallas** (las pruebas usan un Service Layer simulado): `SBO_TAGUMEDICA_21` es del cliente y no se escribe sin preguntar; `SBODemoCL` no tiene estos campos de usuario.
|
|
||||||
|
|
||||||
## Cómo verificar cambios de interfaz
|
## Cómo verificar cambios de interfaz
|
||||||
|
|
||||||
- Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP.
|
- Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP.
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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 <PLASTIC> & 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(':');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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,8 +1,7 @@
|
|||||||
import { ETIQUETAS_BULTOS } from './etiquetas-bultos.js';
|
|
||||||
import type { HtmlReportDef } from './html-reports.types.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
|
* 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`.
|
* 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[] = [ETIQUETAS_BULTOS];
|
export const CLIENT_HTML_REPORTS: readonly HtmlReportDef[] = [];
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +1,7 @@
|
|||||||
import type { SapDbQuery } from './sapdb.types.js';
|
import type { SapDbQuery } from './sapdb.types.js';
|
||||||
|
|
||||||
const FLOWS = ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes'] as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consultas a la base de SAP de TAGUMEDICA (HANA): el flujo de lotes (inmovilizar, liberar y confirmar la liberación).
|
* 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
|
||||||
* Dependen de sus tablas, por eso viven solo en esta rama. Los valores de los campos de usuario de la solicitud (movimiento,
|
* sus campos de usuario. Cada una declara su SQL por motor (`hana`), sus parámetros con nombre y el permiso de menú.
|
||||||
* 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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -10,12 +10,6 @@ import type { SlResource } from './sl-resources.js';
|
|||||||
*
|
*
|
||||||
* `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`.
|
* `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`.
|
||||||
*/
|
*/
|
||||||
export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {
|
export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {};
|
||||||
// Inmovilizar y liberar crean la solicitud de traslado; confirmar la liberación la lee y la modifica.
|
|
||||||
InventoryTransferRequests: {
|
|
||||||
read: ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote'],
|
|
||||||
write: ['/inventario/inmovilizar-lotes', '/inventario/liberar-lotes', '/inventario/confirmar-liberacion-lote'],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CLIENT_SL_RESOURCES: readonly SlResource[] = [];
|
export const CLIENT_SL_RESOURCES: readonly SlResource[] = [];
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
{#-
|
|
||||||
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>
|
|
||||||
Binary file not shown.
@@ -17,11 +17,7 @@ export const clientRoute = (path: string, title: string, loadComponent: NonNulla
|
|||||||
*
|
*
|
||||||
* clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/inmovilizar.page').then((m) => m.InmovilizarPage)),
|
* clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/inmovilizar.page').then((m) => m.InmovilizarPage)),
|
||||||
*/
|
*/
|
||||||
export const CLIENT_ROUTES: Routes = [
|
export const CLIENT_ROUTES: Routes = [];
|
||||||
{ ...clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/lotes/lote-transfer.page').then((m) => m.LoteTransferPage)), data: { path: '/inventario/inmovilizar-lotes', title: 'Inmovilizar lotes', flow: 'inmovilizar' } },
|
|
||||||
{ ...clientRoute('/inventario/liberar-lotes', 'Liberar lotes', () => import('./features/client/lotes/lote-transfer.page').then((m) => m.LoteTransferPage)), data: { path: '/inventario/liberar-lotes', title: 'Liberar lotes', flow: 'liberar' } },
|
|
||||||
clientRoute('/inventario/confirmar-liberacion-lote', 'Confirmar liberación de lote', () => import('./features/client/lotes/lote-release-confirm.page').then((m) => m.LoteReleaseConfirmPage)),
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Las rutas de menú que ya tienen pantalla de cliente (no se muestran como «en construcción»). */
|
/** Las rutas de menú que ya tienen pantalla de cliente (no se muestran como «en construcción»). */
|
||||||
export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`);
|
export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { MenuService } from './menu.service';
|
||||||
|
import { RecentScreens } from './recent-screens';
|
||||||
|
|
||||||
|
@Component({ template: '' })
|
||||||
|
class Page {}
|
||||||
|
|
||||||
|
const ROUTES = ['ventas/ordenes', 'ventas/entrega', 'inventario/lotes', 'compras/ordenes', 'compras/facturas', 'bancos/pagos-recibidos', 'finanzas/asientos'].map((path) => ({ path, component: Page }));
|
||||||
|
|
||||||
|
describe('RecentScreens', () => {
|
||||||
|
async function setup(allowed: string[] = ['*']) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({ providers: [provideRouter(ROUTES), provideHttpClient(), provideHttpClientTesting()] });
|
||||||
|
const loaded = TestBed.inject(MenuService).ensureLoaded();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: allowed });
|
||||||
|
await loaded;
|
||||||
|
return { recent: TestBed.inject(RecentScreens), router: TestBed.inject(Router) };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('anota las pantallas del menú que se abren, la más reciente primero, y las recuerda', async () => {
|
||||||
|
const { recent, router } = await setup();
|
||||||
|
await router.navigateByUrl('/ventas/ordenes');
|
||||||
|
await router.navigateByUrl('/inventario/lotes');
|
||||||
|
expect(recent.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
|
||||||
|
expect(JSON.parse(localStorage.getItem('erp_recent')!)).toEqual(['/inventario/lotes', '/ventas/ordenes']);
|
||||||
|
// otra sesión del navegador las recupera
|
||||||
|
const again = (await setup()).recent;
|
||||||
|
expect(again.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('una pantalla abierta de nuevo sube al primer lugar sin repetirse, y el detalle cuenta como su pantalla', async () => {
|
||||||
|
const { recent, router } = await setup();
|
||||||
|
await router.navigateByUrl('/ventas/ordenes');
|
||||||
|
await router.navigateByUrl('/inventario/lotes');
|
||||||
|
await router.navigateByUrl('/ventas/ordenes');
|
||||||
|
expect(recent.paths()).toEqual(['/ventas/ordenes', '/inventario/lotes']);
|
||||||
|
recent.record('/inventario/lotes/75437?x=1');
|
||||||
|
expect(recent.paths()).toEqual(['/inventario/lotes', '/ventas/ordenes']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guarda solo las 5 últimas e ignora lo que no es del menú', async () => {
|
||||||
|
const { recent, router } = await setup();
|
||||||
|
for (const p of ['/ventas/ordenes', '/ventas/entrega', '/inventario/lotes', '/compras/ordenes', '/compras/facturas', '/bancos/pagos-recibidos']) await router.navigateByUrl(p);
|
||||||
|
expect(recent.paths()).toHaveLength(5);
|
||||||
|
expect(recent.paths()[0]).toBe('/bancos/pagos-recibidos');
|
||||||
|
expect(recent.paths()).not.toContain('/ventas/ordenes');
|
||||||
|
recent.record('/no-esta-en-el-menu');
|
||||||
|
expect(recent.paths()).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('un valor dañado del navegador no rompe nada', async () => {
|
||||||
|
localStorage.setItem('erp_recent', '{no es json');
|
||||||
|
expect((await setup()).recent.paths()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { NavigationEnd, Router } from '@angular/router';
|
||||||
|
import { filter } from 'rxjs';
|
||||||
|
import { MenuService } from './menu.service';
|
||||||
|
|
||||||
|
const KEY = 'erp_recent';
|
||||||
|
const MAX = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Las últimas pantallas del menú que se abrieron (hasta 5, la más reciente primero), recordadas en el navegador. Se llenan
|
||||||
|
* solas al navegar; la bienvenida las ofrece como atajo. Solo cuentan las pantallas del menú del usuario.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class RecentScreens {
|
||||||
|
private readonly menu = inject(MenuService);
|
||||||
|
readonly paths = signal<readonly string[]>(this.read());
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
inject(Router)
|
||||||
|
.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
|
||||||
|
.subscribe((e) => this.record(e.urlAfterRedirects));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anota la pantalla del menú a la que pertenece la ruta (`/inventario/lotes/75437` cuenta como `/inventario/lotes`). */
|
||||||
|
record(url: string): void {
|
||||||
|
const path = url.split(/[?#]/)[0];
|
||||||
|
const item = this.menu.modules().flatMap((m) => m.items).find((i) => path === i.path || path.startsWith(`${i.path}/`));
|
||||||
|
if (!item || this.paths()[0] === item.path) return;
|
||||||
|
const next = [item.path, ...this.paths().filter((p) => p !== item.path)].slice(0, MAX);
|
||||||
|
this.paths.set(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* sin almacenamiento: vale para esta sesión */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private read(): string[] {
|
||||||
|
try {
|
||||||
|
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? '[]');
|
||||||
|
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').slice(0, MAX) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
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.' : '' });
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
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».' });
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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 }] },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
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,97 @@
|
|||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
|
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { Router, provideRouter } from '@angular/router';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { SessionStore } from '../../core/auth/session.store';
|
||||||
|
import { MenuService } from '../../core/menu/menu.service';
|
||||||
|
import { HomePage } from './home.page';
|
||||||
|
|
||||||
|
describe('HomePage', () => {
|
||||||
|
let fixture: ComponentFixture<HomePage>;
|
||||||
|
const el = () => fixture.nativeElement as HTMLElement;
|
||||||
|
const tick = async () => {
|
||||||
|
fixture.detectChanges();
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function open(allowed: string[], recent: string[] = []) {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
localStorage.clear();
|
||||||
|
if (recent.length) localStorage.setItem('erp_recent', JSON.stringify(recent));
|
||||||
|
TestBed.configureTestingModule({ providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()] });
|
||||||
|
const loaded = TestBed.inject(MenuService).ensureLoaded();
|
||||||
|
TestBed.inject(HttpTestingController).expectOne('/api/user/menu').flush({ allowedPaths: allowed });
|
||||||
|
await loaded;
|
||||||
|
fixture = TestBed.createComponent(HomePage);
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = async (text: string) => {
|
||||||
|
const input = el().querySelector('input.find') as HTMLInputElement;
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new Event('input'));
|
||||||
|
await tick();
|
||||||
|
return input;
|
||||||
|
};
|
||||||
|
const cards = () => [...el().querySelectorAll('.card')].map((c) => [c.querySelector('h2')?.textContent?.trim(), [...c.querySelectorAll('a')].map((a) => a.textContent?.trim())] as const);
|
||||||
|
|
||||||
|
beforeEach(() => TestBed.resetTestingModule());
|
||||||
|
|
||||||
|
it('muestra una tarjeta por módulo con sus pantallas en lista, solo las permitidas', async () => {
|
||||||
|
await open(['/ventas/ordenes', '/ventas/entrega', '/inventario/lotes']);
|
||||||
|
expect(cards()).toEqual([
|
||||||
|
['Ventas', ['Órdenes de venta', 'Entregas']],
|
||||||
|
['Inventario', ['Lotes']],
|
||||||
|
]);
|
||||||
|
expect(el().querySelector('.card a')?.getAttribute('href')).toBe('/ventas/ordenes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('el saludo lleva el nombre y la empresa', async () => {
|
||||||
|
await open(['*']);
|
||||||
|
TestBed.inject(SessionStore).set({ token: 't', user: { webUser: 'u', empId: 1, firstName: 'Jose', lastName: 'H' }, company: 'SBO_X', sessionTimeoutMinutes: 30 } as never);
|
||||||
|
await tick();
|
||||||
|
expect(el().querySelector('h1')?.textContent).toContain('Bienvenido, Jose');
|
||||||
|
expect(el().querySelector('.sub')?.textContent).toContain('SBO_X');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('el buscador filtra las pantallas de todos los módulos, sin tildes ni mayúsculas, y dice a qué módulo pertenecen', async () => {
|
||||||
|
await open(['*']);
|
||||||
|
await type('ORDENES');
|
||||||
|
const found = [...el().querySelectorAll('.found a')].map((a) => [a.querySelector('strong')?.textContent, a.querySelector('span')?.textContent]);
|
||||||
|
expect(found).toEqual([['Órdenes de venta', 'Ventas'], ['Órdenes de compra', 'Compras']]);
|
||||||
|
expect(el().querySelector('.cards')).toBeNull();
|
||||||
|
// varias palabras: todas deben estar (en la pantalla o en su módulo)
|
||||||
|
await type('compra orden');
|
||||||
|
expect([...el().querySelectorAll('.found a strong')].map((s) => s.textContent)).toEqual(['Órdenes de compra']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sin coincidencias lo dice, y vaciar el buscador (o Esc) devuelve las tarjetas', async () => {
|
||||||
|
await open(['*']);
|
||||||
|
const input = await type('zzzz');
|
||||||
|
expect(el().querySelector('.found .none')?.textContent).toContain('Ninguna pantalla coincide con «zzzz»');
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
await tick();
|
||||||
|
expect(el().querySelector('.cards')).not.toBeNull();
|
||||||
|
expect((el().querySelector('input.find') as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter abre la primera coincidencia', async () => {
|
||||||
|
await open(['*']);
|
||||||
|
const navigate = vi.spyOn(TestBed.inject(Router), 'navigateByUrl').mockResolvedValue(true);
|
||||||
|
const input = await type('lotes');
|
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
|
||||||
|
expect(navigate).toHaveBeenCalledWith('/inventario/lotes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ofrece las pantallas recientes que el usuario todavía puede abrir', async () => {
|
||||||
|
await open(['/ventas/ordenes', '/inventario/lotes'], ['/inventario/lotes', '/compras/ordenes', '/ventas/ordenes']);
|
||||||
|
expect([...el().querySelectorAll('.recent a')].map((a) => a.textContent?.trim())).toEqual(['Lotes', 'Órdenes de venta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sin recientes no muestra esa fila', async () => {
|
||||||
|
await open(['*']);
|
||||||
|
expect(el().querySelector('.recent')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,36 +1,129 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { SessionStore } from '../../core/auth/session.store';
|
import { SessionStore } from '../../core/auth/session.store';
|
||||||
import { MenuService } from '../../core/menu/menu.service';
|
import { MenuService } from '../../core/menu/menu.service';
|
||||||
|
import { RecentScreens } from '../../core/menu/recent-screens';
|
||||||
|
|
||||||
|
/** Sin tildes ni mayúsculas, para que «ordenes» encuentre «Órdenes de venta». */
|
||||||
|
const plain = (s: string): string => s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bienvenida: un buscador de pantallas, las últimas que se abrieron y una tarjeta por módulo del menú con sus pantallas en
|
||||||
|
* lista. Solo lo que el usuario puede abrir.
|
||||||
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'sbo-home-page',
|
selector: 'sbo-home-page',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [RouterLink],
|
imports: [RouterLink],
|
||||||
template: `
|
template: `
|
||||||
|
<header class="top">
|
||||||
|
<div>
|
||||||
<h1>Bienvenido, {{ session.user()?.firstName }}</h1>
|
<h1>Bienvenido, {{ session.user()?.firstName }}</h1>
|
||||||
<p class="sub">{{ session.company() }}</p>
|
<p class="sub">{{ session.company() }}</p>
|
||||||
@for (m of menu.modules(); track m.id) {
|
</div>
|
||||||
<section>
|
<input
|
||||||
<h2>{{ m.label }}</h2>
|
class="find"
|
||||||
<div class="links">
|
type="search"
|
||||||
@for (i of m.items; track i.path) {
|
placeholder="Buscar pantalla…"
|
||||||
<a [routerLink]="i.path">{{ i.label }}</a>
|
aria-label="Buscar pantalla"
|
||||||
|
autocomplete="off"
|
||||||
|
[value]="query()"
|
||||||
|
(input)="query.set($any($event.target).value)"
|
||||||
|
(keydown.enter)="openFirst()"
|
||||||
|
(keydown.escape)="query.set('')"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (query().trim()) {
|
||||||
|
<ul class="found" role="list" aria-label="Pantallas encontradas">
|
||||||
|
@for (r of found(); track r.path) {
|
||||||
|
<li><a [routerLink]="r.path"><strong>{{ r.label }}</strong> <span>{{ r.module }}</span></a></li>
|
||||||
|
} @empty {
|
||||||
|
<li class="none">Ninguna pantalla coincide con «{{ query().trim() }}».</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
@if (recent().length) {
|
||||||
|
<section class="recent" aria-label="Recientes">
|
||||||
|
<h2>Recientes</h2>
|
||||||
|
<div class="chips">
|
||||||
|
@for (r of recent(); track r.path) {
|
||||||
|
<a [routerLink]="r.path" [title]="r.module">{{ r.label }}</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
<div class="cards">
|
||||||
|
@for (m of menu.modules(); track m.id) {
|
||||||
|
<section class="card">
|
||||||
|
<h2>{{ m.label }}</h2>
|
||||||
|
<ul role="list">
|
||||||
|
@for (i of m.items; track i.path) {
|
||||||
|
<li><a [routerLink]="i.path">{{ i.label }}</a></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
:host { display: block; }
|
||||||
|
.top { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 16px; margin-bottom: 14px; }
|
||||||
h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
h1 { margin: 0; font-size: 18px; font-weight: 600; }
|
||||||
.sub { margin: 2px 0 16px; color: var(--quiet); }
|
.sub { margin: 2px 0 0; color: var(--quiet); }
|
||||||
h2 { margin: 12px 0 6px; font-size: 13px; color: var(--quiet); text-transform: uppercase; letter-spacing: .4px; }
|
.find { width: min(320px, 100%); height: 26px; padding: 1px 8px; background: var(--field); border: 1px solid var(--field-b); border-radius: var(--radius); }
|
||||||
.links { display: flex; flex-wrap: wrap; gap: 6px; }
|
.find:focus { border-color: var(--focus); outline: none; }
|
||||||
a { padding: 4px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-lg); color: var(--ink); text-decoration: none; }
|
|
||||||
a:hover { background: var(--row-sel); border-color: var(--focus); }
|
h2 { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .4px; text-transform: uppercase; color: var(--quiet); }
|
||||||
|
.recent { margin-bottom: 14px; }
|
||||||
|
.recent h2 { margin-bottom: 4px; }
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.chips a { padding: 3px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-pill, 12px); color: var(--ink); text-decoration: none; }
|
||||||
|
.chips a:hover { background: var(--row-sel); border-color: var(--focus); }
|
||||||
|
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; align-items: start; }
|
||||||
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); overflow: hidden; }
|
||||||
|
.card h2 { padding: 6px 12px; background: var(--grid-head); border-bottom: 1px solid var(--line); color: var(--ink); }
|
||||||
|
.card ul, .found { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.card li a { display: block; padding: 4px 12px; color: var(--ink); text-decoration: none; }
|
||||||
|
.card li a:hover { background: var(--row-sel); }
|
||||||
|
.card li + li { border-top: 1px solid var(--line-soft); }
|
||||||
|
|
||||||
|
.found { max-width: 560px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius-panel); }
|
||||||
|
.found li + li { border-top: 1px solid var(--line-soft); }
|
||||||
|
.found a { display: flex; justify-content: space-between; gap: 12px; padding: 5px 12px; color: var(--ink); text-decoration: none; }
|
||||||
|
.found a:hover { background: var(--row-sel); }
|
||||||
|
.found span { color: var(--quiet); }
|
||||||
|
.found .none { padding: 10px 12px; color: var(--quiet); }
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class HomePage {
|
export class HomePage {
|
||||||
protected readonly session = inject(SessionStore);
|
protected readonly session = inject(SessionStore);
|
||||||
protected readonly menu = inject(MenuService);
|
protected readonly menu = inject(MenuService);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly recents = inject(RecentScreens);
|
||||||
|
|
||||||
|
protected readonly query = signal('');
|
||||||
|
|
||||||
|
/** Todas las pantallas que el usuario puede abrir, con su módulo. */
|
||||||
|
private readonly all = computed(() => this.menu.modules().flatMap((m) => m.items.map((i) => ({ path: i.path, label: i.label, module: m.label }))));
|
||||||
|
|
||||||
|
/** Las pantallas que coinciden con lo escrito (en el nombre de la pantalla o del módulo), sin tildes ni mayúsculas. */
|
||||||
|
protected readonly found = computed(() => {
|
||||||
|
const words = plain(this.query()).split(/\s+/).filter(Boolean);
|
||||||
|
return this.all().filter((r) => words.every((w) => plain(`${r.label} ${r.module}`).includes(w)));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Las últimas pantallas abiertas que el usuario todavía puede ver. */
|
||||||
|
protected readonly recent = computed(() => {
|
||||||
|
const byPath = new Map(this.all().map((r) => [r.path, r]));
|
||||||
|
return this.recents.paths().flatMap((p) => byPath.get(p) ?? []);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Enter abre la primera coincidencia. */
|
||||||
|
protected openFirst(): void {
|
||||||
|
const first = this.found()[0];
|
||||||
|
if (first) void this.router.navigateByUrl(first.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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,73 +1,17 @@
|
|||||||
import { udfField } from '../../documents/document-layouts';
|
|
||||||
import type { MasterExtensions } from '../master-extensions';
|
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 de usuario de TAGUMEDICA en los lotes (`OBTN`, verificados en `CUFD`/`UFD1` de `SBO_TAGUMEDICA_21`). Solo
|
* Campos que este cliente agrega a la pantalla de lotes. En `main` va vacío: el proyecto base solo lleva campos
|
||||||
* están en esta rama; `main` lleva solo campos estándar.
|
* 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.
|
||||||
*
|
*
|
||||||
* Notas verificadas en la base: `U_SIS_HORAPROB` guarda la hora como entero `hhmm` (1223 = 12:23) y la pone el proceso de
|
* 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.
|
||||||
* 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
|
* Ejemplo (los campos son de ejemplo):
|
||||||
* campos `U_SIS_MOTEST`, `U_SIS_COMEST` y `U_SIS_ACCTOM` no son del lote sino de la solicitud de traslado (`OWTQ`).
|
*
|
||||||
|
* '/inventario/lotes': {
|
||||||
|
* newTabs: [{ id: 'calidad', label: 'Calidad', left: [{ type: 'text', key: 'U_Ubicacion', label: 'Ubicación' }], right: [] }],
|
||||||
|
* },
|
||||||
*/
|
*/
|
||||||
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,10 +15,9 @@ const EXT: MasterExtensions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('extensiones por cliente de los maestros', () => {
|
describe('extensiones por cliente de los maestros', () => {
|
||||||
// En `main` esta prueba exige `{}` (el base no lleva campos de cliente); en la rama del cliente se lista lo que agrega.
|
it('en main no agregan nada', () => {
|
||||||
it('en esta rama solo se extienden las pantallas del cliente', () => {
|
expect(MASTER_EXTENSIONS).toEqual({});
|
||||||
expect(Object.keys(MASTER_EXTENSIONS)).toEqual(['/inventario/lotes']);
|
expect(MASTER_SCREENS).toEqual(BASE_MASTER_SCREENS);
|
||||||
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', () => {
|
it('agregan campos al final de la cabecera y de las pestañas, una pestaña nueva y columnas de lista', () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AccountSegmentsService } from '../core/sl/account-segments';
|
|||||||
import { AuthService } from '../core/auth/auth.service';
|
import { AuthService } from '../core/auth/auth.service';
|
||||||
import { SessionStore } from '../core/auth/session.store';
|
import { SessionStore } from '../core/auth/session.store';
|
||||||
import { MenuService } from '../core/menu/menu.service';
|
import { MenuService } from '../core/menu/menu.service';
|
||||||
|
import { RecentScreens } from '../core/menu/recent-screens';
|
||||||
import { ThemeSwitcher } from '../core/theme/theme-switcher';
|
import { ThemeSwitcher } from '../core/theme/theme-switcher';
|
||||||
|
|
||||||
const SIDEBAR_KEY = 'erp_sidebar';
|
const SIDEBAR_KEY = 'erp_sidebar';
|
||||||
@@ -48,6 +49,8 @@ export class MainLayout {
|
|||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
this.reveal(router.url);
|
this.reveal(router.url);
|
||||||
router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd), takeUntilDestroyed()).subscribe((e) => this.reveal(e.urlAfterRedirects));
|
router.events.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd), takeUntilDestroyed()).subscribe((e) => this.reveal(e.urlAfterRedirects));
|
||||||
|
// empieza a anotar las pantallas que se abren (las ofrece la bienvenida)
|
||||||
|
inject(RecentScreens);
|
||||||
void this.segments.ensureLoaded();
|
void this.segments.ensureLoaded();
|
||||||
void inject(CompanySettingsService).ensureLoaded();
|
void inject(CompanySettingsService).ensureLoaded();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,4 @@ import type { MenuItem } from './menu.js';
|
|||||||
*
|
*
|
||||||
* inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }],
|
* inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }],
|
||||||
*/
|
*/
|
||||||
export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {
|
export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {};
|
||||||
// Flujo de lotes de TAGUMEDICA: inmovilizar, liberar y confirmar la liberación (solicitudes de traslado)
|
|
||||||
inventario: [
|
|
||||||
{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' },
|
|
||||||
{ path: '/inventario/liberar-lotes', label: 'Liberar lotes' },
|
|
||||||
{ path: '/inventario/confirmar-liberacion-lote', label: 'Confirmar liberación de lote' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Reference in new issue
Block a user