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>
This commit is contained in:
1 parent
cd748bb4ef
commit
67a80e18db
4 files changed
+59
-27
No files matched your search
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user