Compare commits

...
2 Commits
Author SHA1 Message Date
georgeandClaude Sonnet 5.5 691aa454ca Versión 2.0.1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:56:36 -05:00
georgeandClaude Sonnet 5.5 67a80e18db DP_Stock v3: sin COALESCE ni subconsultas (el analizador del Service Layer no los admite)
Suma OpenInvQty con JOIN + GROUP BY. Sin consignación no hay fila, y la nueva consulta
DP_Articulo distingue artículo existente (0) de inexistente.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:50:58 -05:00
6 changed files with 62 additions and 30 deletions

No files matched your search

+5 -4
View File
@@ -48,10 +48,11 @@ Pasos de la UI (decididos):
para cerrarla a mano). para cerrarla a mano).
- **Crear devoluciones**: resultado **parcial por documento**. Cada Devolución se crea aparte; las - **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. 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 `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 mismo que consume el FIFO y lo único que se puede devolver. Sin consignación no hay fila: entonces `DP_Articulo`
(queda "Sin stock"); si no existe, error. La columna se sigue llamando `OnHand` en la consulta, el modelo y 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 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. 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 - **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}`). devuelve siempre un JSON (`{abierta: null}`).
**Sin verificar:** **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`. 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` - **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 (con `PDN1.OpenInvQty`, filtro por almacén y parámetros con nombre) **funcionan en HANA** a través del
@@ -22,7 +22,7 @@ const filaHijo = (extra: Record<string, unknown> = {}) => ({ LineId: 2, U_LineTy
const dto = (texto = 'A1\t5'): ProcesarDto => ({ whsCode: '01', docDate: '2026-10-13', texto }) as ProcesarDto; const dto = (texto = 'A1\t5'): ProcesarDto => ({ whsCode: '01', docDate: '2026-10-13', texto }) as ProcesarDto;
function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[]; fallaCierre?: boolean } = {}) { function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[]; articulo?: unknown[]; fallaCierre?: boolean } = {}) {
const llamadas: string[] = []; const llamadas: string[] = [];
const sl = { const sl = {
get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => { get: vi.fn(async (_s: unknown, path: string): Promise<unknown> => {
@@ -42,6 +42,7 @@ function crear(anterior: ReturnType<typeof udo>, opciones: { stock?: unknown[];
const sql = { const sql = {
consultar: vi.fn(async (_s: unknown, consulta: { codigo: string }) => { 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_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 }]; 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'); 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/);
});
});
@@ -29,7 +29,7 @@ import {
import { CatalogosService } from '../catalogos/catalogos.service.js'; import { CatalogosService } from '../catalogos/catalogos.service.js';
import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js'; import { ServiceLayerClient, ServiceLayerException } from '../service-layer/service-layer.client.js';
import type { SapSession } from '../service-layer/session.store.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 { SqlQueriesService } from '../sql-queries/sql-queries.service.js';
import type { ProcesarDto } from './dto/planificaciones.dto.js'; import type { ProcesarDto } from './dto/planificaciones.dto.js';
import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js'; import { cabeceraAUdo, lineaAUdo, planificacionDesdeUdo } from './udo.mapper.js';
@@ -38,7 +38,8 @@ import type { UdoSl } from './udo.mapper.js';
interface FilaStock { interface FilaStock {
ItemCode: string; ItemCode: string;
ItemName: string | null; ItemName: string | null;
OnHand: number | string; /** Ausente cuando la fila viene de DP_Articulo (sin consignación). */
OnHand?: number | string;
} }
interface FilaEntrada { interface FilaEntrada {
@@ -100,21 +101,24 @@ export class PlanificacionesService {
const { filas, errores } = parsearPortapapeles(texto); const { filas, errores } = parsearPortapapeles(texto);
const lineas = []; const lineas = [];
const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => ({ const consultas = await enLotes(filas, CONCURRENCIA_CONSULTAS, async (fila) => {
fila, const params = { whs: whsCode, item: fila.itemCode };
stock: (await this.sql.consultar<FilaStock>(sesion, CONSULTA_STOCK, { whs: whsCode, item: fila.itemCode }))[0], const stock = (await this.sql.consultar<FilaStock>(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<FilaStock>(sesion, CONSULTA_ARTICULO, { item: fila.itemCode }))[0];
return { fila, articulo };
});
for (const { fila, stock } of consultas) { for (const { fila, articulo: datos } of consultas) {
if (!stock) { if (!datos) {
errores.push(`${fila.itemCode}: el artículo no existe.`); errores.push(`${fila.itemCode}: el artículo no existe.`);
continue; continue;
} }
const articulo: ArticuloADevolver = { const articulo: ArticuloADevolver = {
itemCode: stock.ItemCode, itemCode: datos.ItemCode,
itemName: stock.ItemName ?? '', itemName: datos.ItemName ?? '',
quantity: fila.quantity, quantity: fila.quantity,
onHand: Math.floor(Number(stock.OnHand)), onHand: Math.floor(Number(datos.OnHand ?? 0)),
}; };
lineas.push(crearLineaPadre(articulo)); lineas.push(crearLineaPadre(articulo));
} }
@@ -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 * "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 * 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. * 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. * Sin filas si el artículo no tiene consignación abierta (el servicio lo distingue con DP_Articulo).
* La columna se sigue llamando OnHand porque así se guarda en el UDO (U_OnHand). * 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 = { export const CONSULTA_STOCK: ConsultaGuardada = {
codigo: 'DP_Stock', codigo: 'DP_Stock',
version: 2, version: 3,
mssql: mssql:
'SELECT T0.[ItemCode], T0.[ItemName], ' + 'SELECT T2.[ItemCode], T0.[ItemName], SUM(T2.[OpenInvQty]) AS [OnHand] ' +
'COALESCE((SELECT SUM(T2.[OpenInvQty]) FROM OPDN T1 INNER JOIN PDN1 T2 ON T2.[DocEntry] = T1.[DocEntry] ' + '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' " + "WHERE T1.[U_DOT_TipoRecepcion] = 'C' AND T2.[LineStatus] = 'O' " +
'AND T2.[ItemCode] = T0.[ItemCode] AND T2.[WhsCode] = :whs), 0) AS [OnHand] ' + 'AND T2.[ItemCode] = :item AND T2.[WhsCode] = :whs ' +
'FROM OITM T0 WHERE T0.[ItemCode] = :item', 'GROUP BY T2.[ItemCode], T0.[ItemName]',
hana: hana:
'SELECT T0."ItemCode", T0."ItemName", ' + 'SELECT T2."ItemCode", T0."ItemName", SUM(T2."OpenInvQty") AS "OnHand" ' +
'COALESCE((SELECT SUM(T2."OpenInvQty") FROM OPDN T1 INNER JOIN PDN1 T2 ON T2."DocEntry" = T1."DocEntry" ' + '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' " + "WHERE T1.\"U_DOT_TipoRecepcion\" = 'C' AND T2.\"LineStatus\" = 'O' " +
'AND T2."ItemCode" = T0."ItemCode" AND T2."WhsCode" = :whs), 0) AS "OnHand" ' + 'AND T2."ItemCode" = :item AND T2."WhsCode" = :whs ' +
'FROM OITM T0 WHERE T0."ItemCode" = :item', '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',
}; };
/** /**
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "devolucion-proveedores-web", "name": "devolucion-proveedores-web",
"version": "2.0.0", "version": "2.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "devolucion-proveedores-web", "name": "devolucion-proveedores-web",
"version": "2.0.0", "version": "2.0.1",
"workspaces": [ "workspaces": [
"libs/*", "libs/*",
"apps/*" "apps/*"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "devolucion-proveedores-web", "name": "devolucion-proveedores-web",
"version": "2.0.0", "version": "2.0.1",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"libs/*", "libs/*",