diff --git a/CLAUDE.md b/CLAUDE.md index 026d2ac..0678d87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,10 +48,11 @@ Pasos de la UI (decididos): para cerrarla a mano). - **Crear devoluciones**: resultado **parcial por documento**. Cada Devolución se crea aparte; las buenas graban `U_TargetType`/`U_TargetEntry`, las fallidas `U_ErrCode`/`U_ErrMsg` y se reintentan. -- **El "stock" que se muestra y valida es la Consignación**, no `OITW.OnHand`: `DP_Stock` (v2) suma +- **El "stock" que se muestra y valida es la Consignación**, no `OITW.OnHand`: `DP_Stock` (v3) suma `PDN1.OpenInvQty` de las líneas abiertas de Entradas con `U_DOT_TipoRecepcion = 'C'` del almacén, o sea lo - mismo que consume el FIFO y lo único que se puede devolver. Si el artículo existe sin consignación devuelve 0 - (queda "Sin stock"); si no existe, error. La columna se sigue llamando `OnHand` en la consulta, el modelo y + mismo que consume el FIFO y lo único que se puede devolver. Sin consignación no hay fila: entonces `DP_Articulo` + distingue artículo existente (0, queda "Sin stock") de inexistente (error). **El analizador SQL del Service Layer + no admite `COALESCE` ni subconsultas** (error `Cannot support this function or expression`): solo JOIN + GROUP BY. La columna se sigue llamando `OnHand` en la consulta, el modelo y el UDO (`U_OnHand`); en pantalla y en Excel el encabezado es **Consignación**. Las planificaciones guardadas antes de la 2.0.0 conservan el valor viejo hasta rehacer el FIFO. - **Consultas SQL** por `SQLQueries` del Service Layer (no hay conexión directa a la BD). La API @@ -278,7 +279,7 @@ Reglas: devuelve siempre un JSON (`{abierta: null}`). **Sin verificar:** -- **`DP_Stock` v2** (2026-10-19, versión 2.0.0): suma de Consignación, solo con tests; no se ha ejecutado contra el +- **`DP_Stock` v3 y `DP_Articulo`** (2026-10-19): suma de Consignación con `SUM` + `GROUP BY` (la v2 con `COALESCE` fue rechazada por SAP); solo con tests; no se ha ejecutado contra el Service Layer (ni HANA ni MSSQL). Probar con un pegado real y comparar con la suma de `OpenInvQty`. - **HANA**: el usuario confirmó (2026-10-13) que las consultas `DP_Stock` y `DP_EntradasAbiertas` (con `PDN1.OpenInvQty`, filtro por almacén y parámetros con nombre) **funcionan en HANA** a través del diff --git a/apps/api/src/planificaciones/planificaciones.service.spec.ts b/apps/api/src/planificaciones/planificaciones.service.spec.ts index 76d0246..56b9a41 100644 --- a/apps/api/src/planificaciones/planificaciones.service.spec.ts +++ b/apps/api/src/planificaciones/planificaciones.service.spec.ts @@ -22,7 +22,7 @@ const filaHijo = (extra: Record = {}) => ({ LineId: 2, U_LineTy const dto = (texto = 'A1\t5'): ProcesarDto => ({ whsCode: '01', docDate: '2026-10-13', texto }) as ProcesarDto; -function crear(anterior: ReturnType, opciones: { stock?: unknown[]; fallaCierre?: boolean } = {}) { +function crear(anterior: ReturnType, opciones: { stock?: unknown[]; articulo?: unknown[]; fallaCierre?: boolean } = {}) { const llamadas: string[] = []; const sl = { get: vi.fn(async (_s: unknown, path: string): Promise => { @@ -42,6 +42,7 @@ function crear(anterior: ReturnType, opciones: { stock?: unknown[]; const sql = { consultar: vi.fn(async (_s: unknown, consulta: { codigo: string }) => { if (consulta.codigo === 'DP_Stock') return opciones.stock ?? [{ ItemCode: 'A1', ItemName: 'Libro', OnHand: 100 }]; + if (consulta.codigo === 'DP_Articulo') return opciones.articulo ?? []; return [{ DocNum: 101, CardCode: 'P1', CardName: 'Prov', DocDate: '2026-01-31T00:00:00Z', DocEntry: 15, LineNum: 0, OpenInvQty: 10 }]; }), }; @@ -166,3 +167,18 @@ describe('PlanificacionesService.listar', () => { expect((sl.request.mock.calls[0] as unknown as [unknown, string, string])[2]).toContain('$top=100'); }); }); + +describe('PlanificacionesService.validarPegado', () => { + it('un artículo que existe pero no tiene consignación abierta queda con 0 (Sin stock)', async () => { + const { servicio } = crear(udo(3, 'O'), { stock: [], articulo: [{ ItemCode: 'A1', ItemName: 'Libro' }] }); + const r = await servicio.validarPegado(sesion, 'B1', 'A1\t5'); + expect(r.errores).toEqual([]); + expect(r.lineas[0]).toMatchObject({ itemCode: 'A1', onHand: 0, status: 'SIN_STOCK' }); + }); + + it('un artículo inexistente es un error', async () => { + const { servicio } = crear(udo(3, 'O'), { stock: [], articulo: [] }); + const r = await servicio.validarPegado(sesion, 'B1', 'X9\t5'); + expect(r.errores[0]).toMatch(/no existe/); + }); +}); diff --git a/apps/api/src/planificaciones/planificaciones.service.ts b/apps/api/src/planificaciones/planificaciones.service.ts index f798657..8284321 100644 --- a/apps/api/src/planificaciones/planificaciones.service.ts +++ b/apps/api/src/planificaciones/planificaciones.service.ts @@ -29,7 +29,7 @@ import { import { CatalogosService } from '../catalogos/catalogos.service.js'; import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js'; import type { SapSession } from '../service-layer/session.store.js'; -import { CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js'; +import { CONSULTA_ARTICULO, CONSULTA_ENTRADAS_ABIERTAS, CONSULTA_STOCK } from '../sql-queries/sql-queries.definitions.js'; import { SqlQueriesService } from '../sql-queries/sql-queries.service.js'; import type { ProcesarDto } from './dto/planificaciones.dto.js'; import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js'; @@ -38,7 +38,8 @@ import type { UdoSl } from './udo.mapper.js'; interface FilaStock { ItemCode: string; ItemName: string | null; - OnHand: number | string; + /** Ausente cuando la fila viene de DP_Articulo (sin consignación). */ + OnHand?: number | string; } interface FilaEntrada { @@ -100,21 +101,24 @@ export class PlanificacionesService { const { filas, errores } = parsearPortapapeles(texto); const lineas = []; - const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => ({ - fila, - stock: (await this.sql.consultar(sesion, CONSULTA_STOCK, { whs: whsCode, item: fila.itemCode }))[0], - })); + const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => { + const params = { whs: whsCode, item: fila.itemCode }; + const stock = (await this.sql.consultar(sesion, CONSULTA_STOCK, params))[0]; + // Sin consignación abierta no hay fila: el artículo puede existir con 0 o no existir. + const articulo = stock ?? (await this.sql.consultar(sesion, CONSULTA_ARTICULO, { item: fila.itemCode }))[0]; + return { fila, articulo }; + }); - for (const { fila, stock } of consultas) { - if (!stock) { + for (const { fila, articulo: datos } of consultas) { + if (!datos) { errores.push(`${fila.itemCode}: el artículo no existe.`); continue; } const articulo: ArticuloADevolver = { - itemCode: stock.ItemCode, - itemName: stock.ItemName ?? '', + itemCode: datos.ItemCode, + itemName: datos.ItemName ?? '', quantity: fila.quantity, - onHand: Math.floor(Number(stock.OnHand)), + onHand: Math.floor(Number(datos.OnHand ?? 0)), }; lineas.push(crearLineaPadre(articulo)); } diff --git a/apps/api/src/sql-queries/sql-queries.definitions.ts b/apps/api/src/sql-queries/sql-queries.definitions.ts index 2990621..18aa685 100644 --- a/apps/api/src/sql-queries/sql-queries.definitions.ts +++ b/apps/api/src/sql-queries/sql-queries.definitions.ts @@ -19,24 +19,35 @@ export interface ConsultaGuardada { * "Stock" = lo que realmente se puede devolver: la suma de PDN1.OpenInvQty de las líneas abiertas de * Entradas de mercancías de Consignación (U_DOT_TipoRecepcion = 'C') del almacén, las mismas que usa el * FIFO. No es OITW.OnHand: el stock físico incluye mercancía que no es devolvible por esta vía. - * Devuelve fila (con 0) si el artículo existe pero no tiene consignación; ninguna si no existe. - * La columna se sigue llamando OnHand porque así se guarda en el UDO (U_OnHand). + * Sin filas si el artículo no tiene consignación abierta (el servicio lo distingue con DP_Articulo). + * El analizador del Service Layer no admite COALESCE ni subconsultas: por eso solo JOIN + GROUP BY. + * La columna se llama OnHand porque así se guarda en el UDO (U_OnHand). */ export const CONSULTA_STOCK: ConsultaGuardada = { codigo: 'DP_Stock', - version: 2, + version: 3, mssql: - 'SELECT T0.[ItemCode], T0.[ItemName], ' + - 'COALESCE((SELECT SUM(T2.[OpenInvQty]) FROM OPDN T1 INNER JOIN PDN1 T2 ON T2.[DocEntry] = T1.[DocEntry] ' + + 'SELECT T2.[ItemCode], T0.[ItemName], SUM(T2.[OpenInvQty]) AS [OnHand] ' + + 'FROM OPDN T1 INNER JOIN PDN1 T2 ON T2.[DocEntry] = T1.[DocEntry] ' + + 'INNER JOIN OITM T0 ON T0.[ItemCode] = T2.[ItemCode] ' + "WHERE T1.[U_DOT_TipoRecepcion] = 'C' AND T2.[LineStatus] = 'O' " + - 'AND T2.[ItemCode] = T0.[ItemCode] AND T2.[WhsCode] = :whs), 0) AS [OnHand] ' + - 'FROM OITM T0 WHERE T0.[ItemCode] = :item', + 'AND T2.[ItemCode] = :item AND T2.[WhsCode] = :whs ' + + 'GROUP BY T2.[ItemCode], T0.[ItemName]', hana: - 'SELECT T0."ItemCode", T0."ItemName", ' + - 'COALESCE((SELECT SUM(T2."OpenInvQty") FROM OPDN T1 INNER JOIN PDN1 T2 ON T2."DocEntry" = T1."DocEntry" ' + + 'SELECT T2."ItemCode", T0."ItemName", SUM(T2."OpenInvQty") AS "OnHand" ' + + 'FROM OPDN T1 INNER JOIN PDN1 T2 ON T2."DocEntry" = T1."DocEntry" ' + + 'INNER JOIN OITM T0 ON T0."ItemCode" = T2."ItemCode" ' + "WHERE T1.\"U_DOT_TipoRecepcion\" = 'C' AND T2.\"LineStatus\" = 'O' " + - 'AND T2."ItemCode" = T0."ItemCode" AND T2."WhsCode" = :whs), 0) AS "OnHand" ' + - 'FROM OITM T0 WHERE T0."ItemCode" = :item', + 'AND T2."ItemCode" = :item AND T2."WhsCode" = :whs ' + + 'GROUP BY T2."ItemCode", T0."ItemName"', +}; + +/** Solo para saber si el artículo existe (y su nombre) cuando DP_Stock no devuelve filas. */ +export const CONSULTA_ARTICULO: ConsultaGuardada = { + codigo: 'DP_Articulo', + version: 1, + mssql: 'SELECT T0.[ItemCode], T0.[ItemName] FROM OITM T0 WHERE T0.[ItemCode] = :item', + hana: 'SELECT T0."ItemCode", T0."ItemName" FROM OITM T0 WHERE T0."ItemCode" = :item', }; /**