diff --git a/CLAUDE.md b/CLAUDE.md index 7650d69..363fac2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. **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`). 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//.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). diff --git a/apps/api/src/sapdb/sapdb-queries.ts b/apps/api/src/sapdb/sapdb-queries.ts index e360b5a..f2aaec8 100644 --- a/apps/api/src/sapdb/sapdb-queries.ts +++ b/apps/api/src/sapdb/sapdb-queries.ts @@ -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. */ diff --git a/apps/api/src/sapdb/sapdb.service.spec.ts b/apps/api/src/sapdb/sapdb.service.spec.ts index 21b56d7..b1284a9 100644 --- a/apps/api/src/sapdb/sapdb.service.spec.ts +++ b/apps/api/src/sapdb/sapdb.service.spec.ts @@ -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) }, + ]); }); }); diff --git a/apps/web/src/app/core/auth/auth.service.ts b/apps/web/src/app/core/auth/auth.service.ts index 5271d50..41582ef 100644 --- a/apps/web/src/app/core/auth/auth.service.ts +++ b/apps/web/src/app/core/auth/auth.service.ts @@ -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 { const res = await firstValueFrom(this.http.post(`${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(); } } diff --git a/apps/web/src/app/core/sl/sapdb-api.ts b/apps/web/src/app/core/sl/sapdb-api.ts new file mode 100644 index 0000000..a5e4fb3 --- /dev/null +++ b/apps/web/src/app/core/sl/sapdb-api.ts @@ -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[]; + 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>; + + /** La consulta existe para este usuario y la base está configurada. Se pregunta una vez por sesión. */ + available(name: string): Promise { + this.names ??= firstValueFrom(this.http.get<{ name: string }[]>(`${API_URL}/sapdb/queries`)) + .then((list) => new Set(list.map((q) => q.name))) + .catch(() => new Set()); + 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): Promise { + return firstValueFrom(this.http.post(`${API_URL}/sapdb/queries/${encodeURIComponent(name)}`, { params })); + } +} diff --git a/apps/web/src/app/features/masters/lotes/lotes.screens.ts b/apps/web/src/app/features/masters/lotes/lotes.screens.ts index 2b4f250..098ac81 100644 --- a/apps/web/src/app/features/masters/lotes/lotes.screens.ts +++ b/apps/web/src/app/features/masters/lotes/lotes.screens.ts @@ -67,6 +67,25 @@ export const LOTES_SCREENS: readonly MasterScreen[] = [ 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: () => ({}), diff --git a/apps/web/src/app/features/masters/lotes/lotes.spec.ts b/apps/web/src/app/features/masters/lotes/lotes.spec.ts index 5da3dc7..45646e4 100644 --- a/apps/web/src/app/features/masters/lotes/lotes.spec.ts +++ b/apps/web/src/app/features/masters/lotes/lotes.spec.ts @@ -49,11 +49,18 @@ describe('Lotes (BatchNumberDetails)', () => { 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(); } - beforeEach(() => TestBed.resetTestingModule()); + 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'); @@ -70,7 +77,7 @@ describe('Lotes (BatchNumberDetails)', () => { 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', 'Detalles']); + 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 () => { @@ -79,6 +86,33 @@ describe('Lotes (BatchNumberDetails)', () => { 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'); diff --git a/apps/web/src/app/features/masters/master-form.page.html b/apps/web/src/app/features/masters/master-form.page.html index 291a184..23103be 100644 --- a/apps/web/src/app/features/masters/master-form.page.html +++ b/apps/web/src/app/features/masters/master-form.page.html @@ -27,7 +27,7 @@ - @for (tab of layout.tabs; track tab.id) { + @for (tab of tabs(); track tab.id) {
@@ -41,6 +41,11 @@ }
+ @if (tab.query; as q) { + @if (doc(); as d) { + + } + } @if (tab.collection; as c) { (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; } @@ -66,6 +68,22 @@ export class MasterFormPage { /** 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(); + 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(); + 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 }); } diff --git a/apps/web/src/app/features/masters/master-types.ts b/apps/web/src/app/features/masters/master-types.ts index 7199e14..10ce306 100644 --- a/apps/web/src/app/features/masters/master-types.ts +++ b/apps/web/src/app/features/masters/master-types.ts @@ -40,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; + columns: readonly DetailColumn[]; + hideEmptyBy?: readonly string[]; + hideEmptyLabel?: string; + }; } /** diff --git a/apps/web/src/app/shared/grid/query-table.ts b/apps/web/src/app/shared/grid/query-table.ts new file mode 100644 index 0000000..1a244dc --- /dev/null +++ b/apps/web/src/app/shared/grid/query-table.ts @@ -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()) { + + } @else if (result.isLoading()) { +

Consultando…

+ } @else { + + @if (result.value()?.truncated) { +

Hay más filas de las que se muestran.

+ } + } + `, + 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(); + readonly params = input.required>(); + readonly columns = input.required(); + readonly hideEmptyBy = input(); + 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 }; + 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())); +}