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'),
@@ -36,7 +36,7 @@ describe('MenuService', () => {
const p = menu.ensureLoaded();
ctrl.expectOne('/api/user/menu').flush({ allowedPaths: ['*'] });
await p;
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'socios', 'admin']);
expect(menu.modules().map((m) => m.id)).toEqual(['ventas', 'compras', 'inventario', 'bancos', 'socios', 'admin']);
});
it('si falla marca el error y permite reintentar', async () => {
@@ -46,7 +46,7 @@ describe('descripción del detalle', () => {
});
it('cada acción del servidor tiene su nombre en español', () => {
for (const a of ['login', 'login_failed', 'logout', 'sl_create', 'sl_update', 'sl_denied', 'permissions_set', 'permissions_remove']) {
for (const a of ['login', 'login_failed', 'logout', 'sl_create', 'sl_update', 'sl_action', 'sl_denied', 'permissions_set', 'permissions_remove']) {
expect(AUDIT_ACTIONS[a], a).toBeTruthy();
}
});
@@ -13,6 +13,7 @@ export const AUDIT_ACTIONS: Readonly<Record<string, string>> = {
logout: 'Cierre de sesión',
sl_create: 'Creación',
sl_update: 'Modificación',
sl_action: 'Acción sobre un registro',
sl_denied: 'Operación denegada',
permissions_set: 'Permisos asignados',
permissions_remove: 'Permisos quitados',
@@ -35,6 +36,7 @@ export function describeDetail(row: Pick<AuditRow, 'detail'>): string {
const d = row.detail;
if (!d) return '';
const parts: string[] = [];
if (typeof d['action'] === 'string') parts.push(d['action'] === 'Cancel' ? 'Anulación' : d['action']);
if (typeof d['reason'] === 'string') parts.push(d['reason']);
if (typeof d['error'] === 'string') parts.push(d['error']);
if (Array.isArray(d['fields']) && d['fields'].length) parts.push(`Campos: ${(d['fields'] as string[]).join(', ')}`);
+3 -1
View File
@@ -48,13 +48,15 @@ export interface GridMultiAdd {
export interface DetailColumn {
key: string;
title: string;
type: 'text' | 'number' | 'date' | 'select';
type: 'text' | 'number' | 'date' | 'select' | 'lookup';
width?: string;
required?: boolean;
/** Solo se muestra (aunque la tabla sea editable). */
readonly?: boolean;
/** Opciones de una columna `select`. */
options?: readonly { value: string; label: string }[];
/** Ventana de selección de una columna `lookup` (botón «…»): se escribe el código o se elige de la lista. */
lookup?: VhLookup;
/** Decimales fijos de una columna numérica; sin él, hasta 3. */
decimals?: number;
}
@@ -0,0 +1,128 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { Component, signal } from '@angular/core';
import { type ComponentFixture, TestBed } from '@angular/core/testing';
import { beforeEach, describe, expect, it } from 'vitest';
import type { DetailColumn } from './grid.types';
import { RowsTable } from './rows-table';
const COLUMNS: DetailColumn[] = [
{ key: 'Name', title: 'Nombre', type: 'text', required: true },
{ key: 'Amount', title: 'Importe', type: 'number', decimals: 2 },
{ key: 'Due', title: 'Vence', type: 'date' },
{ key: 'Kind', title: 'Tipo', type: 'select', options: [{ value: 'a', label: 'Uno' }, { value: 'b', label: 'Dos' }] },
{ key: 'Total', title: 'Total', type: 'number', readonly: true },
{ key: 'Bank', title: 'Banco', type: 'lookup', lookup: { title: 'Bancos', resource: 'Banks', columns: [{ key: 'BankCode', title: 'Código' }, { key: 'BankName', title: 'Nombre' }] } },
];
@Component({
imports: [RowsTable],
template: `<sbo-rows-table [columns]="columns" [(rows)]="rows" [readonly]="readonly()" [removable]="removable()" emptyText="Nada" />`,
})
class Host {
columns = COLUMNS;
rows = signal<Record<string, unknown>[]>([{ Name: 'A', Amount: 1500, Due: '2026-10-16T00:00:00Z', Kind: 'b', Total: 9, Bank: '001' }]);
readonly = signal(false);
removable = signal(true);
}
describe('RowsTable', () => {
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();
};
const cells = (row = 0) => [...el().querySelectorAll('tbody tr')[row].querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select')];
beforeEach(async () => {
TestBed.resetTestingModule();
await TestBed.configureTestingModule({ imports: [Host], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
ctrl = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(Host);
await tick();
});
it('muestra cada valor con su formato: número con decimales, fecha AAAA-MM-DD, lista y código', () => {
const [name, amount, due, kind, total, bank] = cells();
expect(name.value).toBe('A');
expect(amount.value).toBe('1,500.00');
expect(due.value).toBe('2026-10-16');
expect(kind.value).toBe('b');
expect(total.value).toBe('9');
expect(bank.value).toBe('001');
});
it('editar una celda de texto, número o fecha actualiza la fila', async () => {
const [name, amount, due] = cells() as HTMLInputElement[];
name.value = 'Nuevo';
name.dispatchEvent(new Event('change'));
amount.value = '2.250,5';
amount.dispatchEvent(new Event('change'));
due.value = '2026-12-31';
due.dispatchEvent(new Event('change'));
await tick();
expect(fixture.componentInstance.rows()[0]).toMatchObject({ Name: 'Nuevo', Due: '2026-12-31' });
expect(typeof fixture.componentInstance.rows()[0]['Amount']).toBe('number');
});
it('un valor que no es número conserva el anterior', async () => {
const amount = cells()[1] as HTMLInputElement;
amount.value = 'abc';
amount.dispatchEvent(new Event('change'));
await tick();
expect(fixture.componentInstance.rows()[0]['Amount']).toBe(1500);
expect(amount.value).toBe('1,500.00');
});
it('una celda de lista elige entre sus opciones', async () => {
const kind = cells()[3] as HTMLSelectElement;
kind.value = 'a';
kind.dispatchEvent(new Event('change'));
await tick();
expect(fixture.componentInstance.rows()[0]['Kind']).toBe('a');
});
it('las columnas de solo lectura no se editan', () => {
expect((cells()[4] as HTMLInputElement).readOnly).toBe(true);
expect((cells()[0] as HTMLInputElement).readOnly).toBe(false);
});
it('el botón × quita la fila y la tabla vacía muestra su texto', async () => {
(el().querySelector('td.del button') as HTMLElement).click();
await tick();
expect(fixture.componentInstance.rows()).toEqual([]);
expect(el().querySelector('tr.none')?.textContent).toContain('Nada');
});
it('sin permiso para quitar o en solo lectura no hay botón ×', async () => {
fixture.componentInstance.removable.set(false);
await tick();
expect(el().querySelector('td.del')).toBeNull();
fixture.componentInstance.removable.set(true);
fixture.componentInstance.readonly.set(true);
await tick();
expect(el().querySelector('td.del')).toBeNull();
expect(cells().every((c) => (c as HTMLInputElement).readOnly || (c as HTMLSelectElement).disabled)).toBe(true);
});
it('una columna con lista de valores abre su ventana y escribe el código elegido', async () => {
(el().querySelector('.pick button') as HTMLElement).click();
await tick();
const req = ctrl.expectOne((r) => r.url === '/api/sl/Banks');
req.flush({ value: [{ BankCode: '001', BankName: 'Banco de Chile' }, { BankCode: '009', BankName: 'Banco Internacional' }], 'odata.count': '2' });
await tick();
(el().querySelectorAll('.dlg tbody tr')[1] as HTMLElement).click();
await tick();
expect(fixture.componentInstance.rows()[0]['Bank']).toBe('009');
expect(el().querySelector('.dlg')).toBeNull();
});
it('en solo lectura una columna con lista no ofrece el botón «…»', async () => {
fixture.componentInstance.readonly.set(true);
await tick();
expect(el().querySelector('.pick button')).toBeNull();
});
});
+51 -1
View File
@@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component, input, model } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, input, model, signal } from '@angular/core';
import { formatNumber, parseDecimal } from '../../core/format';
import type { ListRow } from '../list/list.types';
import { ValueHelp } from '../value-help/value-help';
import type { DetailColumn } from './grid.types';
type Row = Record<string, unknown>;
@@ -15,6 +17,7 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
@Component({
selector: 'sbo-rows-table',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ValueHelp],
template: `
<div class="wrap">
<table>
@@ -47,6 +50,21 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
<option [value]="o.value" [selected]="o.value === row[c.key]">{{ o.label }}</option>
}
</select>
} @else if (c.type === 'lookup') {
<div class="pick">
<input
autocomplete="off"
[readOnly]="locked(c)"
[attr.data-r]="i"
[attr.aria-label]="c.title + ' de la fila ' + (i + 1)"
[value]="text(row, c)"
(change)="set(i, c, $any($event.target))"
(keydown.f4)="$event.preventDefault(); openLookup(i, c)"
/>
@if (!locked(c)) {
<button type="button" tabindex="-1" [attr.aria-label]="'Abrir la lista de ' + c.title" title="Lista de valores (F4)" (click)="openLookup(i, c)">…</button>
}
</div>
} @else {
<input
[type]="c.type === 'date' ? 'date' : 'text'"
@@ -73,9 +91,26 @@ const dateOnly = (v: unknown): string => (typeof v === 'string' ? v.slice(0, 10)
</tbody>
</table>
</div>
@if (target(); as t) {
<sbo-value-help
[open]="true"
[title]="t.lookup.title"
[resource]="t.lookup.resource"
[columns]="t.lookup.columns"
[baseFilter]="t.lookup.baseFilter"
[orderby]="t.lookup.orderby"
[rowKey]="t.lookup.valueKey"
[extraFields]="t.lookup.extraFields ?? []"
(selected)="picked($event)"
(closed)="target.set(null)"
/>
}
`,
styles: `
:host { display: block; }
.pick { display: grid; grid-template-columns: minmax(0, 1fr) 22px; }
.pick button { padding: 0; background: var(--btn); border: 1px solid var(--field-b); border-radius: var(--radius); cursor: pointer; }
.pick button:hover { background: var(--btn-h); }
.wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th { padding: 4px 6px; background: var(--grid-head); border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; text-align: left; white-space: nowrap; }
@@ -102,6 +137,21 @@ export class RowsTable {
readonly removable = input(true);
readonly emptyText = input('Sin filas.');
/** La ventana de selección abierta: qué fila y de qué columna. */
protected readonly target = signal<{ row: number; col: DetailColumn; lookup: NonNullable<DetailColumn['lookup']> } | null>(null);
protected openLookup(row: number, col: DetailColumn): void {
if (col.lookup && !this.locked(col)) this.target.set({ row, col, lookup: col.lookup });
}
protected picked(rows: ListRow[]): void {
const t = this.target();
const picked = rows[0];
if (!t || !picked) return;
const key = t.lookup.valueKey ?? t.lookup.columns[0]?.key ?? '';
this.rows.update((rs) => rs.map((r, i) => (i === t.row ? { ...r, [t.col.key]: picked[key] ?? '' } : r)));
}
protected canRemove(): boolean {
return !this.readonly() && this.removable();
}
+1
View File
@@ -5,6 +5,7 @@ export type AuditAction =
| 'logout'
| 'sl_create'
| 'sl_update'
| 'sl_action'
| 'sl_denied'
| 'permissions_set'
| 'permissions_remove';
+8
View File
@@ -56,6 +56,14 @@ export const MENU: readonly MenuModule[] = [
{ path: '/inventario/solicitudes-traslado', label: 'Solicitudes de traslado' },
],
},
{
id: 'bancos',
label: 'Bancos',
items: [
{ path: '/bancos/pagos-recibidos', label: 'Pagos recibidos' },
{ path: '/bancos/pagos-efectuados', label: 'Pagos efectuados' },
],
},
{
id: 'socios',
label: 'Socios de negocio',