diff --git a/CLAUDE.md b/CLAUDE.md index 001b009..a63860f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,7 +99,8 @@ Pendiente, en orden: 2. Cada cliente tiene su branch (`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. 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 ` desde `main`); (c) `git checkout cliente/` 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. -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/ && 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). -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. -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. +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. **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 ` desde `main`); (c) `git checkout cliente/` 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. **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/ && 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. **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. diff --git a/apps/api/src/sl/sl-resources.extensions.ts b/apps/api/src/sl/sl-resources.extensions.ts new file mode 100644 index 0000000..65a8289 --- /dev/null +++ b/apps/api/src/sl/sl-resources.extensions.ts @@ -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> = {}; + +export const CLIENT_SL_RESOURCES: readonly SlResource[] = []; diff --git a/apps/api/src/sl/sl-resources.spec.ts b/apps/api/src/sl/sl-resources.spec.ts index f74ee10..f8beecb 100644 --- a/apps/api/src/sl/sl-resources.spec.ts +++ b/apps/api/src/sl/sl-resources.spec.ts @@ -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/); + }); +}); diff --git a/apps/api/src/sl/sl-resources.ts b/apps/api/src/sl/sl-resources.ts index 5d2d546..08b9903 100644 --- a/apps/api/src/sl/sl-resources.ts +++ b/apps/api/src/sl/sl-resources.ts @@ -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>, + 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 = new Map(SL_RESOURCES.map((r) => [r.name, r])); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 34a6bf1..0f9b27d 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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, ], }, diff --git a/apps/web/src/app/client-routes.spec.ts b/apps/web/src/app/client-routes.spec.ts new file mode 100644 index 0000000..750dcbf --- /dev/null +++ b/apps/web/src/app/client-routes.spec.ts @@ -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}`)); + }); +}); diff --git a/apps/web/src/app/client-routes.ts b/apps/web/src/app/client-routes.ts new file mode 100644 index 0000000..fc043b3 --- /dev/null +++ b/apps/web/src/app/client-routes.ts @@ -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 => ({ + 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}`); diff --git a/libs/sap-types/src/menu.extensions.ts b/libs/sap-types/src/menu.extensions.ts new file mode 100644 index 0000000..7d84eb6 --- /dev/null +++ b/libs/sap-types/src/menu.extensions.ts @@ -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> = {}; diff --git a/libs/sap-types/src/menu.spec.ts b/libs/sap-types/src/menu.spec.ts index f69ccef..6cebd0e 100644 --- a/libs/sap-types/src/menu.spec.ts +++ b/libs/sap-types/src/menu.spec.ts @@ -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', () => { - it('no repite rutas y todas son absolutas', () => { - const paths = menuPaths(); - 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); +const base = [ + { id: 'a', label: 'A', items: [{ path: '/a/uno', label: 'Uno' }] }, + { id: 'b', label: 'B', items: [] }, +]; - it('"*" ve todo', () => expect(filterMenu(MENU, ['*'])).toHaveLength(MENU.length)); - it('sin permisos no ve nada', () => expect(filterMenu(MENU, [])).toEqual([])); +describe('extendMenu', () => { + 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', () => { - const result = filterMenu(MENU, ['/ventas/*', '/inventario/articulos']); - expect(result.map((m) => m.id)).toEqual(['ventas', 'inventario']); - expect(result[1].items.map((i) => i.path)).toEqual(['/inventario/articulos']); + it('un módulo que no existe o una ruta repetida es un error', () => { + expect(() => extendMenu(base, { zz: [{ path: '/zz/x', label: 'X' }] })).toThrow(/no existe el módulo/); + expect(() => extendMenu(base, { b: [{ path: '/a/uno', label: 'Otra' }] })).toThrow(/ya existe/); }); - it('omite los módulos sin pantallas permitidas', () => { - expect(ids(['/socios/clientes'])).toEqual(['socios']); + it('en main el menú es el del base (sin pantallas de cliente)', () => { + expect(MENU.length).toBeGreaterThan(0); }); }); diff --git a/libs/sap-types/src/menu.ts b/libs/sap-types/src/menu.ts index f9e8fcf..89b4acd 100644 --- a/libs/sap-types/src/menu.ts +++ b/libs/sap-types/src/menu.ts @@ -1,3 +1,4 @@ +import { CLIENT_MENU_ITEMS } from './menu.extensions.js'; import { isPathAllowed } from './permissions.js'; export interface MenuItem { @@ -12,11 +13,8 @@ export interface MenuModule { items: readonly MenuItem[]; } -/** - * 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[] = [ +/** Menú del proyecto base; cada cliente suma sus pantallas en `menu.extensions.ts` (el resultado es `MENU`). */ +const BASE_MENU: readonly MenuModule[] = [ { id: '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>): 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. */ export function filterMenu(menu: readonly MenuModule[], allowedPaths: readonly string[]): MenuModule[] { return menu