feat: base de bancos (recursos de pagos recibidos y efectuados, acción Cancel, menú Bancos, tabla con ventana de selección)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-16 13:23:27 -05:00
1 parent 08977741de
commit 369be317ab
13 files changed
+304 -10

No files matched your search

+12
View File
@@ -49,6 +49,18 @@ export class SlProxyController {
return this.proxy.create(req.user, resource, body, pickSlHeaders(headers, 'POST'), req.ip ?? 'desconocida');
}
/** Acción sobre un registro (`/sl/IncomingPayments/512/Cancel`): solo las de la lista blanca del recurso. */
@Post(':resource/:key/:action')
@HttpCode(200)
action(
@Req() req: AuthenticatedRequest,
@Param('resource') resource: string,
@Param('key') key: string,
@Param('action') action: string,
) {
return this.proxy.action(req.user, resource, key, action, req.ip ?? 'desconocida');
}
@Patch(':resource/:key')
@HttpCode(204)
async update(
+33
View File
@@ -80,4 +80,37 @@ describe('SlProxyService: auditoría', () => {
await service.get(user, 'Orders', '5', {});
expect(record).not.toHaveBeenCalled();
});
describe('acciones sobre un registro (anular un pago)', () => {
it('anular un pago llama a Entidad(clave)/Cancel y queda registrado', async () => {
request.mockResolvedValueOnce({ data: undefined });
await service.action(user, 'IncomingPayments', '512', 'Cancel', '10.0.0.5');
expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', path: 'IncomingPayments(512)/Cancel' }));
expect(record).toHaveBeenCalledWith({ action: 'sl_action', webUser: 'ana', ip: '10.0.0.5', resource: 'IncomingPayments', key: '512', detail: { action: 'Cancel' } });
});
it('una acción que no está en la lista blanca del recurso no llega a SAP', async () => {
await expect(service.action(user, 'IncomingPayments', '512', 'Delete', '10.0.0.5')).rejects.toThrow(/no disponible/i);
await expect(service.action(user, 'Orders', '5', 'Cancel', '10.0.0.5')).rejects.toThrow(/no disponible/i);
expect(request).not.toHaveBeenCalled();
});
it('sin permiso de escritura queda denegado y no llega a SAP', async () => {
paths = ['/bancos/pagos-efectuados'];
await expect(service.action(user, 'IncomingPayments', '512', 'Cancel', '10.0.0.5')).rejects.toBeInstanceOf(ForbiddenException);
expect(request).not.toHaveBeenCalled();
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'sl_denied', resource: 'IncomingPayments', outcome: 'denied' }));
});
it('si SAP rechaza la anulación queda registrada como error y el error sigue su camino', async () => {
request.mockRejectedValueOnce(new Error('ya está anulado'));
await expect(service.action(user, 'VendorPayments', '9', 'Cancel', '10.0.0.5')).rejects.toThrow('ya está anulado');
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'sl_action', resource: 'VendorPayments', key: '9', outcome: 'error', detail: { action: 'Cancel', error: 'ya está anulado' } }));
});
it('la clave del registro se valida (solo números en los pagos)', async () => {
await expect(service.action(user, 'IncomingPayments', "1);DROP", 'Cancel', '10.0.0.5')).rejects.toThrow();
expect(request).not.toHaveBeenCalled();
});
});
});
+16
View File
@@ -94,6 +94,22 @@ export class SlProxyService {
}
}
/** Invoca una acción de un registro (`Entidad(clave)/Cancel`) de la lista blanca del recurso. */
async action(user: AuthUser, name: string, key: string, action: string, ip: string) {
const resource = await this.access(user, name, 'POST', ip);
if (!resource.actions?.includes(action)) throw new NotFoundException(`Acción no disponible: ${name}/${action}`);
const path = `${entityPath(resource, key)}/${action}`;
this.logger.log(`AUDIT ${user.webUser} POST ${path}`);
try {
const res = await this.sap.request({ method: 'POST', path, headers: {} });
await this.audit.record({ action: 'sl_action', webUser: user.webUser, ip, resource: resource.name, key, detail: { action } });
return res.data ?? null;
} catch (e) {
await this.audit.record({ action: 'sl_action', webUser: user.webUser, ip, resource: resource.name, key, outcome: 'error', detail: { action, error: errorMessage(e) } });
throw e;
}
}
private async access(user: AuthUser, name: string, method: SlMethod, ip?: string): Promise<SlResource> {
const resource = SL_RESOURCE_MAP.get(name);
if (!resource) throw new NotFoundException(`Recurso no disponible: ${name}`);
+31
View File
@@ -32,4 +32,35 @@ describe('recursos del Service Layer', () => {
expect(r.write as string[], p).toContain(p);
}
});
it('los pagos recibidos y efectuados se crean y se anulan (Cancel), pero no se modifican', () => {
for (const [name, path] of [['IncomingPayments', '/bancos/pagos-recibidos'], ['VendorPayments', '/bancos/pagos-efectuados']] as const) {
const r = SL_RESOURCE_MAP.get(name)!;
expect(r.methods, name).toEqual(['GET', 'POST']);
expect(r.actions, name).toEqual(['Cancel']);
expect(r.write, name).toBe(path);
expect(r.read, name).toEqual([path]);
}
});
it('solo los pagos tienen acciones sobre un registro', () => {
expect(SL_RESOURCES.filter((r) => r.actions?.length).map((r) => r.name)).toEqual(['IncomingPayments', 'VendorPayments']);
});
it('las facturas y notas de crédito se pueden consultar desde la pantalla de pagos, sin poder modificarlas', () => {
for (const [name, pay] of [['Invoices', '/bancos/pagos-recibidos'], ['CreditNotes', '/bancos/pagos-recibidos'], ['PurchaseInvoices', '/bancos/pagos-efectuados'], ['PurchaseCreditNotes', '/bancos/pagos-efectuados']] as const) {
const r = SL_RESOURCE_MAP.get(name)!;
expect(r.read as string[], name).toContain(pay);
expect(r.write as string | string[], name).not.toContain(pay);
}
});
it('el pago de clientes no lee las facturas de proveedores ni al revés', () => {
expect(SL_RESOURCE_MAP.get('PurchaseInvoices')!.read as string[]).not.toContain('/bancos/pagos-recibidos');
expect(SL_RESOURCE_MAP.get('Invoices')!.read as string[]).not.toContain('/bancos/pagos-efectuados');
});
it('los bancos son un catálogo de solo lectura', () => {
expect(SL_RESOURCE_MAP.get('Banks')!.methods).toEqual(['GET']);
});
});
+17 -6
View File
@@ -15,13 +15,19 @@ export interface SlResource {
write: RequiredPath | null;
/** Colecciones permitidas en `$expand` (por defecto ninguna). */
expand?: string[];
/** Acciones sobre un registro (`POST Entidad(clave)/Accion`) que se pueden invocar, p. ej. `Cancel`. Requieren el permiso de escritura. */
actions?: readonly string[];
}
const document = (name: string, path: RequiredPath): SlResource => ({
/**
* Documento de marketing o de pagos. `alsoRead` suma pantallas que solo pueden consultarlo (los pagos leen las
* facturas pendientes del socio sin poder modificarlas).
*/
const document = (name: string, path: RequiredPath, alsoRead: RequiredPath = []): SlResource => ({
name,
key: 'number',
methods: ['GET', 'POST', 'PATCH'],
read: path,
read: [path, alsoRead].flat(),
write: path,
});
@@ -51,18 +57,18 @@ export const SL_RESOURCES: readonly SlResource[] = [
document('Orders', '/ventas/ordenes'),
document('DeliveryNotes', '/ventas/entrega'),
// Boletas (`DocumentSubType = bod_Bill`) y facturas de reserva (`ReserveInvoice = tYES`) son facturas: mismo recurso, cuatro pantallas.
document('Invoices', ['/ventas/facturas', '/ventas/boletas', '/ventas/facturas-reserva', '/ventas/boletas-reserva']),
document('Invoices', ['/ventas/facturas', '/ventas/boletas', '/ventas/facturas-reserva', '/ventas/boletas-reserva'], '/bancos/pagos-recibidos'),
document('Returns', '/ventas/devoluciones'),
document('CreditNotes', '/ventas/notas-credito'),
document('CreditNotes', '/ventas/notas-credito', '/bancos/pagos-recibidos'),
// Compras
document('PurchaseRequests', '/compras/solicitudes'),
document('PurchaseQuotations', '/compras/ofertas'),
document('PurchaseOrders', '/compras/ordenes'),
document('PurchaseDeliveryNotes', '/compras/recepcion'),
document('PurchaseInvoices', '/compras/facturas'),
document('PurchaseInvoices', '/compras/facturas', '/bancos/pagos-efectuados'),
document('PurchaseReturns', '/compras/devoluciones'),
document('PurchaseCreditNotes', '/compras/notas-credito'),
document('PurchaseCreditNotes', '/compras/notas-credito', '/bancos/pagos-efectuados'),
// Inventario
document('StockTransfers', '/inventario/transferencias'),
@@ -70,6 +76,10 @@ export const SL_RESOURCES: readonly SlResource[] = [
document('InventoryGenEntries', '/inventario/entradas'),
document('InventoryGenExits', '/inventario/salidas'),
// Bancos: pagos recibidos y efectuados. Se pueden anular (`Cancel`) pero no modificar.
{ ...document('IncomingPayments', '/bancos/pagos-recibidos'), methods: ['GET', 'POST'], actions: ['Cancel'] },
{ ...document('VendorPayments', '/bancos/pagos-efectuados'), methods: ['GET', 'POST'], actions: ['Cancel'] },
// Maestros (consulta para cualquier sesión; escritura según el módulo)
catalog('BusinessPartners', 'string', ['/socios/clientes', '/socios/proveedores']),
catalog('Items', 'string', '/inventario/articulos'),
@@ -84,6 +94,7 @@ export const SL_RESOURCES: readonly SlResource[] = [
// Grupos de los maestros (para elegirlos y filtrar las listas de clientes, proveedores y artículos).
catalog('BusinessPartnerGroups', 'number'),
catalog('ItemGroups', 'number'),
catalog('Banks', 'string'),
// Dimensiones, proyectos y cuentas de los documentos (para elegirlos en cabecera y líneas).
// Indicadores de los documentos (tabla OIDC; en la localización chilena, el tipo de documento: 33 factura, 39 boleta).
catalog('FactoringIndicators', 'string'),