feat: campos de usuario por su definición en SAP (valores válidos y tabla de usuario enlazada): endpoint sapdb/user-fields y campo udfField en cabeceras de documentos y maestros

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 15:46:00 -05:00
1 parent 3caf94609d
commit 0629ce4068
17 files changed
+517 -11

No files matched your search

+2 -2
View File
@@ -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/<nombre>` 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/<nombre>` 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 `--`).
+2
View File
@@ -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.
+5
View File
@@ -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(
+33 -1
View File
@@ -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<string, { at: number; info: UserFieldInfo | null }>();
/**
* 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<UserFieldInfo> {
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<Record<string, unknown>>, ip: string): Promise<SapDbResult> {
if (!this.adapter) throw new ServiceUnavailableException('La base de datos de SAP no está configurada (SAPDB_*)');
const query = SAPDB_QUERIES.get(name);
+107
View File
@@ -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/);
});
});
+82
View File
@@ -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<SapDbEngine, Sql> = {
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<UserFieldInfo | null> {
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,
};
}
+4 -4
View File
@@ -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<void> {
const res = await firstValueFrom(this.http.post<LoginResponse>(`${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();
}
}
+18
View File
@@ -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();
}
}
+5
View File
@@ -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<Set<string>>;
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<boolean> {
this.names ??= firstValueFrom(this.http.get<{ name: string }[]>(`${API_URL}/sapdb/queries`))
+49
View File
@@ -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<string, Promise<UserFieldInfo | null>>();
constructor() {
inject(SessionReset).register(() => this.reset());
}
get(table: string, alias: string): Promise<UserFieldInfo | null> {
const key = `${table}|${alias}`;
let hit = this.cache.get(key);
if (!hit) {
hit = firstValueFrom(this.http.get<UserFieldInfo>(`${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();
}
}
@@ -143,6 +143,16 @@
(picked)="onPartner($event)"
/>
}
@case ('udf') {
<sbo-udf-field
[label]="f.label" [dbName]="dbNames(f)"
[table]="f.table" [alias]="f.alias"
[required]="!!f.required"
[readonly]="readOnly() || !!f.readonly || locked(f)"
[value]="header()[f.key]"
(valueChange)="setHeader(f.key, $event)"
/>
}
@case ('checkbox') {
<sbo-checkbox-field
[label]="f.label" [dbName]="dbNames(f)"
@@ -10,7 +10,7 @@ import { formatNumber, todayIso } from '../../core/format';
import { CompanySettingsService } from '../../core/company/company-settings';
import { ControlAccountsService } from '../../core/sl/control-accounts';
import { SlDocumentApi } from '../../core/sl/sl-document';
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput } from '../../shared/form';
import { CheckboxField, CheckboxInput, DateField, LookupField, NumberField, NumberInput, SboField, SelectField, SelectInput, TextField, TextInput, TextareaField, TextareaInput, UdfField } from '../../shared/form';
import type { GridLine, GridMultiAdd } from '../../shared/grid/grid.types';
import { LinesGrid } from '../../shared/grid/lines-grid';
import type { VhLookup, VhRow } from '../../shared/value-help/value-help.types';
@@ -76,7 +76,7 @@ const folioAsNumber = (h: DocRecord): DocRecord => {
@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; }
@@ -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<string, unknown>) => Record<string, unknown> }
| { 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. */
@@ -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') {
<sbo-udf-field
[label]="field().label" [dbName]="dbName()"
[table]="udf().table" [alias]="udf().alias"
[required]="required()"
[readonly]="readOnly() || !!ro()"
[value]="value()"
(valueChange)="changed.emit($any($event))"
/>
}
@case ('select') {
<sbo-select-field
[label]="field().label" [dbName]="dbName()"
@@ -94,6 +104,12 @@ export class MasterField {
readonly saved = input(false);
readonly changed = output<string | number>();
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. */
+1
View File
@@ -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';
@@ -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: '<sbo-udf-field label="Campo" [table]="table" [alias]="alias" [value]="value()" (valueChange)="value.set($event)" />',
})
class Host {
table = 'OBTN';
alias = 'SIS_X';
value = signal<unknown>('');
}
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<Host>;
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<string, unknown>);
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<HTMLInputElement>('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();
});
});
+72
View File
@@ -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') {
<sbo-select-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [options]="options()" placeholder="" [value]="str() || null" (valueChange)="emit($event)" />
} @else if (kind() === 'lookup') {
<sbo-lookup-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [lookup]="lookup()" [value]="str()" (valueChange)="emit($event)" resolveOnLeave />
} @else {
<sbo-text-field [label]="label()" [dbName]="dbName()" [required]="required()" [readonly]="readOnly()" [error]="error()" [value]="str()" (valueChange)="emit($event)" />
}
`,
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<string>();
readonly alias = input.required<string>();
readonly value = model<unknown>('');
protected readonly info = resource({
params: () => ({ table: this.table(), alias: this.alias() }),
loader: ({ params }) => this.api.get(params.table, params.alias),
});
private readonly def = computed<UserFieldInfo | null>(() => (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<VhLookup>(() => {
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);
}
}