diff --git a/CLAUDE.md b/CLAUDE.md index a63860f..1882d42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ Menú `Finanzas`: **Plan de cuentas** (`/finanzas/plan-de-cuentas`) es un maestr ## Criterios de la interfaz -Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~289 kB, 82 kB comprimido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). El menú lateral se oculta con el botón ☰ (en pantallas anchas gana ancho para formularios y tablas y se recuerda en `localStorage` `erp_sidebar`; en estrechas se abre sobre el contenido); cada módulo además se pliega por su cuenta. +Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de componentes (Material/PrimeNG), sin webfonts ni animaciones, componentes propios y densos (13 px), zoneless + `OnPush`, rutas con carga diferida, listas largas paginadas en el servidor (50 por página). Presupuesto del bundle inicial en `angular.json` (aviso 300 kB; hoy ~298 kB, 85 kB comprimido; poco margen: lo nuevo que no sea del arranque va en diferido; la configuración de las pantallas de documento se carga en diferido con un `resolve`, y `document-paths.ts` es la única lista que el enrutador necesita al arrancar: `httpResource` aporta ~18 kB al chunk compartido; si hace falta recuperarlos, reemplazarlo por RxJS). El menú lateral se oculta con el botón ☰ (en pantallas anchas gana ancho para formularios y tablas y se recuerda en `localStorage` `erp_sidebar`; en estrechas se abre sobre el contenido); cada módulo además se pliega por su cuenta. ## Comandos @@ -77,7 +77,7 @@ Hecho (commits en `main`): monorepo · API (config, capa SAP con sesión compart Pendiente, en orden: 1. **Paso 9 — formulario de documentos:** hecho para ventas y compras (`document-form.page`, `document-model.ts`: cargar, crear, actualizar con `If-Match`/412, navegación, totales con `SalesTaxCodes` (también en compras), solo lectura), probado con SAP simulado y contra `SBODemoCL` (leer, crear, actualizar, líneas, 412). Un código escrito a mano (socio, artículo, almacén, impuesto) se resuelve al salir del campo con `SlLookupApi` (mismo recurso y filtro base que la lista "…"); si no existe se avisa. "Copiar de/a" hecho (`document-copy.ts`: flujos estándar y `BaseType/BaseEntry/BaseLine`; probado: orden de compra 2883 → recepción 299, orden de venta 315 → entrega 580, ambas órdenes quedaron cerradas). Formularios sin socio ni totales **hechos** (`document-layouts.ts`, configuración declarativa: cabecera de 2 columnas, columnas de línea, colección `DocumentLines` o `StockTransferLines`): entradas y salidas de inventario, transferencias de stock, solicitudes de traslado y solicitudes de compra. Entradas, salidas y transferencias contabilizadas son de solo lectura (SAP no las modifica); solicitudes de traslado y de compra se crean y modifican. Probado contra `SBODemoCL`: solicitud de compra 2 (la 1 es una prueba por API), solicitud de traslado 472 (cantidad modificada) y entrada de inventario 155 (1 unidad de `3MG30080714 M 10.5` en el almacén 8803). Limitaciones conocidas: las líneas de compra/inventario llevan `UoMEntry` solo si el artículo tiene grupo de unidades (`uomEntryOf`; sin él SAP responde "specify a UoM code"); los formularios de ventas y compras también lo envían (la unidad de venta o de compra del artículo, o la de inventario; `itemColumnWithUom`), y no se envía la unidad manual (-1). Probado: orden de compra 2884 con `SU01`, que no tiene unidad de compra por defecto. **Cómo se define un formulario:** el formulario base (`document-form.page`) no sabe de qué documento se trata; recibe de la pantalla su `layout` (`DocumentLayout` en `document-layouts.ts`): `header` y `footer` (cada uno con `left`/`right`, máximo 2 columnas) son arreglos de campos tomados del catálogo `FIELDS` (texto, fecha, número, almacén, socio, folio, calculado), `columns` son las columnas de línea y `lines` la colección que se guarda. Cada pantalla (en la carpeta de su módulo) declara los suyos (la orden de venta no lleva folio; las facturas, notas de crédito, entregas y recepciones sí, con `TAX_DOCUMENT`); agregar o quitar un campo es editar ese arreglo, sin tocar el HTML. En ventas y compras la pantalla puede agregar campos (`headerLeft`/`headerRight`), reemplazar la cabecera (`header`) o elegir sus columnas de línea del catálogo `LINE_COLUMNS` (`lineColumns: [LINE_COLUMNS.item, LINE_COLUMNS.quantity, …]`; lo que se envía por línea sale de ellas); sin elegir, el documento lleva la cabecera y todas las columnas base. -**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). La alineación de una columna se declara con `align: 'left' | 'center' | 'right'` (listas y detalle; por defecto, derecha en las numéricas). +**Campos por cliente (estándar adicionales o `U_*`):** no se editan las pantallas base. Cada módulo tiene su archivo de extensiones, vacío en `main`: `ventas/ventas.extensions.ts`, `compras/compras.extensions.ts` e `inventario/inventario.extensions.ts` (reunidos en `client-extensions.ts`). La rama `cliente/` declara ahí, por ruta de pantalla, qué suma: `headerLeft`/`headerRight`/`footerLeft`/`footerRight` (campos; tipos `text`, `date`, `number`, `textarea`, `select` con `options`, `warehouse`…), `lineColumns` (se agregan al final y se envían a SAP) y `listColumns`. Además de agregar, la extensión puede **ocultar** (`hideHeaderFields`, `hideLineColumns`, `hideListColumns`; en maestros `hideFields`, `hideListColumns`) y dejar de **solo lectura** (`readOnlyHeaderFields`, `readOnlyLineColumns`; en maestros `readOnlyFields`): lo oculto ni se muestra ni se envía; lo de solo lectura se ve pero no se edita y su valor sigue enviándose (columna `locked`, a diferencia de `readonly`, que no se envía). Los nombres son los de SAP (`CostingCode2`, `Indicator`…); un nombre inexistente, ocultar un campo/columna obligatorio, o una ruta inexistente o ajena al módulo lanzan un error al arrancar. El test «el proyecto base no usa campos de usuario» revisa `BASE_SCREENS`, no las extensiones. Verificado: el proxy `/api/sl` deja pasar nombres `U_*` en `$select`/`$orderby` y en el cuerpo (el error de un campo inexistente lo da SAP). Límite: una columna de línea de usuario con lista de valores válidos aún no tiene editor desplegable (solo texto/número). **Campos de usuario por su definición en SAP (`udfField`):** en vez de escribir los valores válidos a mano, una extensión declara el campo por su tabla y alias (`udfField('OBTN', 'SIS_ESTLOT', 'Estado del lote')`; la clave es `U_SIS_ESTLOT`) y la pantalla lo pide como lo definió SAP, leyéndolo con `GET /api/sapdb/user-fields/:tabla/:alias` (`CUFD`/`UFD1`; `UserFieldsApi`, `sbo-udf-field`): **lista desplegable** si tiene valores válidos, **ventana de selección** (con el nombre junto al código) si está enlazado a una tabla de usuario (`CUFD.RTable`: la tabla sale de la definición del campo, nunca la elige quien llama), texto si no tiene ninguna o si la base de SAP no está configurada (`SAPDB_*`); un campo numérico (`N`) se guarda como número. Funciona en cabeceras de documentos y en maestros; las columnas de línea de usuario con valores válidos siguen siendo texto. El resultado se guarda 10 minutos en el servidor y una vez por sesión en el navegador (`SessionReset` lo olvida al iniciar o cerrar sesión). Verificado contra `SBO_TAGUMEDICA_21` (estado del lote: 4 valores; registro sanitario: 357 filas; movimiento de inventario: 84; tipo de operación: 47). La alineación de una columna se declara con `align: 'left' | 'center' | 'right'` (listas y detalle; por defecto, derecha en las numéricas). **Columnas de las ventanas de selección (cliente, artículo, almacén, impuesto):** son configurables por documento. En una pantalla del base (ventas/compras): `lookups: { item: [...], partner: [...], warehouse: [...], tax: [...] }` en el último parámetro de `marketing(...)` *sustituye* las columnas de esa lista (la primera columna es el código que se escribe en el campo). En la rama del cliente: `lookupColumns` en su archivo de extensiones *suma* columnas al final (por ejemplo `U_Marca` en artículos). El recurso, el filtro y el orden de cada lista no cambian; para los documentos de inventario o solicitudes se usa `withLookupColumns(layout, cols, 'replace' | 'add')`. Las columnas de almacén aplican también al almacén de la cabecera y al de origen de una transferencia. Los formularios con socio muestran `NumAtCard` y el RUT (`FederalTaxID`, solo lectura: lo completa SAP desde el socio); facturas, notas de crédito, entregas y recepciones (`FOLIO_PATHS` en `document-screens.ts`) añaden el folio tributario, que en el Service Layer es `FolioPrefixString` + `FolioNumber` (se edita como texto y se envía como número); las listas de facturas también los muestran. **Campos de contabilidad analítica y numeración (estándar):** cabecera de todos los documentos: `Series` (la fila «Número» lleva la lista de series del documento, pedida con la acción `SeriesService_GetDocumentSeries` del Service Layer —registrada en el proxy como acción de solo consulta—, filtrada por el subtipo de la pantalla; no se puede cambiar una vez creado; sin elegir, SAP usa la por defecto), `Indicator` (ventana de selección sobre `FactoringIndicators` —tabla OIDC; en tu SAP: 33 Factura, 39 Boleta; el recurso `Indicators` no existe en este Service Layer) y `Project` (lista de proyectos). Detalle de ventas y compras: `PriceAfterVAT` (el precio con IVA; solo se envía a SAP el que el usuario escribió, `Price` o `PriceAfterVAT`, nunca los dos), `CostingCode` a `CostingCode5` y `ProjectCode` (cada uno con su **ventana modal** de selección: reglas de distribución por dimensión y proyectos), `AccountCode` (plan de cuentas) y `BaseType`/`BaseEntry`/`BaseLine` (solo lectura: el documento de origen de una línea copiada). Inventario y solicitudes de compra llevan en el detalle solo las cinco dimensiones y el proyecto. `OBJECT_TYPES` (`document-objects.ts`) da el tipo de objeto de SAP de cada pantalla. El subtipo de las series de boleta (`IB`) y el resto de `SERIES_SUBTYPE` no está verificado contra SBODemoCL (solo hay series `--`). diff --git a/apps/api/README.md b/apps/api/README.md index 5919130..39584e5 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -118,3 +118,5 @@ Algunas pantallas necesitan datos que el Service Layer no entrega bien (por ejem - **Parámetros:** el SQL lleva `:nombre` y el servidor los cambia por `?` con los valores como argumentos; un valor nunca se concatena al SQL. - **Operación:** una conexión por consulta (se abre, fija el esquema, ejecuta y cierra; nunca confirma nada), cola propia (`SAPDB_MAX_CONCURRENCY`), tiempo máximo (`SAPDB_TIMEOUT`) y tope de filas (`SAPDB_MAX_ROWS`). La auditoría registra `db_query` con el nombre, los nombres de los parámetros y las filas devueltas, nunca los valores. +**Campos de usuario (`GET /api/sapdb/user-fields/:tabla/:alias`):** entrega la definición de un campo de usuario de SAP leída de `CUFD`/`UFD1` (tipo, tamaño, valores válidos) y, si está enlazado a una tabla de usuario (`CUFD.RTable`), sus filas `Code`/`Name` (hasta `SAPDB_MAX_ROWS`). La tabla enlazada sale de la definición del campo, no de la petición, así que no se puede leer una tabla cualquiera; la tabla y el alias se validan (letras, dígitos y «_»). Cualquier sesión puede pedirlo (es lo que SAP muestra al elegir en el campo); se guarda 10 minutos en memoria; se audita solo la tabla y el alias. + diff --git a/apps/api/src/sapdb/sapdb.controller.ts b/apps/api/src/sapdb/sapdb.controller.ts index b2d2f93..714a668 100644 --- a/apps/api/src/sapdb/sapdb.controller.ts +++ b/apps/api/src/sapdb/sapdb.controller.ts @@ -18,6 +18,11 @@ export class SapDbController { return this.sapdb.available(req.user); } + @Get('user-fields/:table/:alias') + userField(@Req() req: AuthenticatedRequest, @Param('table') table: string, @Param('alias') alias: string) { + return this.sapdb.userField(req.user, table, alias, req.ip ?? ''); + } + @Post('queries/:name') @HttpCode(200) run( diff --git a/apps/api/src/sapdb/sapdb.service.ts b/apps/api/src/sapdb/sapdb.service.ts index ed23254..6f5e5f3 100644 --- a/apps/api/src/sapdb/sapdb.service.ts +++ b/apps/api/src/sapdb/sapdb.service.ts @@ -1,10 +1,11 @@ -import { BadGatewayException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException, NotImplementedException } from '@nestjs/common'; +import { BadGatewayException, BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException, NotImplementedException } from '@nestjs/common'; import { isPathAllowed } from '@basic-erp/sap-types'; import { AuditService } from '../audit/audit.service.js'; import type { AuthUser } from '../auth/auth.types.js'; import { AppConfigService } from '../config/app-config.service.js'; import { PermissionsService } from '../permissions/permissions.service.js'; import { SAPDB_QUERIES } from './sapdb-queries.js'; +import { FIELD_ALIAS, TABLE_ID, type UserFieldInfo, readUserField } from './user-fields.js'; import { bindNamed, parseParams } from './sapdb-sql.js'; import { SAPDB_ADAPTER, type SapDbAdapter, type SapDbQuery, type SapDbResult } from './sapdb.types.js'; @@ -52,6 +53,37 @@ export class SapDbService { } } + private readonly userFields = new Map(); + + /** + * Definición de un campo de usuario de SAP (tipo, valores válidos y tabla de usuario enlazada con sus filas). Cualquier + * sesión puede pedirla: es la misma información que SAP muestra al elegir en el campo. Se guarda 10 minutos en memoria. + */ + async userField(user: AuthUser, table: string, alias: string, ip: string): Promise { + if (!this.adapter) throw new ServiceUnavailableException('La base de datos de SAP no está configurada (SAPDB_*)'); + if (!TABLE_ID.test(table) || !FIELD_ALIAS.test(alias)) throw new BadRequestException('Tabla o campo inválido'); + const key = `${table.toUpperCase()}|${alias.toUpperCase()}`; + const cached = this.userFields.get(key); + if (cached && Date.now() - cached.at < 10 * 60_000) { + if (!cached.info) throw new NotFoundException(`No existe el campo de usuario U_${alias} en ${table}`); + return cached.info; + } + const opts = { timeoutMs: this.config.get('SAPDB_TIMEOUT') * 1000, maxRows: this.config.get('SAPDB_MAX_ROWS') }; + try { + const info = await this.turn(() => readUserField(this.adapter!, this.config.get('SAPDB_ENGINE') ?? 'hana', table, alias, opts)); + this.userFields.set(key, { at: Date.now(), info }); + await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: 'user-field', detail: { table, alias, found: info !== null } }); + if (!info) throw new NotFoundException(`No existe el campo de usuario U_${alias} en ${table}`); + return info; + } catch (e) { + if (e instanceof NotFoundException) throw e; + const message = e instanceof Error ? e.message : String(e); + this.logger.error(`Campo de usuario ${table}.${alias} falló: ${message}`); + await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: 'user-field', outcome: 'error', detail: { table, alias, error: message.slice(0, 300) } }); + throw new BadGatewayException(`No se pudo consultar el campo de usuario: ${message.slice(0, 200)}`); + } + } + async run(user: AuthUser, name: string, rawParams: Readonly>, ip: string): Promise { if (!this.adapter) throw new ServiceUnavailableException('La base de datos de SAP no está configurada (SAPDB_*)'); const query = SAPDB_QUERIES.get(name); diff --git a/apps/api/src/sapdb/user-fields.spec.ts b/apps/api/src/sapdb/user-fields.spec.ts new file mode 100644 index 0000000..688c5a0 --- /dev/null +++ b/apps/api/src/sapdb/user-fields.spec.ts @@ -0,0 +1,107 @@ +import { NotFoundException } from '@nestjs/common'; +import { vi } from 'vitest'; +import type { AuditService } from '../audit/audit.service.js'; +import type { AuthUser } from '../auth/auth.types.js'; +import type { AppConfigService } from '../config/app-config.service.js'; +import type { PermissionsService } from '../permissions/permissions.service.js'; +import { SapDbService } from './sapdb.service.js'; +import type { SapDbAdapter } from './sapdb.types.js'; +import { readUserField } from './user-fields.js'; + +const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' }; +const opts = { timeoutMs: 1000, maxRows: 2 }; + +/** Una base simulada: responde según el SQL. */ +function fakeDb(over: { info?: unknown[]; values?: unknown[]; linked?: unknown[] | Error } = {}) { + const calls: { sql: string; args: readonly unknown[] }[] = []; + const adapter: SapDbAdapter = { + async query(sql, args) { + calls.push({ sql, args }); + if (sql.includes('"CUFD"')) return (over.info ?? []) as never; + if (sql.includes('"UFD1"')) return (over.values ?? []) as never; + if (over.linked instanceof Error) throw over.linked; + return (over.linked ?? []) as never; + }, + }; + return { adapter, calls }; +} + +describe('campo de usuario de SAP', () => { + it('un campo con valores válidos entrega su tipo y sus valores', async () => { + const db = fakeDb({ info: [{ FieldID: 12, Descr: 'Estado del lote', TypeID: 'A', EditSize: 3, RTable: null }], values: [{ FldValue: '101', Descr: 'Cuarentena' }, { FldValue: '102', Descr: 'Aprobado' }] }); + expect(await readUserField(db.adapter, 'hana', 'OBTN', 'SIS_ESTLOT', opts)).toEqual({ + table: 'OBTN', alias: 'SIS_ESTLOT', description: 'Estado del lote', type: 'A', size: 3, + validValues: [{ value: '101', description: 'Cuarentena' }, { value: '102', description: 'Aprobado' }], + linkedTable: null, linkedRows: [], linkedTruncated: false, + }); + // el campo y la tabla viajan como argumentos, nunca dentro del SQL + expect(db.calls[0].args).toEqual(['OBTN', 'SIS_ESTLOT']); + expect(db.calls[1].args).toEqual(['OBTN', 12]); + }); + + it('un campo enlazado a una tabla de usuario entrega sus filas, recortadas al tope', async () => { + const db = fakeDb({ info: [{ FieldID: 3, Descr: 'Registro sanitario', TypeID: 'A', EditSize: 50, RTable: 'SIS_REGSAN' }], linked: [{ Code: '1', Name: 'A' }, { Code: '2', Name: null }, { Code: '3', Name: 'C' }] }); + const r = await readUserField(db.adapter, 'hana', 'OBTN', 'SIS_REGSAN', opts); + expect(r?.linkedTable).toBe('SIS_REGSAN'); + expect(r?.linkedRows).toEqual([{ code: '1', name: 'A' }, { code: '2', name: '' }]); + expect(r?.linkedTruncated).toBe(true); + expect(db.calls[2].sql).toBe('SELECT "Code", "Name" FROM "@SIS_REGSAN" ORDER BY "Code"'); + }); + + it('la tabla enlazada sale del campo y se valida: un nombre raro no llega al SQL', async () => { + const db = fakeDb({ info: [{ FieldID: 3, Descr: 'X', TypeID: 'A', EditSize: 5, RTable: 'X"; DROP TABLE "OITM' }] }); + const r = await readUserField(db.adapter, 'hana', 'OBTN', 'X', opts); + expect(r?.linkedRows).toEqual([]); + expect(db.calls).toHaveLength(2); + }); + + it('si el enlace no es a una tabla con Code y Name, el campo se trata como libre; un campo que no existe da null', async () => { + const db = fakeDb({ info: [{ FieldID: 3, Descr: 'X', TypeID: 'A', EditSize: 5, RTable: 'OCRD' }], linked: new Error('invalid column name: Code') }); + expect((await readUserField(db.adapter, 'hana', 'OBTN', 'X', opts))?.linkedRows).toEqual([]); + expect(await readUserField(fakeDb().adapter, 'hana', 'OBTN', 'NADA', opts)).toBeNull(); + }); +}); + +describe('SapDbService.userField', () => { + const make = (adapter: SapDbAdapter | null) => { + const record = vi.fn(async () => undefined); + const service = new SapDbService( + adapter, + { get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: 2, SAPDB_MAX_ROWS: 100, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService, + { getPaths: async () => ['*'] } as unknown as PermissionsService, + { record } as unknown as AuditService, + ); + return { service, record }; + }; + + it('rechaza una tabla o un campo con caracteres raros antes de tocar la base', async () => { + const db = fakeDb(); + const { service } = make(db.adapter); + await expect(service.userField(user, 'OBTN"; --', 'X', '')).rejects.toThrow(/inválido/); + await expect(service.userField(user, 'OBTN', 'a b', '')).rejects.toThrow(/inválido/); + expect(db.calls).toHaveLength(0); + }); + + it('un campo que no existe da 404 y se recuerda (no se vuelve a preguntar)', async () => { + const db = fakeDb(); + const { service } = make(db.adapter); + await expect(service.userField(user, 'OBTN', 'NADA', '')).rejects.toThrow(NotFoundException); + const calls = db.calls.length; + await expect(service.userField(user, 'obtn', 'nada', '')).rejects.toThrow(NotFoundException); + expect(db.calls.length).toBe(calls); + }); + + it('guarda la respuesta en memoria y audita solo la tabla y el campo', async () => { + const db = fakeDb({ info: [{ FieldID: 1, Descr: 'E', TypeID: 'A', EditSize: 3, RTable: null }] }); + const { service, record } = make(db.adapter); + await service.userField(user, 'OBTN', 'SIS_ESTLOT', '10.0.0.5'); + const calls = db.calls.length; + await service.userField(user, 'OBTN', 'SIS_ESTLOT', '10.0.0.5'); + expect(db.calls.length).toBe(calls); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', resource: 'user-field', detail: { table: 'OBTN', alias: 'SIS_ESTLOT', found: true } })); + }); + + it('sin base configurada responde 503', async () => { + await expect(make(null).service.userField(user, 'OBTN', 'X', '')).rejects.toThrow(/no está configurada/); + }); +}); diff --git a/apps/api/src/sapdb/user-fields.ts b/apps/api/src/sapdb/user-fields.ts new file mode 100644 index 0000000..9e2c5e5 --- /dev/null +++ b/apps/api/src/sapdb/user-fields.ts @@ -0,0 +1,82 @@ +import type { SapDbAdapter, SapDbEngine } from './sapdb.types.js'; + +/** Lo que SAP sabe de un campo de usuario: su tipo, sus valores válidos y la tabla de usuario a la que está enlazado. */ +export interface UserFieldInfo { + table: string; + alias: string; + description: string; + /** Tipo de SAP: `A` alfanumérico, `N` numérico, `D` fecha… */ + type: string; + size: number; + /** Valores válidos del campo (vacío si acepta cualquier valor). */ + validValues: { value: string; description: string }[]; + /** Tabla de usuario enlazada (`SIS_REGSAN`), si el campo está vinculado a una. */ + linkedTable: string | null; + /** Filas (`Code` y `Name`) de la tabla enlazada. */ + linkedRows: { code: string; name: string }[]; + /** La tabla enlazada tiene más filas de las que se entregan. */ + linkedTruncated: boolean; +} + +/** Una tabla de SAP (`OBTN`, `@MI_TABLA`) y el alias de un campo de usuario (`SIS_ESTLOT`, sin el `U_`). */ +export const TABLE_ID = /^@?[A-Za-z0-9_]{1,30}$/; +export const FIELD_ALIAS = /^[A-Za-z0-9_]{1,30}$/; +/** Una tabla enlazada se escribe en el SQL entre comillas: solo letras, dígitos y «_». */ +const LINKED = /^[A-Za-z0-9_]{1,30}$/; + +interface Sql { + info: string; + values: string; + linked: (table: string) => string; +} + +/** El SQL de cada motor (`CUFD` define los campos de usuario y `UFD1` sus valores válidos; existen igual en HANA y MSSQL). */ +const SQL: Record = { + hana: { + info: 'SELECT "FieldID", "Descr", "TypeID", "EditSize", "RTable" FROM "CUFD" WHERE "TableID" = ? AND "AliasID" = ?', + values: 'SELECT "FldValue", "Descr" FROM "UFD1" WHERE "TableID" = ? AND "FieldID" = ? ORDER BY "IndexID"', + linked: (t) => `SELECT "Code", "Name" FROM "@${t}" ORDER BY "Code"`, + }, +}; + +const text = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); + +/** + * Lee de la base la definición de un campo de usuario. Devuelve `null` si el campo no existe. La tabla enlazada sale de + * la propia definición del campo (no la elige quien llama), así que no se puede leer una tabla cualquiera. + */ +export async function readUserField( + adapter: SapDbAdapter, + engine: SapDbEngine, + table: string, + alias: string, + opts: { timeoutMs: number; maxRows: number }, +): Promise { + const sql = SQL[engine]; + const [field] = await adapter.query(sql.info, [table, alias], opts); + if (!field) return null; + const values = await adapter.query(sql.values, [table, field['FieldID']], opts); + const linkedTable = text(field['RTable']) || null; + let linkedRows: UserFieldInfo['linkedRows'] = []; + let linkedTruncated = false; + if (linkedTable && LINKED.test(linkedTable)) { + try { + const rows = await adapter.query(sql.linked(linkedTable), [], opts); + linkedTruncated = rows.length > opts.maxRows; + linkedRows = rows.slice(0, opts.maxRows).map((r) => ({ code: text(r['Code']), name: text(r['Name']) })); + } catch { + /* el enlace no es a una tabla de usuario con Code y Name: el campo se trata como de valores libres */ + } + } + return { + table, + alias, + description: text(field['Descr']), + type: text(field['TypeID']), + size: Number(field['EditSize'] ?? 0) || 0, + validValues: values.map((v) => ({ value: text(v['FldValue']), description: text(v['Descr']) })), + linkedTable, + linkedRows, + linkedTruncated, + }; +} diff --git a/apps/web/src/app/core/auth/auth.service.ts b/apps/web/src/app/core/auth/auth.service.ts index 41582ef..5a2d953 100644 --- a/apps/web/src/app/core/auth/auth.service.ts +++ b/apps/web/src/app/core/auth/auth.service.ts @@ -4,7 +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 { SessionReset } from '../session-reset'; import { MenuService } from '../menu/menu.service'; import { SessionStore } from './session.store'; @@ -13,13 +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 reset = inject(SessionReset); 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.reset.run(); this.session.set(res); } @@ -47,6 +47,6 @@ export class AuthService { private end(): void { this.session.clear(); this.menu.reset(); - this.sapdb.reset(); + this.reset.run(); } } diff --git a/apps/web/src/app/core/session-reset.ts b/apps/web/src/app/core/session-reset.ts new file mode 100644 index 0000000..3ba7321 --- /dev/null +++ b/apps/web/src/app/core/session-reset.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@angular/core'; + +/** + * Lo que debe olvidarse al iniciar o cerrar sesión (los permisos y lo que depende de ellos). Cada servicio con datos de la + * sesión se registra aquí al crearse, así el servicio de sesión no tiene que conocerlos (y no arrastra su código al arranque). + */ +@Injectable({ providedIn: 'root' }) +export class SessionReset { + private readonly callbacks: (() => void)[] = []; + + register(callback: () => void): void { + this.callbacks.push(callback); + } + + run(): void { + for (const cb of this.callbacks) cb(); + } +} diff --git a/apps/web/src/app/core/sl/sapdb-api.ts b/apps/web/src/app/core/sl/sapdb-api.ts index a5e4fb3..9e2d90e 100644 --- a/apps/web/src/app/core/sl/sapdb-api.ts +++ b/apps/web/src/app/core/sl/sapdb-api.ts @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { API_URL } from '../api'; +import { SessionReset } from '../session-reset'; /** Respuesta de una consulta a la base de SAP. */ export interface SapDbResult { @@ -18,6 +19,10 @@ export class SapDbApi { private readonly http = inject(HttpClient); private names?: Promise>; + constructor() { + inject(SessionReset).register(() => this.reset()); + } + /** 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`)) diff --git a/apps/web/src/app/core/sl/user-fields.ts b/apps/web/src/app/core/sl/user-fields.ts new file mode 100644 index 0000000..8a9c8ff --- /dev/null +++ b/apps/web/src/app/core/sl/user-fields.ts @@ -0,0 +1,49 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { API_URL } from '../api'; +import { SessionReset } from '../session-reset'; + +/** La definición de un campo de usuario de SAP (`GET /api/sapdb/user-fields/:tabla/:campo`). */ +export interface UserFieldInfo { + table: string; + alias: string; + description: string; + /** `A` alfanumérico, `N` numérico, `D` fecha, `M` memo… */ + type: string; + size: number; + validValues: { value: string; description: string }[]; + linkedTable: string | null; + linkedRows: { code: string; name: string }[]; + linkedTruncated: boolean; +} + +/** + * Valores válidos y tabla enlazada de los campos de usuario, tal como están definidos en SAP: así un campo de usuario + * se muestra como lista desplegable o con su ventana de selección sin escribir sus valores en el código. Se pregunta una + * vez por campo y sesión; sin base de datos configurada (o sin el campo) devuelve `null` y el campo se pide como texto. + */ +@Injectable({ providedIn: 'root' }) +export class UserFieldsApi { + private readonly http = inject(HttpClient); + private cache = new Map>(); + + constructor() { + inject(SessionReset).register(() => this.reset()); + } + + get(table: string, alias: string): Promise { + const key = `${table}|${alias}`; + let hit = this.cache.get(key); + if (!hit) { + hit = firstValueFrom(this.http.get(`${API_URL}/sapdb/user-fields/${encodeURIComponent(table)}/${encodeURIComponent(alias)}`)).catch(() => null); + this.cache.set(key, hit); + } + return hit; + } + + /** Al iniciar o cerrar sesión. */ + reset(): void { + this.cache = new Map(); + } +} diff --git a/apps/web/src/app/features/documents/document-form.page.html b/apps/web/src/app/features/documents/document-form.page.html index 3fc1acd..c0ace58 100644 --- a/apps/web/src/app/features/documents/document-form.page.html +++ b/apps/web/src/app/features/documents/document-form.page.html @@ -143,6 +143,16 @@ (picked)="onPartner($event)" /> } + @case ('udf') { + + } @case ('checkbox') { { @Component({ selector: 'sbo-document-form-page', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, ValueHelp], + imports: [AddressDialog, DocumentPdf, Tab, Tabs, CheckboxField, CheckboxInput, DateField, Icon, LinesGrid, LookupField, MenuButton, NgTemplateOutlet, NumberField, NumberInput, RouterLink, RowsTable, SboField, SelectField, SelectInput, SboWindow, TextField, TextInput, TextareaField, TextareaInput, Toolbar, UdfField, ValueHelp], templateUrl: './document-form.page.html', styles: ` h1 { margin: 0 0 6px; font-size: 18px; font-weight: 600; } diff --git a/apps/web/src/app/features/documents/document-layouts.ts b/apps/web/src/app/features/documents/document-layouts.ts index 4446ec0..43e24ff 100644 --- a/apps/web/src/app/features/documents/document-layouts.ts +++ b/apps/web/src/app/features/documents/document-layouts.ts @@ -12,6 +12,19 @@ export type GroupCell = | { kind: 'text'; key: string; label: string; width?: string; maxlength?: number } | { kind: 'lookup'; key: string; label: string; lookup: VhLookup; width?: string }; +/** + * Un campo de usuario por su definición en SAP: la tabla (`OBTN`, `OWTQ`…) y el alias sin el `U_` (`SIS_ESTLOT`). Sus valores + * válidos o su tabla de usuario enlazada se leen de SAP al abrir la pantalla; no se escriben aquí. + */ +export const udfField = (table: string, alias: string, label: string, extra: { required?: boolean; missing?: string; readonly?: boolean } = {}): HeaderField => ({ + type: 'udf', + key: `U_${alias}`, + label, + table, + alias, + ...extra, +}); + /** Fila con varios campos juntos, para la cabecera de un documento. */ export const fieldGroup = (label: string, cells: readonly GroupCell[]): HeaderField => ({ type: 'group', label, cells }); @@ -40,6 +53,11 @@ export type HeaderField = /** Campo de código con su ventana de selección (grupo, moneda, lista de precios…). */ | { type: 'lookup'; key: string; label: string; lookup: VhLookup; required?: boolean; missing?: string; immutable?: boolean; onPick?: (row: Record) => Record } | { type: 'textarea'; key: string; label: string } + /** + * Campo de usuario (`U_*`) que se pide como lo definió SAP: lista desplegable si tiene valores válidos, ventana de + * selección si está enlazado a una tabla de usuario y texto en otro caso (`udfField`). + */ + | { type: 'udf'; key: string; label: string; table: string; alias: string; required?: boolean; missing?: string; readonly?: boolean } /** Lista de valores válidos (típico de un campo de usuario con valores válidos en SAP). */ | { type: 'select'; key: string; label: string; options: readonly { value: string | number; label: string }[]; required?: boolean; missing?: string; immutable?: boolean } /** Abre la lista de almacenes. */ diff --git a/apps/web/src/app/features/masters/master-field.ts b/apps/web/src/app/features/masters/master-field.ts index 6098fc9..c2ec87e 100644 --- a/apps/web/src/app/features/masters/master-field.ts +++ b/apps/web/src/app/features/masters/master-field.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core'; -import { CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField } from '../../shared/form'; +import { CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField, UdfField } from '../../shared/form'; import { WAREHOUSE_LOOKUP } from '../documents/document-layouts'; import type { VhLookup } from '../../shared/value-help/value-help.types'; import type { HeaderField } from '../documents/document-layouts'; @@ -13,7 +13,7 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin @Component({ selector: 'sbo-master-field', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField], + imports: [CheckboxField, DateField, LookupField, NumberField, SelectField, TextField, TextareaField, UdfField], template: ` @switch (field().type) { @case ('checkbox') { @@ -46,6 +46,16 @@ const text = (v: unknown): string => (v === null || v === undefined ? '' : Strin resolveOnLeave /> } + @case ('udf') { + + } @case ('select') { (); + protected readonly udf = computed(() => { + const f = this.field(); + if (f.type !== 'udf') throw new Error('campo sin definición de usuario'); + return f; + }); + protected readonly warehouse: VhLookup = { ...WAREHOUSE_LOOKUP, nameKey: 'WarehouseName' }; /** Un código numérico de SAP (grupo, lista de precios) se guarda como número. */ diff --git a/apps/web/src/app/shared/form/index.ts b/apps/web/src/app/shared/form/index.ts index dfd85a9..dbd0930 100644 --- a/apps/web/src/app/shared/form/index.ts +++ b/apps/web/src/app/shared/form/index.ts @@ -4,3 +4,4 @@ export { SboField } from './sbo-field'; // Atajos con etiqueta (un control por fila) export { CheckboxField, DateField, NumberField, SelectField, TextField, TextareaField } from './fields'; export { LookupField } from './lookup-field'; +export { UdfField } from './udf-field'; diff --git a/apps/web/src/app/shared/form/udf-field.spec.ts b/apps/web/src/app/shared/form/udf-field.spec.ts new file mode 100644 index 0000000..4e04a55 --- /dev/null +++ b/apps/web/src/app/shared/form/udf-field.spec.ts @@ -0,0 +1,89 @@ +import { Component, signal } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { type ComponentFixture, TestBed } from '@angular/core/testing'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { UdfField } from './udf-field'; + +@Component({ + imports: [UdfField], + template: '', +}) +class Host { + table = 'OBTN'; + alias = 'SIS_X'; + value = signal(''); +} + +const base = { table: 'OBTN', alias: 'SIS_X', description: 'Campo X', size: 10, validValues: [], linkedTable: null, linkedRows: [], linkedTruncated: false }; + +describe('campo de usuario de SAP', () => { + let fixture: ComponentFixture; + let ctrl: HttpTestingController; + const el = () => fixture.nativeElement as HTMLElement; + const tick = async () => { + fixture.detectChanges(); + await new Promise((r) => setTimeout(r)); + fixture.detectChanges(); + }; + + async function open(answer: object, value: unknown = '') { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); + ctrl = TestBed.inject(HttpTestingController); + fixture = TestBed.createComponent(Host); + fixture.componentInstance.value.set(value); + await tick(); + const req = ctrl.expectOne('/api/sapdb/user-fields/OBTN/SIS_X'); + if (answer && typeof answer === 'object' && 'fail' in answer) req.flush({ message: 'x' }, { status: 503, statusText: 'Service Unavailable' }); + else req.flush(answer as Record); + await tick(); + } + + beforeEach(() => TestBed.resetTestingModule()); + + it('con valores válidos se pide en una lista desplegable con ellos', async () => { + await open({ ...base, validValues: [{ value: '101', description: 'Cuarentena' }, { value: '102', description: 'Aprobado' }] }, '102'); + const select = el().querySelector('select') as HTMLSelectElement; + expect([...select.options].map((o) => o.textContent?.trim())).toEqual(expect.arrayContaining(['101 · Cuarentena', '102 · Aprobado'])); + expect(select.value).toBe('102'); + select.value = '101'; + select.dispatchEvent(new Event('change')); + await tick(); + expect(fixture.componentInstance.value()).toBe('101'); + }); + + it('enlazado a una tabla de usuario, muestra su nombre junto al código y abre su lista', async () => { + await open({ ...base, linkedTable: 'SIS_REGSAN', linkedRows: [{ code: '10001', name: 'DM2462E' }, { code: '10002', name: 'OTRO' }] }, '10001'); + await tick(); // el nombre se resuelve de las filas ya cargadas + const inputs = [...el().querySelectorAll('input')].map((i) => i.value); + expect(inputs).toEqual(expect.arrayContaining(['10001', 'DM2462E'])); + expect(el().querySelector('button.sbo-btn')).not.toBeNull(); + }); + + it('un campo numérico guarda número, no texto', async () => { + await open({ ...base, type: 'N', validValues: [{ value: '1', description: 'Uno' }, { value: '2', description: 'Dos' }] }, 1); + const select = el().querySelector('select') as HTMLSelectElement; + select.value = '2'; + select.dispatchEvent(new Event('change')); + await tick(); + expect(fixture.componentInstance.value()).toBe(2); + }); + + it('sin base de datos configurada o sin el campo, se pide como texto', async () => { + await open({ fail: true }, 'abc'); + expect(el().querySelector('select')).toBeNull(); + const input = el().querySelector('input') as HTMLInputElement; + expect(input.value).toBe('abc'); + input.value = 'xyz'; + input.dispatchEvent(new Event('input')); + await tick(); + expect(fixture.componentInstance.value()).toBe('xyz'); + }); + + it('un campo sin valores válidos ni tabla enlazada también es texto', async () => { + await open({ ...base }); + expect(el().querySelector('select')).toBeNull(); + expect(el().querySelector('input')).not.toBeNull(); + }); +}); diff --git a/apps/web/src/app/shared/form/udf-field.ts b/apps/web/src/app/shared/form/udf-field.ts new file mode 100644 index 0000000..c511993 --- /dev/null +++ b/apps/web/src/app/shared/form/udf-field.ts @@ -0,0 +1,72 @@ +import { ChangeDetectionStrategy, Component, computed, input, model, resource } from '@angular/core'; +import { UserFieldsApi, type UserFieldInfo } from '../../core/sl/user-fields'; +import type { VhLookup } from '../value-help/value-help.types'; +import { inject } from '@angular/core'; +import { SelectField, TextField } from './fields'; +import { FieldBase } from './field-base'; +import { LookupField } from './lookup-field'; + +/** + * Campo de usuario de SAP (`U_*`) que se pide según como lo definió SAP: lista desplegable si tiene valores válidos, + * ventana de selección si está enlazado a una tabla de usuario, texto en otro caso (o mientras carga, o si la base de datos + * no está configurada). El tipo numérico se guarda como número. + */ +@Component({ + selector: 'sbo-udf-field', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [LookupField, SelectField, TextField], + template: ` + @if (kind() === 'select') { + + } @else if (kind() === 'lookup') { + + } @else { + + } + `, + styles: ':host { display: block; }', +}) +export class UdfField extends FieldBase { + private readonly api = inject(UserFieldsApi); + + /** Tabla del campo (`OBTN`, `OWTQ`, `@MI_TABLA`) y alias sin el `U_` (`SIS_ESTLOT`). */ + readonly table = input.required(); + readonly alias = input.required(); + readonly value = model(''); + + protected readonly info = resource({ + params: () => ({ table: this.table(), alias: this.alias() }), + loader: ({ params }) => this.api.get(params.table, params.alias), + }); + private readonly def = computed(() => (this.info.hasValue() ? this.info.value() : null)); + + protected readonly kind = computed<'select' | 'lookup' | 'text'>(() => { + const d = this.def(); + return d?.validValues.length ? 'select' : d?.linkedRows.length ? 'lookup' : 'text'; + }); + protected readonly str = computed(() => { + const v = this.value(); + return v === null || v === undefined ? '' : String(v); + }); + protected readonly options = computed(() => (this.def()?.validValues ?? []).map((v) => ({ value: v.value, label: v.description ? `${v.value} · ${v.description}` : v.value }))); + protected readonly lookup = computed(() => { + const d = this.def()!; + return { + title: d.description || d.linkedTable || 'Valores', + resource: d.linkedTable ?? 'UserTable', + columns: [ + { key: 'code', title: 'Código', width: '140px', mono: true, searchable: true }, + { key: 'name', title: 'Nombre', searchable: true }, + ], + rows: d.linkedRows.map((r) => ({ code: r.code, name: r.name })), + valueKey: 'code', + nameKey: 'name', + codeWidth: '140px', + }; + }); + + protected emit(v: string | number | null): void { + const s = v === null ? '' : String(v); + this.value.set(this.def()?.type === 'N' && s.trim() !== '' && Number.isFinite(Number(s)) ? Number(s) : s); + } +}