Multi-sucursal
Cómo un laboratorio opera varias sucursales en ixiclinic — modelo de datos, sucursal activa, scoping de datos, acceso por usuario y gestión.
Un laboratorio (tenant) puede tener varias sucursales (sedes). El multi-sucursal se
apoya en el modelo multi-tenant (ver Multi-tenancy): todo
sigue aislado por tenantId, y dentro del tenant los datos operativos se separan por
branchId. Los datos maestros (pacientes, catálogo) son compartidos entre sucursales.
Modelo de datos
branches(apps/api/src/db/schema/branches.ts):id,tenantId,name,code(prefijo corto para numeración de caja, p. ej.SPM→SPM-000001),address,isDefault(la sede principal),isActive.user_branches: acceso de un usuario a sucursales concretas. Si un usuario no tiene filas, accede a todas las sucursales de su tenant (comportamiento heredado); en cuanto tiene al menos una, queda limitado a esas.- Cada laboratorio nace con una sucursal "Sede Principal" (
isDefault: true), tanto en el registro público como al crear un cliente desde la consola.
Qué se separa por sucursal y qué se comparte
| Ámbito | Por sucursal (branchId) | Compartido (tenant) |
|---|---|---|
Órdenes (orders) | ✅ | |
Facturas (invoices) | ✅ | |
Caja (cash_registers, cash_sessions) | ✅ | |
| Cola/turnos, citas, equipos, recetas, vitales | ✅ | |
Almacenes (warehouses) | ✅ (opcional) | |
Pacientes (patients) | ✅ (el mismo paciente se atiende en cualquier sede) | |
| Catálogo (pruebas, precios), médicos, empresas | ✅ |
Sucursal activa
El JWT lleva branchId además de tenantId y role (apps/api/src/lib/jwt.ts). El
plugin tenant-scope (apps/api/src/plugins/tenant-scope.ts) inyecta request.tenant = { id, branchId } en cada request. Los handlers leen request.tenant.branchId para fijar la
sucursal al crear datos y para filtrar listados.
Cambiar de sucursal
El selector del topbar (apps/admin/src/components/layout/org-switcher.tsx) llama a
POST /api/tenants/switch, que re-emite el token con el nuevo branchId. El cambio:
- Valida que el usuario tenga acceso al tenant.
- Valida que la sucursal exista en ese tenant.
- Valida el acceso a la sucursal contra
user_branches(vacío = acceso a todas).
GET /api/tenants/:tenantId/branches (usado por el selector) devuelve solo las sucursales
activas a las que el usuario tiene acceso.
Scoping de órdenes y facturación
- Al crear,
createOrderycreateInvoice/createGroupedInvoicefijanbranchId = request.tenant.branchId(la sucursal activa). - Al listar,
GET /api/ordersyGET /api/billing/invoicesaceptan un filtro opcional?branchId=. El admin lo usa con un selector que por defecto apunta a la sucursal activa. - La migración
0043_multi_branchagrega las columnas y rellena (backfill) las órdenes y facturas existentes con la sede por defecto de su tenant, de modo que el filtrado por sucursal no deja registros huérfanos.
Compatibilidad: todo es aditivo. Un laboratorio de una sola sucursal no nota cambios (todo su dato vive en la sede por defecto y los filtros son transparentes).
Numeración
- Caja: las sesiones se numeran por sucursal usando el
codede la sucursal (SPM-000001), con un lock por(tenantId, branchId)(apps/api/src/modules/cash/service.ts). - Órdenes y facturas/NCF: la numeración sigue siendo a nivel de tenant (los NCF fiscales se asignan por tenant). Numeración por sucursal de órdenes/NCF es un paso futuro.
Acceso por usuario y gestión
- Gestión de sucursales: Settings → Sucursales en el admin (crear, editar nombre/
código/dirección, marcar principal, activar/desactivar). API:
GET /api/tenants/branches/manage,POST /api/tenants/:tenantId/branches,PATCH /api/tenants/:tenantId/branches/:branchId(todas gateadas porrequireModule("settings"), scopeadas al tenant activo del token para evitar IDOR). - Asignar usuarios a sucursales: en el formulario de usuario (Settings → Usuarios), un
multiselector de sucursales. API:
GET/PUT /api/tenants/users/:userId/branches. Sin selección = acceso a todas.
Cómo dar conciencia de sucursal a un módulo nuevo
- Añade
branchId(uuid, nullable, FK abranches) a la tabla del módulo. - En el handler de creación, pásale
request.tenant.branchIdal service y guárdalo. - En el listado, acepta un
branchIdopcional en el schema y agrégalo a las condiciones (if (params.branchId) conditions.push(eq(tabla.branchId, params.branchId))). - En la migración, rellena los registros existentes con la sede por defecto del tenant.
Pendientes
- Filtro de sucursal en el dashboard y los reportes (el modelo ya lo permite).
- Numeración de órdenes y NCF por sucursal (hoy es por tenant).
- Ver Known issues para el estado general.