Merge branch 'main' into cliente/tagumedica
This commit is contained in:
commit
de5964606c
10 files changed
+178
-31
No files matched your search
@@ -0,0 +1,15 @@
|
||||
import type { SlResource } from './sl-resources.js';
|
||||
|
||||
/**
|
||||
* Lo que la rama de un cliente suma al proxy del Service Layer (vacío en `main`).
|
||||
*
|
||||
* `CLIENT_RESOURCE_PATHS`: pantallas suyas (rutas de menú) que además pueden leer (`read`) o escribir (`write`) un
|
||||
* recurso que ya existe, por nombre. Por ejemplo, «Inmovilizar lotes» crea solicitudes de traslado:
|
||||
*
|
||||
* InventoryTransferRequests: { write: ['/inventario/inmovilizar-lotes'], read: ['/inventario/inmovilizar-lotes'] },
|
||||
*
|
||||
* `CLIENT_SL_RESOURCES`: recursos nuevos (tablas de usuario, objetos propios). Nunca `EmployeesInfo`.
|
||||
*/
|
||||
export const CLIENT_RESOURCE_PATHS: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>> = {};
|
||||
|
||||
export const CLIENT_SL_RESOURCES: readonly SlResource[] = [];
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SL_RESOURCE_MAP, SL_RESOURCES } from './sl-resources.js';
|
||||
import { SL_RESOURCE_MAP, SL_RESOURCES, extendResources } from './sl-resources.js';
|
||||
|
||||
describe('recursos del Service Layer', () => {
|
||||
it('no incluye jamás EmployeesInfo (tiene U_WEB_PASS)', () => {
|
||||
@@ -99,3 +99,24 @@ describe('recursos del Service Layer', () => {
|
||||
expect(r.selectOnly).not.toContain('UserPassword');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extensiones de recursos por cliente', () => {
|
||||
const base = [
|
||||
{ name: 'Docs', key: 'number', methods: ['GET', 'POST'], read: ['/a'], write: '/a' },
|
||||
{ name: 'Cat', key: 'string', methods: ['GET'], read: null, write: [] },
|
||||
] as const;
|
||||
|
||||
it('suma pantallas del cliente a los permisos de un recurso, sin tocar los de cualquier sesión', () => {
|
||||
const r = extendResources(base as never, { Docs: { read: ['/x'], write: ['/x'] }, Cat: { read: ['/x'] } }, []);
|
||||
expect(r[0]).toMatchObject({ read: ['/a', '/x'], write: ['/a', '/x'] });
|
||||
expect(r[1].read).toBeNull();
|
||||
});
|
||||
|
||||
it('agrega recursos nuevos y rechaza repetidos, inexistentes y EmployeesInfo', () => {
|
||||
const nuevo = { name: 'U_TABLA', key: 'string', methods: ['GET'], read: null, write: [] } as never;
|
||||
expect(extendResources(base as never, {}, [nuevo]).map((x) => x.name)).toEqual(['Docs', 'Cat', 'U_TABLA']);
|
||||
expect(() => extendResources(base as never, { Nada: { read: ['/x'] } }, [])).toThrow(/no existe el recurso/);
|
||||
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'Docs' } as never])).toThrow(/ya existe/);
|
||||
expect(() => extendResources(base as never, {}, [{ ...(nuevo as object), name: 'EmployeesInfo' } as never])).toThrow(/EmployeesInfo/);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES } from './sl-resources.extensions.js';
|
||||
|
||||
export type SlMethod = 'GET' | 'POST' | 'PATCH';
|
||||
|
||||
/** Ruta(s) de menú que el usuario debe tener permitidas (basta con una). */
|
||||
@@ -56,7 +58,7 @@ const queryAction = (name: string): SlResource => ({ name, key: 'string', method
|
||||
*
|
||||
* Las rutas de menú son las mismas que usará el frontend (permisos por pantalla).
|
||||
*/
|
||||
export const SL_RESOURCES: readonly SlResource[] = [
|
||||
const BASE_SL_RESOURCES: readonly SlResource[] = [
|
||||
// Ventas
|
||||
document('Quotations', '/ventas/ofertas'),
|
||||
document('Orders', '/ventas/ordenes'),
|
||||
@@ -132,4 +134,37 @@ export const SL_RESOURCES: readonly SlResource[] = [
|
||||
catalog('AdditionalExpenses', 'number'),
|
||||
];
|
||||
|
||||
const union = (a: RequiredPath | null, extra: readonly string[] | undefined): RequiredPath | null => {
|
||||
// `null` = cualquier sesión: no se restringe por agregar pantallas
|
||||
if (a === null || !extra?.length) return a;
|
||||
return [...new Set([a, extra].flat())];
|
||||
};
|
||||
|
||||
/**
|
||||
* Suma lo que declara la rama de un cliente: pantallas suyas con permiso sobre recursos que ya existen
|
||||
* (`paths`: por nombre de recurso) y recursos nuevos (tablas de usuario, objetos propios). `EmployeesInfo` nunca se
|
||||
* registra (trae `U_WEB_PASS`); un recurso que no existe o repetido es un error.
|
||||
*/
|
||||
export function extendResources(
|
||||
base: readonly SlResource[],
|
||||
paths: Readonly<Record<string, { read?: readonly string[]; write?: readonly string[] }>>,
|
||||
extra: readonly SlResource[],
|
||||
): readonly SlResource[] {
|
||||
const names = new Set(base.map((r) => r.name));
|
||||
for (const name of Object.keys(paths)) {
|
||||
if (!names.has(name)) throw new Error(`sl-resources.extensions: no existe el recurso «${name}»`);
|
||||
}
|
||||
for (const r of extra) {
|
||||
if (r.name === 'EmployeesInfo') throw new Error('sl-resources.extensions: EmployeesInfo no se puede registrar (trae U_WEB_PASS)');
|
||||
if (names.has(r.name)) throw new Error(`sl-resources.extensions: el recurso «${r.name}» ya existe`);
|
||||
names.add(r.name);
|
||||
}
|
||||
return [
|
||||
...base.map((r) => (paths[r.name] ? { ...r, read: union(r.read, paths[r.name].read), write: union(r.write, paths[r.name].write) } : r)),
|
||||
...extra,
|
||||
];
|
||||
}
|
||||
|
||||
export const SL_RESOURCES: readonly SlResource[] = extendResources(BASE_SL_RESOURCES, CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES);
|
||||
|
||||
export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isDevMode } from '@angular/core';
|
||||
import type { Route, Routes } from '@angular/router';
|
||||
import { MENU } from '@basic-erp/sap-types';
|
||||
import { CLIENT_PATHS, CLIENT_ROUTES } from './client-routes';
|
||||
import { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
|
||||
import { DOCUMENT_PATHS } from './features/documents/document-paths';
|
||||
import { JOURNAL_PATH } from './features/finance/journal-paths';
|
||||
@@ -146,7 +147,7 @@ const adminRoutes: Routes = [
|
||||
];
|
||||
|
||||
/** Resto de pantallas del menú (maestros, etc.): aún sin construir. */
|
||||
const builtPaths = new Set([...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']);
|
||||
const builtPaths = new Set([...CLIENT_PATHS, ...DOCUMENT_PATHS, ...MASTER_PATHS, ...PAYMENT_PATHS, JOURNAL_PATH, '/reportes/ejecutar', '/reportes/matricula', '/admin/permisos', '/admin/auditoria']);
|
||||
const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
|
||||
.filter((item) => !builtPaths.has(item.path))
|
||||
.map(
|
||||
@@ -181,6 +182,7 @@ export const routes: Routes = [
|
||||
...journalRoutes,
|
||||
...reportRoutes,
|
||||
...adminRoutes,
|
||||
...CLIENT_ROUTES,
|
||||
...pendingRoutes,
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CLIENT_PATHS, CLIENT_ROUTES, clientRoute } from './client-routes';
|
||||
|
||||
describe('rutas de cliente', () => {
|
||||
it('una pantalla de cliente cuelga de su ruta de menú, con permiso y en diferido', () => {
|
||||
const r = clientRoute('/inventario/ejemplo', 'Ejemplo', async () => class {});
|
||||
expect(r.path).toBe('inventario/ejemplo');
|
||||
expect(r.data).toEqual({ path: '/inventario/ejemplo', title: 'Ejemplo' });
|
||||
expect(r.canActivate).toHaveLength(1);
|
||||
expect(r.loadComponent).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('las rutas de cliente salen de la lista de rutas', () => {
|
||||
expect(CLIENT_PATHS).toEqual(CLIENT_ROUTES.map((r) => `/${r.path}`));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Route, Routes } from '@angular/router';
|
||||
import { permissionGuard } from './core/auth/guards';
|
||||
|
||||
/** Una pantalla de cliente: ruta del menú (`/inventario/inmovilizar-lotes`), título y su componente en diferido. */
|
||||
export const clientRoute = (path: string, title: string, loadComponent: NonNullable<Route['loadComponent']>): Route => ({
|
||||
path: path.slice(1),
|
||||
canActivate: [permissionGuard],
|
||||
data: { path, title },
|
||||
loadComponent,
|
||||
});
|
||||
|
||||
/**
|
||||
* Pantallas propias del cliente (vacío en `main`): cada una con su entrada en `menu.extensions.ts` de `libs/sap-types`.
|
||||
* Se cargan en diferido, como todas.
|
||||
*
|
||||
* Ejemplo:
|
||||
*
|
||||
* clientRoute('/inventario/inmovilizar-lotes', 'Inmovilizar lotes', () => import('./features/client/inmovilizar.page').then((m) => m.InmovilizarPage)),
|
||||
*/
|
||||
export const CLIENT_ROUTES: Routes = [];
|
||||
|
||||
/** Las rutas de menú que ya tienen pantalla de cliente (no se muestran como «en construcción»). */
|
||||
export const CLIENT_PATHS: readonly string[] = CLIENT_ROUTES.map((r) => `/${r.path}`);
|
||||
Reference in new issue
Block a user