Compare commits

...
6 Commits
Author SHA1 Message Date
dot.gcalleandClaude Opus 5.5 e343a18da1 CLAUDE.md: nuevo remoto en Gitea y tags SemVer por release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:30:49 -03:00
dot.gcalleandClaude Opus 5.5 29dcc8ccef README: cómo generar un release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:29:40 -03:00
dot.gcalleandClaude Opus 5.5 7efdcce113 Agregar README.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:29:12 -03:00
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
7 changed files with 168 additions and 36 deletions

No files matched your search

+11 -10
View File
@@ -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
@@ -91,6 +92,11 @@ Pasos de la UI (decididos):
`npm run publicar`, que deja `app/version.json` (número, fecha y commit). La API lo lee al arrancar
(`src/version.ts`), lo informa en `/auth/info` y `/auth/me`, y la web lo muestra en el login y en la barra
superior (en desarrollo dice "desarrollo").
- **Repositorio**: Gitea, remoto `origin` = `https://unasgeorge.vaquita-hadar.ts.net/george/devolucion_proveedores.git`
(rama `main`; reemplaza al anterior `http://100.68.242.27:30000/...`). Cada release lleva un **tag SemVer
anotado `vX.Y.Z`** igual al `version` del `package.json` (`git tag -a vX.Y.Z` + `git push origin main --tags`),
hecho **antes** de `npm run publicar` para que `version.json` lleve el commit correcto. El primero es
`v2.0.1`. El procedimiento para el usuario está en `README.md` ("Generar un release").
- **Logs**: la aplicación **no escribe logs a archivo**; usa el `Logger` de Nest por consola. En
producción NSSM captura la salida y la rota a los 10 MB (no limita cuántos archivos conserva: hay que
limpiar los viejos). Con `NO_COLOR=1` el log sale sin códigos de
@@ -245,6 +251,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
@@ -278,8 +286,6 @@ 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
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
Service Layer. Falta confirmar en HANA el paginado (`$inlinecount`/`odata.count` es de `/b1s/v1`; en
@@ -300,11 +306,6 @@ Reglas:
la cookie de sesión en un navegador real sobre HTTPS.
- **`OpenInvQty`** (2026-10-13): la consulta `DP_EntradasAbiertas` v3 todavía no se ha ejecutado contra el
Service Layer de HANA. Se prueba con *Rehacer FIFO* de la planificación 7 de `SBOTSTLIBROS`.
- **Versión del release** (2026-10-15): release **1.1.0** generado en el servidor Windows de pruebas (Excel,
valores por defecto de la cabecera, versión visible). Lleva "(sin git)": **git no está instalado en ese
servidor**, aunque la carpeta es un repositorio con commits y remoto `origin`
(`http://100.68.242.27:30000/george/devolucion_proveedores.git`). Los cambios del 2026-10-15 **no están en
ningún commit**: hacerlo (y `push`) cuando haya git, antes de seguir trabajando desde el Mac.
- **Estilos:** son CSS porque, en el Mac con macOS 12, el compilador nativo de Sass exige
macOS 14 o superior. En Sequoia se puede volver a SCSS si se prefiere.
+100
View File
@@ -0,0 +1,100 @@
# Devolución a Proveedores
Aplicación web (Angular + NestJS) para SAP Business One que genera **Devoluciones de mercancías**
(ORPD) enlazadas a sus **Entradas de mercancías por compra** (OPDN), a partir de un listado de
artículos y cantidades pegado desde Excel. Reemplaza al addon .NET anterior.
## Cómo funciona
1. **Pegar**: ItemCode + cantidad desde Excel. Se valida contra la **Consignación** del almacén elegido.
2. **Procesar**: asignación FIFO contra las líneas abiertas de Entradas de mercancías del almacén,
ordenadas por fecha. El usuario marca qué líneas devolver.
3. **Crear devoluciones**: agrupa las líneas marcadas por proveedor y crea una Devolución por lote
(11 líneas por defecto).
4. **Descargar PDF**: pide al web service de BES el PDF de la Guía de despacho (DTE 52) de cada
Devolución, con opción de unirlos en uno.
Cada ejecución (planificación) se guarda en el UDO `LIB_ODEV`. Solo puede haber una planificación
abierta a la vez; se cierra a mano.
## Requisitos
- Node.js 22 LTS o superior.
- SAP Business One con **Service Layer** (`/b1s/v1`), base **HANA** (producción) o **MSSQL** (pruebas).
- El usuario de SAP inicia sesión en la web; sus credenciales no se guardan en la configuración.
## Estructura
```
libs/shared/ @dp/shared tipos y lógica de dominio (parseo de Excel, FIFO)
apps/api/ NestJS API REST (/api) sobre el Service Layer; en producción sirve también la web
apps/web/ Angular interfaz
scripts/ publicar.mjs arma la carpeta de despliegue en release/
deploy/windows/ instalación como servicio de Windows (NSSM)
```
## Puesta en marcha (desarrollo)
```sh
npm install
cp apps/api/.env.example apps/api/.env # completar SL_URL, SL_COMPANY_DB y DB_TYPE
npm run dev:api # API NestJS (puerto PORT del .env)
npm run dev:web # Angular en http://localhost:4215 (proxy /api → API)
```
Variables principales del `.env`: `PORT`, `SL_URL`, `SL_COMPANY_DB`, `SL_REJECT_UNAUTHORIZED`,
`DB_TYPE` (`HANA`/`MSSQL`) y `COOKIE_SECURE`. Para producción con HTTPS: `HTTPS_PFX_PATH` y
`HTTPS_PFX_PASSPHRASE` (o `HTTPS_KEY_PATH`/`HTTPS_CERT_PATH`) y `HTTP_REDIRECT_PORT`.
Ver `apps/api/.env.example`.
En el primer ingreso, **Configuración → Metadatos → Reconstruir** crea las tablas, los campos y
el UDO necesarios en SAP.
## Pruebas y compilación
```sh
npm test # shared + api + web
npm run test:e2e -w api
npm run lint -w api
npm run build
```
## Generar un release
La versión es el `version` del `package.json` raíz y sigue SemVer (`MAYOR.MENOR.PARCHE`). Cada
release sube la versión y lleva un tag `vX.Y.Z` en git.
1. **Subir la versión** (elegir uno; no crea commit ni tag):
```sh
npm run version:parche # 2.0.1 → 2.0.2 correcciones
npm run version:menor # 2.0.1 → 2.1.0 funciones nuevas compatibles
npm run version:mayor # 2.0.1 → 3.0.0 cambios incompatibles
```
2. **Commit y tag**:
```sh
git add -A
git commit -m "Versión X.Y.Z"
git tag -a vX.Y.Z -m "Versión X.Y.Z"
git push origin main --tags
```
3. **Armar la carpeta de despliegue** (después del commit, para que el release lleve el commit
correcto):
```sh
npm run publicar # compila shared + api + web y arma la carpeta
npm run publicar -- --sin-build # reutiliza los builds ya hechos (más rápido)
```
El resultado queda en `release/devolucion-proveedores/` (ignorada por git): la API compilada,
la web compilada, solo las dependencias de producción y los scripts de instalación. Incluye
`app/version.json` con el número, la fecha y el commit (si hay cambios sin commit, el commit
aparece con `-modificado`). La versión se muestra en el login y en la barra superior.
4. **Instalar o actualizar en el servidor**: copiar esa carpeta al servidor Windows. Se instala
como un único servicio (NSSM) con HTTPS directo en Node. Instalación nueva: secciones 1 a 8 de
[`deploy/windows/DEPLOY.md`](deploy/windows/DEPLOY.md); actualizar una instalación existente
sin tocar el `.env`: sección 11.
@@ -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) {
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));
}
@@ -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',
};
/**
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "devolucion-proveedores-web",
"version": "2.0.0",
"version": "2.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devolucion-proveedores-web",
"version": "2.0.0",
"version": "2.0.1",
"workspaces": [
"libs/*",
"apps/*"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "devolucion-proveedores-web",
"version": "2.0.0",
"version": "2.0.1",
"private": true,
"workspaces": [
"libs/*",