Compare commits

...
5 Commits
Author SHA1 Message Date
georgeandClaude Sonnet 5.5 b6c553fed3 CLAUDE.md: validación contra Consignación verificada en SAP
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 16:12:17 -05:00
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
georgeandClaude Sonnet 5.5 cd748bb4ef Documentar la validación contra Consignación en CLAUDE.md
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:41:46 -05:00
georgeandClaude Sonnet 5.5 2182147adb Versión 2.0.0: validar contra Consignación (suma de OpenInvQty) en lugar del stock
DP_Stock (v2) suma PDN1.OpenInvQty de las Entradas de Consignación abiertas del almacén,
lo mismo que usa el FIFO. La columna se muestra como Consignación.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:40:19 -05:00
13 changed files with 85 additions and 34 deletions

No files matched your search

+10 -1
View File
@@ -14,7 +14,7 @@ cantidades pegado desde Excel. Cada ejecución se guarda en el UDO `LIB_ODEV`
Pasos de la UI (decididos):
1. **Pegar**: ItemCode + cantidad desde Excel (con el botón *Pegar desde el portapapeles* o
`Ctrl/Cmd+V` en el cuadro). Se valida el stock (`OITW.OnHand`) del almacén elegido. No guarda.
`Ctrl/Cmd+V` en el cuadro). Se valida contra la **Consignación** del almacén elegido (ver Decisiones). No guarda.
2. **Procesar**: reemplaza a "Selección" del addon. Asignación FIFO contra líneas abiertas
de Entradas de mercancías **del almacén elegido**, ordenadas por `DocDate`. Genera filas
Padre (`F`, artículo) y Hijo (`S`, una por Entrada consumida). El usuario marca o desmarca
@@ -48,6 +48,13 @@ 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` (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. 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
crea/actualiza `DP_Stock` y `DP_EntradasAbiertas` sola (variante según `DB_TYPE`). El analizador
SQL del Service Layer **no admite `FORMAT`** y SAP reescribe el `SqlText`: al cambiar el SQL hay
@@ -239,6 +246,8 @@ Reglas:
## Estado al 2026-10-13
**Hecho y verificado contra el Service Layer real (SBOLibros, MSSQL):**
- **Validación contra Consignación** (2026-10-19, 2.0.1): `DP_Stock` v3 (`SUM` + `GROUP BY`) y `DP_Articulo` funcionan
contra el Service Layer real (confirmado por el usuario; la variante probada fue HANA, la de la v2 rechazada).
- Login, Metadatos y **Reconstruir** (creación de tablas, campos y UDO desde cero).
- `libs/shared`: modelos y dominio portado del .NET. 14 tests.
- `apps/api`: Service Layer, autenticación, metadatos, catálogos, planificaciones y PDF. 81 tests
@@ -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;
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 sl = {
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 = {
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/);
});
});
@@ -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<FilaStock>(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<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) {
if (!stock) {
errores.push(`${fila.itemCode}: el artículo no existe o no tiene stock registrado en el almacén ${whsCode}.`);
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));
}
@@ -15,17 +15,39 @@ export interface ConsultaGuardada {
hana: string;
}
/**
* "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.
* 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: 1,
version: 3,
mssql:
'SELECT T0.[ItemCode], T0.[ItemName], T1.[OnHand] ' +
'FROM OITM T0 INNER JOIN OITW T1 ON T1.[ItemCode] = T0.[ItemCode] ' +
'WHERE T1.[WhsCode] = :whs AND T0.[ItemCode] = :item',
'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] = :item AND T2.[WhsCode] = :whs ' +
'GROUP BY T2.[ItemCode], T0.[ItemName]',
hana:
'SELECT T0."ItemCode", T0."ItemName", T1."OnHand" ' +
'FROM OITM T0 INNER JOIN OITW T1 ON T1."ItemCode" = T0."ItemCode" ' +
'WHERE T1."WhsCode" = :whs AND T0."ItemCode" = :item',
'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" = :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',
};
/**
@@ -136,7 +136,7 @@
<table class="grid">
<thead>
<tr>
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Stock</th>
<th></th><th>Artículo</th><th class="num">Cantidad</th><th class="num">Consignación</th>
<th>Entrada</th><th>Fecha entrada</th><th class="num">Abierta</th><th class="num">Asignada</th>
<th>Proveedor</th><th>Devolución</th>
</tr>
@@ -260,7 +260,7 @@ export class PlanificacionDetallePage implements OnInit, OnDestroy {
{ titulo: 'Artículo', ancho: 18 },
{ titulo: 'Descripción', ancho: 45 },
{ titulo: 'Cantidad', tipo: 'numero' },
{ titulo: 'Stock', tipo: 'numero' },
{ titulo: 'Consignación', tipo: 'numero' },
{ titulo: 'Estado', ancho: 11 },
{ titulo: 'Devolver', ancho: 10 },
{ titulo: 'Entrada', tipo: 'numero' },
@@ -1,7 +1,7 @@
<section class="encabezado">
<div>
<h1>{{ reemplaza() ? 'Rehacer planificación Nº ' + reemplazaNumero() : 'Nueva planificación' }}</h1>
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida el stock del almacén antes de procesar.</p>
<p class="muted">Pegue los artículos y cantidades desde Excel; se valida contra lo disponible en Consignación del almacén (cantidad abierta de las Entradas de mercancías) antes de procesar.</p>
</div>
<a class="btn" routerLink="/planificaciones">Volver</a>
</section>
@@ -91,7 +91,7 @@
<table class="grid">
<thead>
<tr>
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Stock</th><th>Estado</th>
<th>Código</th><th>Descripción</th><th class="num">A devolver</th><th class="num">Consignación</th><th>Estado</th>
</tr>
</thead>
<tbody>
@@ -112,7 +112,7 @@
</table>
@if (sinStock() > 0) {
<p class="muted nota">
{{ sinStock() }} artículo(s) sin stock suficiente quedarán fuera al procesar.
{{ sinStock() }} artículo(s) sin consignación suficiente quedarán fuera al procesar.
</p>
}
</section>
@@ -95,7 +95,7 @@ describe('NuevaPlanificacionPage', () => {
expect(nombres[0]).toMatch(/^articulos-validados-01-\d{4}-\d{2}-\d{2}\.xlsx$/);
const hoja = leerZip(new Uint8Array(await blobs[0].arrayBuffer())).get('xl/worksheets/sheet1.xml')!;
expect(filasDeHoja(hoja)).toEqual([
['Código', 'Descripción', 'A devolver', 'Stock', 'Estado'],
['Código', 'Descripción', 'A devolver', 'Consignación', 'Estado'],
['A1', 'Libro', '5', '10', 'OK'],
['A2', 'Libro', '5', '10', 'Sin stock'],
]);
@@ -154,7 +154,7 @@ export class NuevaPlanificacionPage implements OnInit {
{ titulo: 'Código', ancho: 18 },
{ titulo: 'Descripción', ancho: 50 },
{ titulo: 'A devolver', tipo: 'numero', ancho: 12 },
{ titulo: 'Stock', tipo: 'numero', ancho: 12 },
{ titulo: 'Consignación', tipo: 'numero', ancho: 12 },
{ titulo: 'Estado', ancho: 12 },
],
filas: lineas.map((l) => [l.itemCode, l.itemDescription, l.quantity, l.onHand, l.status === 'OK' ? 'OK' : 'Sin stock']),
+2 -2
View File
@@ -20,10 +20,10 @@ describe('asignarFifo', () => {
expect(lineas[1]).toMatchObject({ itemCode: 'a001', applied: true, baseType: 20, baseRef: 1001 });
});
it('omite artículos sin stock suficiente', () => {
it('omite artículos sin consignación suficiente', () => {
const { lineas, advertencias } = asignarFifo([{ itemCode: 'A001', itemName: 'Art', quantity: 6, onHand: 5 }], new Map());
expect(lineas).toEqual([]);
expect(advertencias[0]).toContain('sin stock');
expect(advertencias[0]).toContain('sin consignación');
});
it('advierte cuando las Entradas no alcanzan', () => {
+1 -1
View File
@@ -76,7 +76,7 @@ export function asignarFifo(articulos: ArticuloADevolver[], entradasPorItem: Map
const padre = crearLineaPadre(articulo);
if (padre.status === 'SIN_STOCK') {
advertencias.push(`${articulo.itemCode}: sin stock suficiente (${articulo.onHand} de ${articulo.quantity}), se omite.`);
advertencias.push(`${articulo.itemCode}: sin consignación suficiente (${articulo.onHand} de ${articulo.quantity}), se omite.`);
continue;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "devolucion-proveedores-web",
"version": "1.1.0",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devolucion-proveedores-web",
"version": "1.1.0",
"version": "2.0.1",
"workspaces": [
"libs/*",
"apps/*"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "devolucion-proveedores-web",
"version": "1.1.0",
"version": "2.0.1",
"private": true,
"workspaces": [
"libs/*",