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

+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,
};
}