Compare commits

...
3 Commits
22 changed files with 456 additions and 17 deletions

No files matched your search

+1 -1
View File
@@ -82,7 +82,7 @@ Pendiente, en orden:
**Lotes y series (entradas, salidas y transferencias de stock):** si el artículo se gestiona por lote (`ManageBatchNumbers`) o por serie (`ManageSerialNumbers`; el maestro de artículos los trae con `extraFields` al elegirlo) la línea muestra un botón «Indicar lotes / series» (columna «Lotes / Series») que abre una ventana con una tabla editable: lotes = `BatchNumber`, `Quantity`, `ExpiryDate`, `ManufacturingDate`; series = `InternalSerialNumber`, `ManufacturerSerialNumber`, fechas (cada serie con `Quantity: 1`). Viajan dentro de la línea como `BatchNumbers` / `SerialNumbers`; solo se envían los campos propios (no los que SAP agrega al leer). Validación: los lotes deben sumar la cantidad de la línea y debe haber una serie por unidad. Al cambiar de artículo se descartan. Un documento contabilizado los muestra en solo lectura. Las solicitudes de traslado, las de compra y los documentos de ventas/compras no los llevan todavía (`layout.details`, `LINE_DETAILS` en `line-details.ts`). Probado: entrada de inventario 156 (1 unidad de `SU01` en el almacén 01, lote `LT-PRUEBA-01`). Pendiente: elegir de una lista los lotes existentes en una salida o transferencia (hoy se escribe el número; SAP valida que exista y haya saldo).
**Redondeo** (`Rounding`, `RoundingDiffAmount`): fila «Redondeo» (casilla + importe) en el pie de ventas y compras, después de «Gastos adicionales» y antes del impuesto (como en SAP; ver `tools/factura.png`). Los campos siempre existen en el documento (se leen y se conservan); la fila solo se ve si la empresa usa redondeo por moneda (`OADM.RoundMthd`, `RoundingMethod = tYES` en el Service Layer; confirmado con el usuario; por documento no se muestra). Esa bandera y el separador de segmentos de las cuentas salen de `GET /api/company/settings` (backend: `CompanyService`, lee `CompanyService_GetAdminInfo` y entrega solo esos dos campos, con caché de 10 min). El importe solo cuenta con la casilla marcada; sin marcarla no se envía nada y SAP calcula el suyo. **Facturas y notas de crédito** (`billing: true`, `BILLING_DOCUMENT` en `screen-kit.ts`): el pie suma «Anticipo total» (`DownPaymentAmount`, tras el descuento), «Importe aplicado» (`PaidToDate`) y «Saldo pendiente» (`DocTotal − PaidToDate`, 0 en un documento nuevo), de solo lectura, como en SAP. **Gastos adicionales** hechos: tabla «Gastos adicionales» en el pie de ventas y compras (una fila por gasto: código con su ventana de selección sobre `AdditionalExpenses`, nombre, importe, impuesto y comentarios; colección `DocumentAdditionalExpenses`). Se suman al total con su propio impuesto (`computeTotals`); al modificar, si cambian las filas se envía la colección completa con `B1S-ReplaceCollectionsOnPatch` (quitarlas todas envía `[]`); `expenses: false` en `marketing(...)` la quita de un documento; las extensiones pueden ocultarla con el nombre `DocumentAdditionalExpenses`. Falta: botón PDF. En ventas un gasto adicional sin cuenta de ingresos falla en SBODemoCL ("Define the freight revenue account…"; configuración de SAP, no del código); en compras funciona (orden de compra 2885).
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
2. ~~Pantallas de maestros~~ — hechas (clientes, proveedores y artículos): `features/masters/`, con la misma idea que los documentos pero con clave de texto (`CardCode`, `ItemCode`). Cada pantalla declara su `MasterScreen` (`socios-negocio/socios.screens.ts`, `articulos/articulos.screens.ts`): columnas y filtros de la lista, `baseFilter` (clientes = `CardType eq 'cCustomer'`, proveedores = `cSupplier`; el tipo también se envía al crear con `fixed`), y un `MasterLayout` con código/nombre/estado arriba y los demás campos en **pestañas** (dos columnas, tipos de `HeaderField`: texto, número, fecha, área de texto, casilla `tYES/tNO`, lista de valores numérica o de texto, almacén). El código no se cambia una vez creado (`immutable`); «Inactivo» es `Frozen`. Rutas con `master-paths.ts` + `resolve` diferido; navegación primero/anterior/siguiente/último por clave de texto (`SlMasterApi`). Los campos por cliente van en `socios-negocio/socios.extensions.ts` y `articulos/articulos.extensions.ts` (vacíos en `main`; `header`, `tabs` por `id`, `newTabs`, `listColumns`, y también `hideFields`, `readOnlyFields`, `hideListColumns`; pestaña, campo o ruta inexistente = error). **Tablas en las pestañas de los maestros** (`MasterTab.collection`, `MasterCollection`; componente `sbo-rows-table`): clientes y proveedores tienen **Direcciones** (`BPAddresses`) y **Personas de contacto** (`ContactEmployees`); el artículo, **Precios** (`ItemPrices`) y **Stock por almacén** (`ItemWarehouseInfoCollection`, solo lectura). Cómo se envían (verificado contra SAP con un cliente y un artículo de prueba): `mode: 'replace'` (direcciones, contactos) manda la colección completa —las filas leídas con los cambios aplicados, para conservar sus campos de SAP (`RowNum`, `InternalCode`…), más las nuevas— con `B1S-ReplaceCollectionsOnPatch: true` (así también se puede quitar una fila, si SAP lo permite); `mode: 'merge'` (precios) manda **solo las filas cambiadas**, por `rowKey` (`PriceList`) y sin reemplazo, porque reenviar las demás listas con valores viejos impide que SAP recalcule las derivadas por factor. Las filas vacías (sin `identity`) no se guardan. Probado: cliente `TESTBP01` (dirección de envío añadida) y artículo `TESTITEM01` (precio de la lista 3), ambos de prueba en SBODemoCL. Pendiente en maestros: las extensiones de cliente aún no pueden agregar u ocultar columnas de estas tablas, ni cambiar el tipo de socio. **Lotes** (`/inventario/lotes`, `masters/lotes/`, recurso `BatchNumberDetails`, solo campos estándar): lista (lote, artículo, descripción, vencimiento, estado; filtros de estado y de vencimiento) y formulario con estado (Liberado / Bloqueado / No accesible), atributos 1 y 2, fechas de admisión, fabricación y vencimiento y detalles. SAP crea los lotes desde los documentos, así que **no hay «Nuevo»**: el recurso admite solo `GET` y `PATCH`. Para esto el framework de maestros ganó `keyType: 'number'` (la clave es `DocEntry`; la navegación primero/anterior/siguiente/último no la pone entre comillas), `creatable: false` (sin botón «Nuevo»; `/nuevo` vuelve a la lista), `titleField` (el título muestra el número de lote, no la clave) y `badge` (la insignia sale del estado del lote). Los campos de usuario de cada cliente van en `lotes.extensions.ts` (vacío en `main`). **Stock del lote por almacén:** pestaña «Stock por almacén» (solo lectura, con fila «Total» y casilla para ocultar los almacenes sin cantidades) que sale de la consulta registrada `batch-stock` del módulo `sapdb` (tablas estándar `OBTQ`/`OBTN`/`OWHS`; el `DocEntry` del lote del Service Layer es `OBTN.AbsEntry`, verificado). Es una `MasterTab.query` (`sbo-query-table`): si la base de SAP no está configurada (`SAPDB_*`) o el usuario no tiene permiso, la pestaña no aparece (`SapDbApi.available`, una consulta por sesión; se reinicia al iniciar o cerrar sesión). Hoy solo con SQL de HANA; el de MSSQL se agrega cuando haya un cliente. Los catálogos `BusinessPartnerGroups` e `ItemGroups` están registrados en el proxy.
3. ~~Pantalla web de permisos~~ — hecha (`/admin/permisos`).
4. **Auditoría — en marcha** (`apps/api/src/audit/`, tabla `audit_log` en la misma SQLite): registra inicios de sesión (correctos, fallidos con su motivo —credenciales, usuario inactivo, demasiados intentos— y cierres), cada **creación y modificación** que pasa por el proxy `/api/sl` (recurso, clave, resultado `ok`/`error` con el mensaje de SAP, y **solo los nombres de los campos** y cuántas filas lleva cada colección: nunca valores ni contraseñas), las operaciones **denegadas** por permisos y los cambios de permisos de menú. Las lecturas y las acciones de solo consulta (series) no se auditan. `AuditService.record` nunca lanza (un fallo del registro no cambia la operación del usuario). Consulta solo para administradores: `GET /api/audit` (filtros usuario, acción, recurso, resultado, desde/hasta; 50 por página, máx. 200) y la pantalla `/admin/auditoria` (menú Administración, solo acceso total). Pendiente de afinar: retención (`purgeOlderThan` existe, falta programarlo y su variable de entorno), la IP detrás del proxy de desarrollo (todo llega como loopback; hace falta `xfwd` en el proxy y `trust proxy` solo cuando el backend no esté expuesto), exportar a CSV, y `nestjs-cls` para poner el usuario en cada línea del log.
5. ~~Migrar reportes/PDF vía API Gateway~~ — hecho (ver «Reportes Gateway» arriba; no registrar credenciales de los `tools/` del mini-erp, traen contraseñas en claro). **Pendiente, por cliente: reportes sencillos con plantilla HTML** (decidido con el usuario, no se construye en `main`; solo lo pide la rama de cada cliente). En el mini-erp era una sola plantilla (`mini-backend/templates/reports/stickers.html`, Jinja2 + WeasyPrint: `@page`, CSS grid, `page-break-after`, fuente propia en `fonts/`). Equivalente previsto: **Nunjucks** (misma sintaxis `{{ }}` / `{% for %}`; la plantilla se porta casi igual) + **Puppeteer** (Chrome headless, `page.pdf()`; soporta mejor `@page` y grid). Diseño: módulo genérico `apps/api/src/templates/` (renderiza y genera el PDF; cola de 1-2 con un solo navegador abierto; datos por el Service Layer) y las plantillas en `templates/<cliente>/<nombre>.njk` con su `fonts/`, dentro de la rama del cliente. Falta decidir cómo se elige una plantilla: botón PDF de un documento (recibe el `DocEntry`) o reporte más en «Reportes» con parámetros (encaja mejor en listados como los stickers). Si basta un documento simple, **pdfmake** evita Chrome (~150 MB en el despliegue). Aparte queda el botón PDF de cada documento con Crystal vía Gateway (falta el código de reporte de cada tipo).
+18
View File
@@ -11,6 +11,24 @@ export const BASE_SAPDB_QUERIES: readonly SapDbQuery[] = [
params: {},
menu: null,
},
{
// Tablas estándar de SAP B1 (OBTQ: cantidad por lote y almacén; OBTN: maestro de lotes; OWHS: almacenes). `absEntry` es el
// `DocEntry` del lote en el Service Layer.
name: 'batch-stock',
title: 'Stock de un lote por almacén',
sql: {
hana: `SELECT T1."WhsCode" AS "WarehouseCode", W."WhsName" AS "WarehouseName", T1."Quantity" AS "Quantity",
T1."CommitQty" AS "Committed", T1."CountQty" AS "Counted"
FROM "OBTQ" T1
INNER JOIN "OBTN" T0 ON T1."MdAbsEntry" = T0."AbsEntry" AND T1."ItemCode" = T0."ItemCode"
LEFT JOIN "OWHS" W ON W."WhsCode" = T1."WhsCode"
WHERE T0."AbsEntry" = :absEntry
ORDER BY T1."WhsCode"`,
},
params: { absEntry: { type: 'number' } },
menu: '/inventario/lotes',
maxRows: 1000,
},
];
/** Comprueba que los nombres no se repitan, que el SQL sea de solo lectura y que use justo los parámetros declarados. Lanza si no. */
+4 -1
View File
@@ -79,6 +79,9 @@ describe('SapDbService', () => {
});
it('lista las consultas disponibles', async () => {
expect(await make({ query } as SapDbAdapter).available(user)).toEqual([{ name: 'ping', title: expect.any(String) }]);
expect(await make({ query } as SapDbAdapter).available(user)).toEqual([
{ name: 'ping', title: expect.any(String) },
{ name: 'batch-stock', title: expect.any(String) },
]);
});
});
+7
View File
@@ -85,6 +85,13 @@ describe('recursos del Service Layer', () => {
for (const name of ['ShippingTypes', 'WizardPaymentMethods']) expect(SL_RESOURCE_MAP.get(name)!.methods, name).toEqual(['GET']);
});
it('los lotes se consultan y se modifican, pero no se crean desde aquí (nacen en los documentos)', () => {
const r = SL_RESOURCE_MAP.get('BatchNumberDetails')!;
expect(r.key).toBe('number');
expect(r.methods).toEqual(['GET', 'PATCH']);
expect(r.write).toBe('/inventario/lotes');
});
it('los usuarios de SAP solo entregan los campos que necesita el solicitante', () => {
const r = SL_RESOURCE_MAP.get('Users')!;
expect(r.methods).toEqual(['GET']);
+2
View File
@@ -97,6 +97,8 @@ export const SL_RESOURCES: readonly SlResource[] = [
// Maestros (consulta para cualquier sesión; escritura según el módulo)
catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']),
catalog('Items', 'string', '/inventario/articulos'),
// Datos maestros de lotes: SAP los crea desde los documentos (entradas, recepciones…), aquí solo se consultan y modifican.
{ name: 'BatchNumberDetails', key: 'number', methods: ['GET', 'PATCH'], read: null, write: '/inventario/lotes' },
catalog('Warehouses', 'string'),
catalog('Currencies', 'string'),
catalog('SalesTaxCodes', 'string'),
@@ -4,6 +4,7 @@ import { Router } from '@angular/router';
import type { LoginRequest, LoginResponse } from '@basic-erp/sap-types';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
import { SapDbApi } from '../sl/sapdb-api';
import { MenuService } from '../menu/menu.service';
import { SessionStore } from './session.store';
@@ -12,11 +13,13 @@ export class AuthService {
private readonly http = inject(HttpClient);
private readonly session = inject(SessionStore);
private readonly menu = inject(MenuService);
private readonly sapdb = inject(SapDbApi);
private readonly router = inject(Router);
async login(credentials: LoginRequest): Promise<void> {
const res = await firstValueFrom(this.http.post<LoginResponse>(`${API_URL}/auth/login`, credentials));
this.menu.reset();
this.sapdb.reset();
this.session.set(res);
}
@@ -44,5 +47,6 @@ export class AuthService {
private end(): void {
this.session.clear();
this.menu.reset();
this.sapdb.reset();
}
}
+37
View File
@@ -0,0 +1,37 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { API_URL } from '../api';
/** Respuesta de una consulta a la base de SAP. */
export interface SapDbResult {
rows: Record<string, unknown>[];
truncated: boolean;
}
/**
* Consultas registradas de solo lectura a la base de SAP (`/api/sapdb`). Sin base configurada o sin permiso, la API
* responde 503 / 403 y la consulta simplemente no está disponible (las pantallas ocultan lo que dependa de ella).
*/
@Injectable({ providedIn: 'root' })
export class SapDbApi {
private readonly http = inject(HttpClient);
private names?: Promise<Set<string>>;
/** La consulta existe para este usuario y la base está configurada. Se pregunta una vez por sesión. */
available(name: string): Promise<boolean> {
this.names ??= firstValueFrom(this.http.get<{ name: string }[]>(`${API_URL}/sapdb/queries`))
.then((list) => new Set(list.map((q) => q.name)))
.catch(() => new Set<string>());
return this.names.then((set) => set.has(name));
}
/** Al iniciar o cerrar sesión: otro usuario puede tener otros permisos. */
reset(): void {
this.names = undefined;
}
run(name: string, params: Record<string, unknown>): Promise<SapDbResult> {
return firstValueFrom(this.http.post<SapDbResult>(`${API_URL}/sapdb/queries/${encodeURIComponent(name)}`, { params }));
}
}
+3 -3
View File
@@ -18,14 +18,14 @@ export class SlMasterApi {
}
/** Clave del registro vecino (primero / anterior / siguiente / último), o `null` si no hay. */
async neighbour(resource: string, keyField: string, target: MasterNavTarget, from: string | null, baseFilter?: string): Promise<string | null> {
const q = masterNavigationQuery(target, keyField, from);
async neighbour(resource: string, keyField: string, target: MasterNavTarget, from: string | null, baseFilter?: string, numeric = false): Promise<string | null> {
const q = masterNavigationQuery(target, keyField, from, numeric);
let params = new HttpParams().set('$select', keyField).set('$top', 1).set('$orderby', q.$orderby);
const filter = combineFilters(baseFilter, q.$filter);
if (filter) params = params.set('$filter', filter);
const res = await firstValueFrom(this.http.get<SlListResponse<Rec>>(this.url(resource), { params }));
const key = res.value[0]?.[keyField];
return typeof key === 'string' ? key : null;
return typeof key === 'string' ? key : typeof key === 'number' ? String(key) : null;
}
/** Crea el registro; SAP devuelve el creado. */
@@ -0,0 +1,17 @@
import type { MasterExtensions } from '../master-extensions';
/**
* Campos que este cliente agrega a la pantalla de lotes. En `main` va vacío: el proyecto base solo lleva campos
* estándar de SAP. La rama `cliente/<nombre>` lo completa, por ruta de pantalla, con campos estándar adicionales o de
* usuario (`U_*`) y no toca ningún otro archivo.
*
* Además de agregar, puede ocultar campos (`hideFields`, `hideListColumns`) y dejarlos de solo lectura
* (`readOnlyFields`). Un nombre que no exista, o ocultar un campo obligatorio, lanza un error al arrancar.
*
* Ejemplo (los campos son de ejemplo):
*
* '/inventario/lotes': {
* newTabs: [{ id: 'calidad', label: 'Calidad', left: [{ type: 'text', key: 'U_Ubicacion', label: 'Ubicación' }], right: [] }],
* },
*/
export const LOTES_EXTENSIONS: MasterExtensions = {};
@@ -0,0 +1,94 @@
import type { SelectFilter } from '../../../core/sl/list-filters';
import type { HeaderField } from '../../documents/document-layouts';
import type { MasterScreen } from '../master-types';
const field = (f: HeaderField): HeaderField => f;
/** Estado del lote (`BatchNumberDetailStatus`). */
const STATUS = [
{ value: 'bdsStatus_Released', label: 'Liberado' },
{ value: 'bdsStatus_Locked', label: 'Bloqueado' },
{ value: 'bdsStatus_NotAccessible', label: 'No accesible' },
] as const;
const statusLabel = (v: unknown): string => STATUS.find((s) => s.value === v)?.label ?? String(v ?? '');
const statusTone = (v: unknown) => (v === 'bdsStatus_Released' ? ('ok' as const) : v === 'bdsStatus_Locked' ? ('warn' as const) : ('muted' as const));
const statusFilter: SelectFilter = { type: 'select', key: 'Status', label: 'Estado', options: STATUS };
/**
* Lotes (`BatchNumberDetails`): datos maestros del lote de un artículo (estado, atributos, fechas). SAP crea los lotes
* desde los documentos (entradas, recepciones…), así que aquí solo se consultan y se modifican. Solo campos estándar.
*/
export const LOTES_SCREENS: readonly MasterScreen[] = [
{
path: '/inventario/lotes',
resource: 'BatchNumberDetails',
keyField: 'DocEntry',
keyType: 'number',
creatable: false,
titleField: 'Batch',
singular: 'Lote',
createLabel: '',
orderby: 'DocEntry desc',
extraFields: ['DocEntry', 'Status'],
badge: (d) => (d['Status'] ? { label: statusLabel(d['Status']), tone: statusTone(d['Status']) } : undefined),
columns: [
{ key: 'Batch', title: 'Lote', width: '160px', mono: true, searchable: true },
{ key: 'ItemCode', title: 'Nº de artículo', width: '170px', mono: true, searchable: true },
{ key: 'ItemDescription', title: 'Descripción', searchable: true },
{ key: 'ExpirationDate', title: 'Vencimiento', type: 'date', width: '110px' },
{ key: 'Status', title: 'Estado', width: '110px', format: (r) => statusLabel(r['Status']), tone: (r) => statusTone(r['Status']) },
],
filters: [statusFilter, { type: 'date', key: 'ExpirationDate', label: 'Vencimiento' }],
layout: {
header: {
left: [
field({ type: 'text', key: 'Batch', label: 'Lote', immutable: true }),
field({ type: 'text', key: 'ItemCode', label: 'Nº de artículo', immutable: true }),
field({ type: 'text', key: 'ItemDescription', label: 'Descripción', immutable: true }),
],
right: [
field({ type: 'select', key: 'Status', label: 'Estado', options: STATUS }),
field({ type: 'text', key: 'DocEntry', label: 'Número interno', immutable: true }),
],
},
tabs: [
{
id: 'general',
label: 'General',
left: [
field({ type: 'text', key: 'BatchAttribute1', label: 'Atributo 1' }),
field({ type: 'text', key: 'BatchAttribute2', label: 'Atributo 2' }),
],
right: [
field({ type: 'date', key: 'AdmissionDate', label: 'Fecha de admisión' }),
field({ type: 'date', key: 'ManufacturingDate', label: 'Fecha de fabricación' }),
field({ type: 'date', key: 'ExpirationDate', label: 'Fecha de vencimiento' }),
],
},
{
id: 'stock',
label: 'Stock por almacén',
left: [],
right: [],
query: {
name: 'batch-stock',
params: (d) => ({ absEntry: d['DocEntry'] }),
hideEmptyBy: ['Quantity', 'Committed', 'Counted'],
hideEmptyLabel: 'Solo almacenes con cantidad, comprometido o contado',
columns: [
{ key: 'WarehouseCode', title: 'Almacén', type: 'text', width: '110px' },
{ key: 'WarehouseName', title: 'Nombre', type: 'text', width: '260px' },
{ key: 'Quantity', title: 'En stock', type: 'number', width: '110px', total: true },
{ key: 'Committed', title: 'Comprometido', type: 'number', width: '110px', total: true },
{ key: 'Counted', title: 'Contado', type: 'number', width: '110px', total: true },
],
},
},
{ id: 'detalles', label: 'Detalles', left: [field({ type: 'textarea', key: 'Details', label: 'Detalles' })], right: [] },
],
newHeader: () => ({}),
},
},
];
@@ -0,0 +1,147 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MasterFormPage } from '../master-form.page';
import { masterNavigationQuery } from '../master-model';
import { BASE_MASTER_SCREENS } from '../master-screens';
const lote = (over: Record<string, unknown> = {}) => ({
'odata.etag': 'W/"2"',
DocEntry: 74701,
Batch: '2090456',
ItemCode: '10101070119011602',
ItemDescription: 'CATGUT SIMPLE 6/0',
Status: 'bdsStatus_Released',
BatchAttribute1: 'PROV-1',
BatchAttribute2: null,
AdmissionDate: '2026-01-05T00:00:00Z',
ManufacturingDate: '2025-12-01T00:00:00Z',
ExpirationDate: '2028-12-01T00:00:00Z',
Details: null,
...over,
});
describe('Lotes (BatchNumberDetails)', () => {
const screen = BASE_MASTER_SCREENS.find((s) => s.path === '/inventario/lotes')!;
let fixture: ComponentFixture<MasterFormPage>;
let ctrl: HttpTestingController;
let navigate: ReturnType<typeof vi.spyOn>;
const el = () => fixture.nativeElement as HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const page = () => fixture.componentInstance as any;
const tick = async () => {
fixture.detectChanges();
await new Promise((r) => setTimeout(r));
fixture.detectChanges();
};
async function open(key: string | null, doc?: Record<string, unknown>) {
const params = key === null ? {} : { key };
const route = { snapshot: { data: { screen, title: screen.singular }, paramMap: convertToParamMap(params) }, paramMap: of(convertToParamMap(params)) };
await TestBed.configureTestingModule({
imports: [MasterFormPage],
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting(), { provide: ActivatedRoute, useValue: route }],
}).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
fixture = TestBed.createComponent(MasterFormPage);
await tick();
// la pestaña de stock depende de una consulta a la base de SAP: ¿está configurada y el usuario puede ejecutarla?
const queries = ctrl.match('/api/sapdb/queries');
for (const q of queries) queriesAnswer === 'fail' ? q.flush({ message: 'x' }, { status: 503, statusText: 'Service Unavailable' }) : q.flush([{ name: 'ping' }, { name: 'batch-stock' }]);
if (key !== null) ctrl.expectOne(`/api/sl/BatchNumberDetails/${key}`).flush(doc ?? {});
await tick();
}
let queriesAnswer: 'ok' | 'fail' = 'ok';
beforeEach(() => {
queriesAnswer = 'ok';
TestBed.resetTestingModule();
});
it('la pantalla usa solo campos estándar, clave numérica y no se crea desde aquí', () => {
expect(screen.keyField).toBe('DocEntry');
expect(screen.keyType).toBe('number');
expect(screen.creatable).toBe(false);
expect(screen.columns[0].key).toBe('Batch');
});
it('muestra el número de lote en el título, su estado y los datos del artículo sin poder cambiarlos', async () => {
await open('74701', lote());
expect(el().querySelector('.title')?.textContent).toContain('Lote · 2090456');
expect(el().querySelector('.badge.tone-ok')?.textContent?.trim()).toBe('Liberado');
const readonly = (value: string) => [...el().querySelectorAll<HTMLInputElement>('input')].find((i) => i.value === value)!.readOnly;
expect(readonly('2090456')).toBe(true);
expect(readonly('10101070119011602')).toBe(true);
expect(readonly('CATGUT SIMPLE 6/0')).toBe(true);
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Stock por almacén', 'Detalles']);
});
it('las fechas que trae SAP con hora se ven en el selector de fecha', async () => {
await open('74701', lote());
const dates = [...el().querySelectorAll<HTMLInputElement>('input[type=date]')].map((i) => i.value);
expect(dates).toEqual(['2026-01-05', '2025-12-01', '2028-12-01']);
});
it('sin base de datos de SAP configurada (o sin permiso) no hay pestaña de stock', async () => {
queriesAnswer = 'fail';
await open('74701', lote());
expect([...el().querySelectorAll('[role=tab]')].map((t) => t.textContent?.trim())).toEqual(['General', 'Detalles']);
});
it('el stock del lote se pide por su número interno y se totaliza', async () => {
await open('74701', lote());
(el().querySelectorAll('[role=tab]')[1] as HTMLElement).click();
await tick();
const req = ctrl.expectOne('/api/sapdb/queries/batch-stock');
expect(req.request.body).toEqual({ params: { absEntry: 74701 } });
req.flush({
truncated: false,
rows: [
{ WarehouseCode: '201', WarehouseName: 'APROBADOS DRO', Quantity: '7.500000', Committed: '0.000000', Counted: '0.000000' },
{ WarehouseCode: '100', WarehouseName: 'CUARENTENA INSUMOS', Quantity: '2.500000', Committed: '1.000000', Counted: '0.000000' },
{ WarehouseCode: '150', WarehouseName: 'PRODUCTOS EN PROCESO LAB', Quantity: '0.000000', Committed: '0.000000', Counted: '0.000000' },
],
});
await tick();
const tab = el().querySelectorAll('sbo-tab')[1];
// los almacenes sin cantidades se ocultan; el total suma todos
expect([...tab.querySelectorAll('tbody tr')].map((r) => r.querySelector('input')?.value)).toEqual(['201', '100']);
expect(tab.querySelector('thead tr:nth-child(2)')?.textContent).toContain('10');
});
it('un lote bloqueado se ve como tal', async () => {
await open('74701', lote({ Status: 'bdsStatus_Locked' }));
expect(el().querySelector('.badge.tone-warn')?.textContent?.trim()).toBe('Bloqueado');
});
it('no hay botón «Nuevo» y la ruta /nuevo vuelve a la lista', async () => {
await open('74701', lote());
expect(el().querySelector('sbo-toolbar')?.textContent).not.toContain('Nuevo');
TestBed.resetTestingModule();
await open(null);
expect(navigate).toHaveBeenCalledWith(['..'], expect.anything());
});
it('modifica solo lo que cambió con PATCH a su número interno y If-Match', async () => {
await open('74701', lote());
page().setValue('Status', 'bdsStatus_Locked');
page().setValue('BatchAttribute2', 'Estante 4');
await tick();
(el().querySelector('.actions .btn.main') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.method === 'PATCH');
expect(req.request.url).toBe('/api/sl/BatchNumberDetails/74701');
expect(req.request.headers.get('If-Match')).toBe('W/"2"');
expect(req.request.body).toEqual({ Status: 'bdsStatus_Locked', BatchAttribute2: 'Estante 4' });
});
it('la navegación por la clave numérica no la pone entre comillas', () => {
expect(masterNavigationQuery('next', 'DocEntry', '74701', true)).toEqual({ $filter: 'DocEntry gt 74701', $orderby: 'DocEntry asc' });
expect(masterNavigationQuery('prev', 'DocEntry', '74701', true)).toEqual({ $filter: 'DocEntry lt 74701', $orderby: 'DocEntry desc' });
expect(masterNavigationQuery('next', 'CardCode', 'C1')).toEqual({ $filter: "CardCode gt 'C1'", $orderby: 'CardCode asc' });
});
});
@@ -101,7 +101,8 @@ export class MasterField {
const f = this.field();
return f.type === 'lookup' && f.lookup.valueType === 'number' && /^\d+$/.test(code.trim()) ? Number(code) : code;
}
protected readonly str = computed(() => text(this.value()));
/** El texto del campo; una fecha de SAP llega como `2031-10-31T00:00:00Z` y el selector de fecha pide `yyyy-MM-dd`. */
protected readonly str = computed(() => (this.field().type === 'date' ? text(this.value()).slice(0, 10) : text(this.value())));
protected readonly num = computed(() => (this.value() === '' || this.value() === null || this.value() === undefined ? null : Number(this.value())));
protected readonly required = computed(() => {
const f = this.field();
@@ -1,6 +1,8 @@
<sbo-toolbar bleed aria-label="Acciones del registro">
<a class="tb" routerLink=".." title="Volver a la lista"><sbo-icon name="find" />Lista</a>
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Registro nuevo"><sbo-icon name="new" />Nuevo</a>
@if (creatable) {
<a class="tb" [routerLink]="isNew() ? '.' : '../nuevo'" title="Registro nuevo"><sbo-icon name="new" />Nuevo</a>
}
<span class="tb-sep"></span>
<button class="tb" type="button" aria-label="Primero" (click)="go('first')"><sbo-icon name="first" /></button>
<button class="tb" type="button" aria-label="Anterior" (click)="go('prev')"><sbo-icon name="prev" /></button>
@@ -25,7 +27,7 @@
</div>
<sbo-tabs ariaLabel="Datos del registro">
@for (tab of layout.tabs; track tab.id) {
@for (tab of tabs(); track tab.id) {
<sbo-tab [label]="tab.label">
<div class="sbo-hdr">
<div class="sbo-col">
@@ -39,6 +41,11 @@
}
</div>
</div>
@if (tab.query; as q) {
@if (doc(); as d) {
<sbo-query-table [query]="q.name" [params]="q.params(d)" [columns]="q.columns" [hideEmptyBy]="q.hideEmptyBy" [hideEmptyLabel]="q.hideEmptyLabel ?? 'Ocultar las filas en 0'" />
}
}
@if (tab.collection; as c) {
<sbo-rows-table
[columns]="c.columns"
@@ -1,10 +1,12 @@
import { HttpErrorResponse, httpResource } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, linkedSignal, resource, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { map } from 'rxjs';
import { API_URL, apiErrorMessage } from '../../core/api';
import { SapDbApi } from '../../core/sl/sapdb-api';
import { SlMasterApi } from '../../core/sl/sl-master';
import { QueryTable } from '../../shared/grid/query-table';
import { RowsTable } from '../../shared/grid/rows-table';
import { Icon, SboWindow, Tab, Tabs, Toolbar, type StatusMessage, type WindowBadge } from '../../shared/window';
import { updatePayload, type DocRecord } from '../documents/document-model';
@@ -38,7 +40,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin
@Component({
selector: 'sbo-master-form-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ChartTreePanel, Icon, MasterField, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
imports: [ChartTreePanel, Icon, MasterField, QueryTable, RouterLink, RowsTable, SboWindow, Tab, Tabs, Toolbar],
templateUrl: './master-form.page.html',
styles: `
.addrow { margin: 6px 0 0; }
@@ -63,6 +65,28 @@ export class MasterFormPage {
initialValue: this.route.snapshot.paramMap.get('key'),
});
protected readonly isNew = computed(() => this.key() === null);
/** SAP no crea este registro desde aquí (los lotes): sin botón «Nuevo» y `/nuevo` vuelve a la lista. */
protected readonly creatable = this.screen.creatable !== false;
/** Pestañas que dependen de una consulta a la base de SAP: solo se muestran si la base está configurada y el usuario puede ejecutarla. */
private readonly sapdb = inject(SapDbApi);
private readonly queryTabs = this.layout.tabs.filter((t) => t.query);
private readonly shownQueryTabs = resource({
loader: async () => {
const shown = new Set<string>();
for (const t of this.queryTabs) if (await this.sapdb.available(t.query!.name)) shown.add(t.id);
return shown;
},
});
protected readonly tabs = computed(() => {
if (!this.queryTabs.length) return this.layout.tabs;
const shown = this.shownQueryTabs.hasValue() ? this.shownQueryTabs.value() : new Set<string>();
return this.layout.tabs.filter((t) => !t.query || shown.has(t.id));
});
constructor() {
if (!this.creatable && this.isNew()) void this.router.navigate(['..'], { relativeTo: this.route });
}
// --- datos ---------------------------------------------------------------------------------
@@ -128,10 +152,12 @@ export class MasterFormPage {
if (this.isNew()) return { label: 'Nuevo', tone: 'info' };
const d = this.doc();
if (!d) return undefined;
if (this.screen.badge) return this.screen.badge(d);
return d['Frozen'] === 'tYES' ? { label: 'Inactivo', tone: 'muted' } : { label: 'Activo', tone: 'ok' };
});
protected readonly windowTitle = computed(() => {
const d = this.doc();
if (this.screen.titleField && d) return `${this.screen.singular} · ${text(d[this.screen.titleField])}`;
const code = this.screen.displayField && d ? displayCode({ Code: text(d[this.screen.keyField]), FormatCode: text(d[this.screen.displayField]) }) : d?.[this.screen.keyField];
return code === undefined ? this.screen.singular : `${this.screen.singular} · ${text(code)}`;
});
@@ -189,7 +215,7 @@ export class MasterFormPage {
protected async go(target: MasterNavTarget): Promise<void> {
try {
const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter);
const next = await this.api.neighbour(this.screen.resource, this.screen.keyField, target, this.key(), this.screen.baseFilter, this.screen.keyType === 'number');
if (next === null) {
this.message.set({ kind: 'warn', text: 'No hay más registros en esa dirección.' });
return;
@@ -21,7 +21,7 @@ import type { MasterScreen } from './master-types';
[orderby]="screen.orderby"
[baseFilter]="screen.baseFilter"
[rowKey]="screen.keyField"
[createLabel]="screen.createLabel"
[createLabel]="screen.creatable === false ? '' : screen.createLabel"
(open)="open($event)"
(create)="create()"
/>
@@ -89,8 +89,8 @@ describe('pantallas de maestros', () => {
for (const s of BASE_MASTER_SCREENS) {
for (const k of masterKeysOf(s.layout)) expect(k.startsWith('U_'), `${s.path} ${k}`).toBe(false);
for (const c of s.columns) expect(c.key.startsWith('U_')).toBe(false);
expect(s.orderby).toBe(s.keyField);
expect(s.columns[0].key).toBe(s.keyField);
expect(s.orderby.split(' ')[0]).toBe(s.keyField);
expect(s.columns[0].key).toBe(s.titleField ?? s.keyField);
}
});
@@ -43,10 +43,11 @@ export function masterNavigationQuery(
target: MasterNavTarget,
keyField: string,
current: string | null,
numeric = false,
): { $filter?: string; $orderby: string } {
const asc = `${keyField} asc`;
const desc = `${keyField} desc`;
const quoted = current === null ? '' : `'${escapeODataString(current)}'`;
const quoted = current === null ? '' : numeric ? String(Number(current)) : `'${escapeODataString(current)}'`;
switch (target) {
case 'first':
return { $orderby: asc };
@@ -3,4 +3,4 @@
* configuración completa (`master-screens.ts`) se carga en diferido al abrir una. Un test comprueba que
* coincide con `MASTER_SCREENS`.
*/
export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/finanzas/plan-de-cuentas'];
export const MASTER_PATHS: readonly string[] = ['/socios/clientes', '/socios/proveedores', '/inventario/articulos', '/inventario/lotes', '/finanzas/plan-de-cuentas'];
@@ -2,13 +2,15 @@ import { FINANZAS_EXTENSIONS } from './finanzas/finanzas.extensions';
import { FINANZAS_SCREENS } from './finanzas/finanzas.screens';
import { ARTICULOS_SCREENS } from './articulos/articulos.screens';
import { ARTICULOS_EXTENSIONS } from './articulos/articulos.extensions';
import { LOTES_EXTENSIONS } from './lotes/lotes.extensions';
import { LOTES_SCREENS } from './lotes/lotes.screens';
import { type MasterExtensions, applyMasterExtensions } from './master-extensions';
import { SOCIOS_EXTENSIONS } from './socios-negocio/socios.extensions';
import { SOCIOS_SCREENS } from './socios-negocio/socios.screens';
import type { MasterScreen } from './master-types';
/** Las pantallas de maestros del proyecto base, agrupadas por módulo (una carpeta cada uno). */
export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...FINANZAS_SCREENS];
export const BASE_MASTER_SCREENS: readonly MasterScreen[] = [...SOCIOS_SCREENS, ...ARTICULOS_SCREENS, ...LOTES_SCREENS, ...FINANZAS_SCREENS];
/** Cada módulo solo puede extender sus propias pantallas: una ruta ajena es un error, no un descuido silencioso. */
function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions): MasterExtensions {
@@ -22,6 +24,7 @@ function ownedBy(screens: readonly MasterScreen[], extensions: MasterExtensions)
export const MASTER_EXTENSIONS: MasterExtensions = {
...ownedBy(SOCIOS_SCREENS, SOCIOS_EXTENSIONS),
...ownedBy(ARTICULOS_SCREENS, ARTICULOS_EXTENSIONS),
...ownedBy(LOTES_SCREENS, LOTES_EXTENSIONS),
...ownedBy(FINANZAS_SCREENS, FINANZAS_EXTENSIONS),
};
@@ -1,6 +1,7 @@
import type { ListFilter } from '../../core/sl/list-filters';
import type { DetailColumn } from '../../shared/grid/grid.types';
import type { ListColumn } from '../../shared/list/list.types';
import type { WindowBadge } from '../../shared/window';
import type { FieldColumns } from '../documents/document-layouts';
import type { DocRecord } from '../documents/document-model';
@@ -39,6 +40,17 @@ export interface MasterTab extends FieldColumns {
id: string;
label: string;
collection?: MasterCollection;
/**
* Una tabla de solo lectura con el resultado de una consulta registrada a la base de SAP (`/api/sapdb`; el stock de un
* lote por almacén). Si la base no está configurada o el usuario no tiene permiso, la pestaña no se muestra.
*/
query?: {
name: string;
params: (doc: DocRecord) => Record<string, unknown>;
columns: readonly DetailColumn[];
hideEmptyBy?: readonly string[];
hideEmptyLabel?: string;
};
}
/**
@@ -67,6 +79,14 @@ export interface MasterScreen {
resource: string;
/** Campo clave del recurso (`CardCode`, `ItemCode`). */
keyField: string;
/** Tipo de la clave (`DocEntry` de un lote es numérica); por defecto, texto. */
keyType?: 'string' | 'number';
/** `false` si SAP no crea el registro desde aquí (los lotes nacen en los documentos): sin botón «Nuevo». */
creatable?: boolean;
/** Campo que se muestra en el título junto al nombre en singular, en vez de la clave (el número de lote). */
titleField?: string;
/** La insignia del estado del registro (por defecto, Activo / Inactivo según `Frozen`). */
badge?: (doc: DocRecord) => WindowBadge | undefined;
/** Campo que se muestra en el título en vez de la clave, si la tiene (`FormatCode` del plan de cuentas). */
displayField?: string;
/** Nombre en singular, para el título del formulario (`Cliente`). */
@@ -0,0 +1,51 @@
import { ChangeDetectionStrategy, Component, computed, inject, input, resource } from '@angular/core';
import { apiErrorMessage } from '../../core/api';
import { SapDbApi } from '../../core/sl/sapdb-api';
import type { DetailColumn } from './grid.types';
import { RowsTable } from './rows-table';
/** Tabla de solo lectura con el resultado de una consulta registrada a la base de SAP (el stock de un lote por almacén). */
@Component({
selector: 'sbo-query-table',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RowsTable],
template: `
@if (result.error()) {
<p class="err" role="alert">{{ error() }}</p>
} @else if (result.isLoading()) {
<p class="hint">Consultando…</p>
} @else {
<sbo-rows-table [columns]="columns()" [rows]="rows()" [readonly]="true" [removable]="false" [hideEmptyBy]="hideEmptyBy() ?? null" [hideEmptyLabel]="hideEmptyLabel()" emptyText="Sin datos." />
@if (result.value()?.truncated) {
<p class="hint">Hay más filas de las que se muestran.</p>
}
}
`,
styles: `
:host { display: block; }
.hint { margin: 6px 0; color: var(--quiet); }
.err { margin: 6px 0; color: var(--err); }
`,
})
export class QueryTable {
private readonly api = inject(SapDbApi);
readonly query = input.required<string>();
readonly params = input.required<Record<string, unknown>>();
readonly columns = input.required<readonly DetailColumn[]>();
readonly hideEmptyBy = input<readonly string[]>();
readonly hideEmptyLabel = input('Ocultar las filas en 0');
/** La consulta y sus parámetros como texto: quien nos usa puede armar un objeto nuevo en cada ciclo y no por eso se vuelve a consultar. */
private readonly request = computed(() => JSON.stringify({ query: this.query(), params: this.params() }));
protected readonly result = resource({
params: () => this.request(),
loader: ({ params }) => {
const r = JSON.parse(params) as { query: string; params: Record<string, unknown> };
return this.api.run(r.query, r.params);
},
});
protected readonly rows = computed(() => (this.result.hasValue() ? this.result.value().rows : []));
protected readonly error = computed(() => apiErrorMessage(this.result.error()));
}
+1
View File
@@ -50,6 +50,7 @@ export const MENU: readonly MenuModule[] = [
label: 'Inventario',
items: [
{ path: '/inventario/articulos', label: 'Artículos' },
{ path: '/inventario/lotes', label: 'Lotes' },
{ path: '/inventario/entradas', label: 'Entrada de inventario' },
{ path: '/inventario/salidas', label: 'Salida de inventario' },
{ path: '/inventario/transferencias', label: 'Transferencias de stock' },