Compare commits
6
Commits
cd748bb4ef
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e343a18da1 | ||
|
|
29dcc8ccef | ||
|
|
7efdcce113 | ||
|
|
b6c553fed3 | ||
|
|
691aa454ca | ||
|
|
67a80e18db |
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "devolucion-proveedores-web",
|
||||
"version": "2.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"libs/*",
|
||||
|
||||
Reference in new issue
Block a user