feat: puntos de extensión por cliente para el menú, las rutas del frontend y los permisos del proxy del Service Layer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-20 15:39:18 -05:00
1 parent 8ba845cc23
commit 3caf94609d
10 files changed
+178 -31

No files matched your search

+5 -4
View File
@@ -99,7 +99,8 @@ Pendiente, en orden:
2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes. 2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes.
3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente. 3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente.
4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. Cada paso termina en un commit propio. 4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación. Cada paso termina en un commit propio.
5. **Una mejora genérica descubierta en la rama de un cliente** va a `main`, no se queda en el cliente. Mantener el commit del cliente y el genérico **separados** (un solo propósito por commit). Flujo: (a) commit de lo del cliente; (b) `git checkout main` y desarrollar ahí la mejora en su propio commit (o, si ya está hecha en la rama del cliente como commit propio, `git cherry-pick <hash>` desde `main`); (c) `git checkout cliente/<nombre>` y `git merge main`. Desarrollarla dos veces (en el cliente y en `main`) provoca conflictos o código repetido. Como siempre, confirmar con el usuario antes de mergear a `main` o de traer cambios a otra rama. 5. **Puntos de extensión por cliente** (archivos vacíos en `main`; la rama del cliente los completa sin tocar nada más): `libs/sap-types/src/menu.extensions.ts` (`CLIENT_MENU_ITEMS`: pantallas nuevas del menú, por id de módulo), `apps/web/src/app/client-routes.ts` (`CLIENT_ROUTES` con `clientRoute(ruta, título, import)`: sus pantallas, en diferido y con permiso), `apps/api/src/sl/sl-resources.extensions.ts` (`CLIENT_RESOURCE_PATHS`: rutas de menú del cliente que además pueden leer o escribir un recurso del Service Layer que ya existe, y `CLIENT_SL_RESOURCES`: recursos nuevos; nunca `EmployeesInfo`) y `apps/api/src/sapdb/sapdb.extensions.ts` (consultas a la base de SAP), más las extensiones de campos de cada módulo (`*.extensions.ts`). Un módulo o recurso que no existe o una ruta repetida lanza un error al arrancar.
6. **Traer lo nuevo de `main` a la rama de un cliente:** `git merge main` no descarga nada de internet: une la `main` que ya está en el equipo. Si hay remoto y otras personas suben a `main`, primero actualizarla: `git checkout main && git pull`, luego `git checkout cliente/<nombre> && git merge main` (o, sin cambiar de rama: `git fetch && git merge origin/main`). El merge añade los commits que faltan y no toca `main`; los conflictos solo aparecen si ambas ramas editaron las mismas líneas. Tras el merge, la rama del cliente solo tiene lo nuevo en el equipo hasta hacer `git push` desde ella (con confirmación del usuario). 6. **Una mejora genérica descubierta en la rama de un cliente** va a `main`, no se queda en el cliente. Mantener el commit del cliente y el genérico **separados** (un solo propósito por commit). Flujo: (a) commit de lo del cliente; (b) `git checkout main` y desarrollar ahí la mejora en su propio commit (o, si ya está hecha en la rama del cliente como commit propio, `git cherry-pick <hash>` desde `main`); (c) `git checkout cliente/<nombre>` y `git merge main`. Desarrollarla dos veces (en el cliente y en `main`) provoca conflictos o código repetido. Como siempre, confirmar con el usuario antes de mergear a `main` o de traer cambios a otra rama.
7. **Para que los merges salgan limpios:** lo específico del cliente vive en sus archivos de extensiones (`*.extensions.ts`, vacíos en `main`), su `.env` y su matrícula de reportes (SQLite), nunca en las pantallas base. Un cambio que obligue a editar una pantalla base es genérico y va a `main`. El `.env`, `apps/api/certs/*.pem` y `apps/api/data/` están ignorados por git y no se suben; cada cliente tiene los suyos. 7. **Traer lo nuevo de `main` a la rama de un cliente:** `git merge main` no descarga nada de internet: une la `main` que ya está en el equipo. Si hay remoto y otras personas suben a `main`, primero actualizarla: `git checkout main && git pull`, luego `git checkout cliente/<nombre> && git merge main` (o, sin cambiar de rama: `git fetch && git merge origin/main`). El merge añade los commits que faltan y no toca `main`; los conflictos solo aparecen si ambas ramas editaron las mismas líneas. Tras el merge, la rama del cliente solo tiene lo nuevo en el equipo hasta hacer `git push` desde ella (con confirmación del usuario).
8. **Estado al preparar las ramas (2026-10-19):** `main` limpio (120 commits), compila y pasa todo (libs 19, API 115, web 682), sin secretos versionados; el remoto es el Gitea `http://100.68.242.27:30000/george/basic-erp.git` (`origin`; `main` subida el 2026-10-19 y con seguimiento) y aún no hay ramas de cliente creadas. 8. **Para que los merges salgan limpios:** lo específico del cliente vive en sus archivos de extensiones (`*.extensions.ts`, vacíos en `main`), su `.env` y su matrícula de reportes (SQLite), nunca en las pantallas base. Un cambio que obligue a editar una pantalla base es genérico y va a `main`. El `.env`, `apps/api/certs/*.pem` y `apps/api/data/` están ignorados por git y no se suben; cada cliente tiene los suyos.
9. **Estado al preparar las ramas (2026-10-19):** `main` limpio (120 commits), compila y pasa todo (libs 19, API 115, web 682), sin secretos versionados; el remoto es el Gitea `http://100.68.242.27:30000/george/basic-erp.git` (`origin`; `main` subida el 2026-10-19 y con seguimiento) y aún no hay ramas de cliente creadas.
@@ -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[] = [];
+22 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('recursos del Service Layer', () => {
it('no incluye jamás EmployeesInfo (tiene U_WEB_PASS)', () => { 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'); 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/);
});
});
+36 -1
View File
@@ -1,3 +1,5 @@
import { CLIENT_RESOURCE_PATHS, CLIENT_SL_RESOURCES } from './sl-resources.extensions.js';
export type SlMethod = 'GET' | 'POST' | 'PATCH'; export type SlMethod = 'GET' | 'POST' | 'PATCH';
/** Ruta(s) de menú que el usuario debe tener permitidas (basta con una). */ /** 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). * 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 // Ventas
document('Quotations', '/ventas/ofertas'), document('Quotations', '/ventas/ofertas'),
document('Orders', '/ventas/ordenes'), document('Orders', '/ventas/ordenes'),
@@ -132,4 +134,37 @@ export const SL_RESOURCES: readonly SlResource[] = [
catalog('AdditionalExpenses', 'number'), 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])); export const SL_RESOURCE_MAP: ReadonlyMap<string, SlResource> = new Map(SL_RESOURCES.map((r) => [r.name, r]));
+3 -1
View File
@@ -1,6 +1,7 @@
import { isDevMode } from '@angular/core'; import { isDevMode } from '@angular/core';
import type { Route, Routes } from '@angular/router'; import type { Route, Routes } from '@angular/router';
import { MENU } from '@basic-erp/sap-types'; 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 { authGuard, guestGuard, menuLoadedGuard, permissionGuard } from './core/auth/guards';
import { DOCUMENT_PATHS } from './features/documents/document-paths'; import { DOCUMENT_PATHS } from './features/documents/document-paths';
import { JOURNAL_PATH } from './features/finance/journal-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. */ /** 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) const pendingRoutes: Routes = MENU.flatMap((m) => m.items)
.filter((item) => !builtPaths.has(item.path)) .filter((item) => !builtPaths.has(item.path))
.map( .map(
@@ -181,6 +182,7 @@ export const routes: Routes = [
...journalRoutes, ...journalRoutes,
...reportRoutes, ...reportRoutes,
...adminRoutes, ...adminRoutes,
...CLIENT_ROUTES,
...pendingRoutes, ...pendingRoutes,
], ],
}, },
+16
View File
@@ -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}`));
});
});
+23
View File
@@ -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}`);
+12
View File
@@ -0,0 +1,12 @@
import type { MenuItem } from './menu.js';
/**
* Pantallas que la rama de un cliente suma al menú (vacío en `main`), por id de módulo (`inventario`, `ventas`…): se
* agregan al final de ese módulo. Cada ruta necesita su pantalla en `client-routes.ts` del frontend; si el recurso que
* usa necesita permiso en el proxy del Service Layer, se declara en `sl-resources.extensions.ts` de la API.
*
* Ejemplo:
*
* inventario: [{ path: '/inventario/inmovilizar-lotes', label: 'Inmovilizar lotes' }],
*/
export const CLIENT_MENU_ITEMS: Readonly<Record<string, readonly MenuItem[]>> = {};
+17 -19
View File
@@ -1,26 +1,24 @@
import { MENU, filterMenu, menuPaths } from './menu.js'; import { describe, expect, it } from 'vitest';
import { MENU, extendMenu } from './menu.js';
describe('MENU', () => { const base = [
it('no repite rutas y todas son absolutas', () => { { id: 'a', label: 'A', items: [{ path: '/a/uno', label: 'Uno' }] },
const paths = menuPaths(); { id: 'b', label: 'B', items: [] },
expect(new Set(paths).size).toBe(paths.length); ];
expect(paths.every((p) => /^\/[a-z-]+\/[a-z-]+$/.test(p))).toBe(true);
});
});
describe('filterMenu', () => {
const ids = (allowed: string[]) => filterMenu(MENU, allowed).map((m) => m.id);
it('"*" ve todo', () => expect(filterMenu(MENU, ['*'])).toHaveLength(MENU.length)); describe('extendMenu', () => {
it('sin permisos no ve nada', () => expect(filterMenu(MENU, [])).toEqual([])); it('suma las pantallas del cliente al final de su módulo y deja los demás igual', () => {
const m = extendMenu(base, { a: [{ path: '/a/dos', label: 'Dos' }] });
expect(m[0].items.map((i) => i.path)).toEqual(['/a/uno', '/a/dos']);
expect(m[1]).toBe(base[1]);
});
it('un módulo completo y una pantalla suelta de otro', () => { it('un módulo que no existe o una ruta repetida es un error', () => {
const result = filterMenu(MENU, ['/ventas/*', '/inventario/articulos']); expect(() => extendMenu(base, { zz: [{ path: '/zz/x', label: 'X' }] })).toThrow(/no existe el módulo/);
expect(result.map((m) => m.id)).toEqual(['ventas', 'inventario']); expect(() => extendMenu(base, { b: [{ path: '/a/uno', label: 'Otra' }] })).toThrow(/ya existe/);
expect(result[1].items.map((i) => i.path)).toEqual(['/inventario/articulos']);
}); });
it('omite los módulos sin pantallas permitidas', () => { it('en main el menú es el del base (sin pantallas de cliente)', () => {
expect(ids(['/socios/clientes'])).toEqual(['socios']); expect(MENU.length).toBeGreaterThan(0);
}); });
}); });
+29 -5
View File
@@ -1,3 +1,4 @@
import { CLIENT_MENU_ITEMS } from './menu.extensions.js';
import { isPathAllowed } from './permissions.js'; import { isPathAllowed } from './permissions.js';
export interface MenuItem { export interface MenuItem {
@@ -12,11 +13,8 @@ export interface MenuModule {
items: readonly MenuItem[]; items: readonly MenuItem[];
} }
/** /** Menú del proyecto base; cada cliente suma sus pantallas en `menu.extensions.ts` (el resultado es `MENU`). */
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado const BASE_MENU: readonly MenuModule[] = [
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
*/
export const MENU: readonly MenuModule[] = [
{ {
id: 'ventas', id: 'ventas',
label: 'Ventas', label: 'Ventas',
@@ -101,6 +99,32 @@ export const MENU: readonly MenuModule[] = [
}, },
]; ];
/**
* Suma las pantallas de un cliente al final de los módulos del menú base (por id de módulo). Un módulo que no existe o
* una ruta repetida es un error: un descuido no debe pasar en silencio.
*/
export function extendMenu(base: readonly MenuModule[], extra: Readonly<Record<string, readonly MenuItem[]>>): readonly MenuModule[] {
for (const id of Object.keys(extra)) {
if (!base.some((m) => m.id === id)) throw new Error(`menu.extensions: no existe el módulo «${id}»`);
}
const taken = new Set(base.flatMap((m) => m.items.map((i) => i.path)));
return base.map((m) => {
const more = extra[m.id];
if (!more?.length) return m;
for (const item of more) {
if (taken.has(item.path)) throw new Error(`menu.extensions: la ruta «${item.path}» ya existe`);
taken.add(item.path);
}
return { ...m, items: [...m.items, ...more] };
});
}
/**
* Menú de la aplicación: única fuente de rutas. El frontend lo dibuja filtrado
* por permisos y el backend (registro del proxy SL) usa las mismas rutas.
*/
export const MENU: readonly MenuModule[] = extendMenu(BASE_MENU, CLIENT_MENU_ITEMS);
/** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */ /** Deja solo lo que el usuario puede ver; los módulos sin pantallas visibles se omiten. */
export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] { export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] {
return menu return menu