# Plan: Rubros (unidades de negocio) — separación de proveedores y catálogo

**Fecha**: 8 de septiembre de 2026
**Estado**: Fases 1 a 5 implementadas y verificadas en agogo (08/09/2026)
**Módulos afectados**: Proveedores, Compras, Gastos, Inventario/Productos, Categorías, Auth, Sucursales
**Docs relacionados**: `plan-compras-gastos.md`, `plan-acreedores-proveedores-exterior.md`, `ofertas-promociones.md`
**Repos**: `novasispy-backend-api` (NestJS + Prisma) y `novasispy-erp` (React)

---

## Problema

Caso disparador: **agogo** opera dos sucursales de **rubros distintos** (ej. despensa y ferretería). El stock ya está separado por depósito, pero los **maestros son compartidos**, así que cada sucursal ve datos que no le corresponden:

1. **Proveedores**: `proveedores` está scopeado únicamente por `empresa_id`. No existe ninguna relación con sucursal. El vendedor de la ferretería ve en el autocomplete a todos los proveedores de la despensa.

2. **Catálogo de productos**: el filtro de Sucursal/Depósito **ya existe** (`ProductosTab.jsx:1251` y `:1281`), pero **no recorta el catálogo**: solo define sobre qué depósitos se *mide* el stock (`productos.service.ts:813-895`). Recién combinado con `filtro_stock = "con"` achica la lista. Un producto del otro rubro con stock 0 sigue apareciendo. Además el selector solo se renderiza si `sucursales.length > 1`.

3. **Bug latente de contexto**: el frontend lee `user?.sucursal_id` en `ProductosTab.jsx:408` y `ComprasTemplate.jsx:1319`, pero **ese campo no existe en el modelo `usuario`**. Siempre es `undefined` y cae al fallback `sucursales[0]`. Hoy todos los usuarios arrancan en la primera sucursal, sea la suya o no. El único vínculo usuario↔sucursal existente es `asignacion_sucursal_caja_usuario`.

La raíz común: **el sistema no tiene el concepto de "esta entidad pertenece a este rubro"**. Solo tiene separación *por stock*, que es un efecto colateral del inventario, no una pertenencia.

---

## Decisiones de diseño (cerradas 08/09/2026)

| # | Decisión | Resolución |
|---|---|---|
| 1 | Rigidez del filtro | **Filtro blando.** La lista arranca filtrada por el rubro en contexto; quien corresponda puede elegir "Todos los rubros". **No es control de acceso** (ver "Alcance de seguridad"). |
| 2 | Eje de separación | **Rubro / unidad de negocio**, no la sucursal directa. Hoy 1 sucursal = 1 rubro, pero el rubro sobrevive a que una sucursal sume un segundo rubro o a que abran una segunda sucursal del mismo rubro, sin remapear nada. |
| 3 | Relación sucursal↔rubro | **N:M** (`sucursal_rubros`), no un `rubro_id` en `empresas_sucursales`. Misma razón que #2. |
| 4 | Proveedores | Tabla puente **`proveedor_rubros`**, calcada de `oferta_sucursales`. |
| 5 | Productos | **`rubro_id` en `categorias`**, heredado por el producto. NO se crea `producto_rubros`. |
| 6 | Regla de vacío | **Sin filas asignadas / `rubro_id` null = visible en TODOS los rubros.** Mismo criterio que `oferta_sucursales` (`ofertas.service.ts:374`). |
| 7 | Contexto del usuario | Default derivado de `asignacion_sucursal_caja_usuario` → sucursal → rubro, expuesto en `/auth/me`; **+ selector de rubro en el header** para cambiarlo. |
| 8 | Transporte del contexto | Query param opcional **`rubro_id`** en los endpoints. **No se toca el JWT** (evita invalidar tokens y complicar el refresh). |

### Por qué productos hereda de categoría y no lleva tabla propia (decisión #5)

- Se etiquetan ~10 categorías en vez de 72 productos, y **los productos nuevos quedan clasificados solos** al elegirles categoría. Con `producto_rubros`, cada alta sería un paso extra que alguien va a olvidar.
- En la práctica el rubro de un producto *es* su categoría: PERECEDEROS, SALSAS y ENLATADOS son despensa; no hay caso donde un producto de SALSAS sea de ferretería.
- Los productos compartidos entre rubros (bolsas, artículos de limpieza, y pseudo-productos como `DESCUENTO OTORGADO`) se resuelven dejando esa categoría **sin rubro** → visible en los dos. Sale gratis, sin campo extra.

**Contra asumida**: un producto puntual no puede escapar del rubro de su categoría sin agregar un override. Se consideró YAGNI. Si aparece el caso, la salida es agregar `productos.rubro_id` nullable como override (null = heredar de categoría), sin migrar nada de lo ya construido.

---

## Alcance de seguridad (leer antes de implementar)

Este plan implementa un **filtro de conveniencia, no un control de acceso.** Un usuario con el token puede pedir `rubro_id` de otro rubro, u omitir el parámetro, y recibir todo. Eso es **deliberado** (decisión #1) y consistente con cómo funciona hoy `oferta_sucursales`.

Si más adelante se necesita separación dura, el cambio es: validar en el backend que el `rubro_id` pedido esté dentro de los rubros asignados al usuario, y aplicar el filtro **aunque no venga el parámetro**. El modelo de datos de este plan ya lo soporta sin migraciones adicionales; lo que cambia es la política, no el esquema. No se implementa ahora.

---

## Modelo de datos

### SQL (migración idempotente)

```sql
-- 1. Catálogo de rubros por empresa
CREATE TABLE IF NOT EXISTS rubros (
  id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  empresa_id  UUID NOT NULL REFERENCES empresas(id),
  codigo      VARCHAR(20)  NOT NULL,
  descripcion VARCHAR(100) NOT NULL,
  color       VARCHAR(20),          -- chip en UI
  icono       VARCHAR(50),          -- nombre de icono lucide
  orden       INT DEFAULT 0,
  activo      BOOLEAN DEFAULT true,
  created_at  TIMESTAMP DEFAULT now(),
  updated_at  TIMESTAMP DEFAULT now(),
  CONSTRAINT uq_rubros_empresa_codigo UNIQUE (empresa_id, codigo)
);

-- 2. Sucursal ←→ rubro (N:M a propósito, ver decisión #3)
CREATE TABLE IF NOT EXISTS sucursal_rubros (
  id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  sucursal_id UUID NOT NULL REFERENCES empresas_sucursales(id) ON DELETE CASCADE,
  rubro_id    UUID NOT NULL REFERENCES rubros(id) ON DELETE CASCADE,
  CONSTRAINT uq_sucursal_rubros UNIQUE (sucursal_id, rubro_id)
);
CREATE INDEX IF NOT EXISTS idx_sucursal_rubros_rubro ON sucursal_rubros(rubro_id);

-- 3. Proveedor ←→ rubro (sin filas = todos los rubros)
CREATE TABLE IF NOT EXISTS proveedor_rubros (
  id           UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  proveedor_id UUID NOT NULL REFERENCES proveedores(id) ON DELETE CASCADE,
  rubro_id     UUID NOT NULL REFERENCES rubros(id) ON DELETE CASCADE,
  CONSTRAINT uq_proveedor_rubros UNIQUE (proveedor_id, rubro_id)
);
CREATE INDEX IF NOT EXISTS idx_proveedor_rubros_rubro ON proveedor_rubros(rubro_id);

-- 4. Categoría → rubro (null = todos los rubros)
ALTER TABLE categorias
  ADD COLUMN IF NOT EXISTS rubro_id UUID REFERENCES rubros(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_categorias_rubro ON categorias(rubro_id);
```

### Prisma

```prisma
model rubros {
  id               String             @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
  empresa_id       String             @db.Uuid
  codigo           String             @db.VarChar(20)
  descripcion      String             @db.VarChar(100)
  color            String?            @db.VarChar(20)
  icono            String?            @db.VarChar(50)
  orden            Int                @default(0)
  activo           Boolean            @default(true)
  created_at       DateTime?          @default(now()) @db.Timestamp(6)
  updated_at       DateTime?          @default(now()) @db.Timestamp(6)
  empresas         empresas           @relation(fields: [empresa_id], references: [id], onDelete: NoAction, onUpdate: NoAction)
  sucursal_rubros  sucursal_rubros[]
  proveedor_rubros proveedor_rubros[]
  categorias       categorias[]

  @@unique([empresa_id, codigo])
}

model sucursal_rubros {
  id                  String              @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
  sucursal_id         String              @db.Uuid
  rubro_id            String              @db.Uuid
  empresas_sucursales empresas_sucursales @relation(fields: [sucursal_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
  rubros              rubros              @relation(fields: [rubro_id], references: [id], onDelete: Cascade, onUpdate: NoAction)

  @@unique([sucursal_id, rubro_id])
  @@index([rubro_id], map: "idx_sucursal_rubros_rubro")
}

model proveedor_rubros {
  id           String      @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
  proveedor_id String      @db.Uuid
  rubro_id     String      @db.Uuid
  proveedores  proveedores @relation(fields: [proveedor_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
  rubros       rubros      @relation(fields: [rubro_id], references: [id], onDelete: Cascade, onUpdate: NoAction)

  @@unique([proveedor_id, rubro_id])
  @@index([rubro_id], map: "idx_proveedor_rubros_rubro")
}
```

Más: `rubro_id String? @db.Uuid` + relación `rubros` en `model categorias`, y los back-relations en `proveedores` y `empresas_sucursales`.

---

## Semántica del filtro (una sola regla, tres lugares)

Dado un `rubro_id` en contexto, un registro **entra** en la lista si:

> no tiene rubro asignado (**es compartido**) **O** tiene asignado el rubro en contexto.

Sin `rubro_id` (o con "Todos los rubros"), no se aplica filtro alguno.

**Proveedores** — `where` a agregar en `proveedores.service.ts` (`search` línea 172, `findAll` línea 239) y en `compras.service.ts:1347`:

```ts
...(rubro_id
  ? { OR: [{ proveedor_rubros: { none: {} } }, { proveedor_rubros: { some: { rubro_id } } }] }
  : {}),
```

**Productos** — `where` a agregar en `productos.service.ts:819` (`findAll`):

```ts
...(rubro_id
  ? { OR: [{ categoria_id: null }, { categoria: { rubro_id: null } }, { categoria: { rubro_id } }] }
  : {}),
```

Este filtro es **independiente** de `sucursal_id` / `deposito_id` / `filtro_stock`, que siguen haciendo exactamente lo que hacen hoy (definir el ámbito de medición de stock). No se toca esa lógica.

---

## Contexto del usuario

### Backend — `auth.service.ts` › `getMe`

Agregar al payload de `/auth/me` (sin tocar el JWT ni `signTokens`):

```ts
sucursales_asignadas: [{ id, descripcion }],   // desde asignacion_sucursal_caja_usuario
sucursal_default_id: string | null,            // primera asignación, o null
rubros_disponibles: [{ id, codigo, descripcion, color, icono }],  // rubros activos de la empresa
rubro_default_id: string | null,               // rubro de la sucursal asignada; null si tiene 0 o >1
```

Reglas para `rubro_default_id`: se resuelve por `sucursales_asignadas → sucursal_rubros → rubros`. Si el usuario no tiene asignaciones, o sus sucursales cubren más de un rubro, queda `null` = arranca en "Todos los rubros". Nadie queda encerrado por accidente.

**De paso se corrige el bug #3**: `sucursal_default_id` es el campo que `user?.sucursal_id` intentaba ser. Hay que actualizar los dos consumidores (`ProductosTab.jsx:408`, `ComprasTemplate.jsx:1319`) para leerlo. Se mantiene el fallback a `sucursales[0]` para usuarios sin asignación.

### Backend — nuevo módulo `src/rubros/`

CRUD estándar siguiendo el patrón de `src/categorias/`: `rubros.module.ts`, `rubros.controller.ts`, `rubros.service.ts`, `dto/`. Todo scopeado por `user.empresa_id`, con `AuditService` en create/update/delete como hace `asignaciones-sucursal.service.ts`.

El controller va bajo el módulo `ADMINISTRACION` (donde ya viven los maestros transversales: bancos, condiciones de pago, sucursales), con el submódulo `ADM_RUBROS` y los privilegios `ADM_RUB_RUBRO_{VER,CREAR,EDITAR,ELIMINAR}` declarados en `src/seguridad/seeds/seguridad.seed-data.ts`. El catálogo se sincroniza solo al arrancar el backend.

| Método | Ruta | Descripción |
|---|---|---|
| GET | `/v1/rubros` | Lista rubros de la empresa |
| POST | `/v1/rubros` | Crea rubro |
| PATCH | `/v1/rubros/:id` | Edita rubro |
| DELETE | `/v1/rubros/:id` | Baja lógica (`activo=false`) — nunca borrado físico, hay FKs |
| PUT | `/v1/rubros/:id/sucursales` | Reemplaza el set de sucursales del rubro |
| PUT | `/v1/proveedores/:id/rubros` | Reemplaza el set de rubros del proveedor |

Los dos `PUT` de sets siguen el patrón `deleteMany` + `createMany` en transacción que ya usa `ofertas.service.ts:246-249`.

### Endpoints que aceptan `rubro_id` (query param opcional)

- `GET /v1/proveedores` — `proveedores.controller.ts:70`
- `GET /v1/proveedores/search` — `proveedores.controller.ts:35`
- `GET /v1/compras/proveedores/search` — `compras.controller.ts:70`
- `GET /v1/productos` — controller de productos

---

## Frontend

### Selector de rubro en el header

Vive en `src/hooks/Layout.jsx`, que ya trae sucursales y usuario. Estado en un store nuevo `src/store/RubroStore.jsx` (zustand, igual que el resto de `src/store/`), inicializado con `rubro_default_id` de `/auth/me` y **persistido en localStorage** para que sobreviva al refresh.

Se renderiza **solo si `rubros_disponibles.length > 1`**. Empresas de un solo rubro (la mayoría) no ven nada nuevo — mismo criterio que ya usa el filtro de sucursal en `ProductosTab.jsx:1251`.

Incluye siempre la opción "Todos los rubros".

### Proveedores

- **`ProveedorSelector.jsx`** es el chokepoint: **10 pantallas** lo consumen (Compras, Gastos, Órdenes de Compra, Recepciones, Pagos, Notas de Crédito de Compra, Importaciones ×2, y los dos diálogos de requisición). Agregar el `rubro_id` del store ahí **cubre casi todo el sistema de una sola vez**. Es el mayor argumento a favor de este diseño.
- `ProveedoresListConfig.jsx`: columna/chip de rubro + filtro propio.
- `ProveedorFormDialog.jsx`: multi-select de rubros, con texto explícito de que vacío = todos los rubros.

### Catálogo (`ProductosTab.jsx`)

Además del filtro por rubro, el pedido explícito fue **poder identificar la pertenencia de un vistazo**: agregar un **chip de rubro por fila** en el listado (color/icono desde `rubros`), junto a los chips de IVA y Activo que ya existen. Sin chip = producto compartido.

Nota: el filtro de rubro y el de sucursal/depósito conviven y responden preguntas distintas — "de qué rubro es este producto" vs. "dónde tiene stock". No se fusionan.

### Categorías

Selector de rubro en el ABM de categorías. Es el punto de carga real de los datos del catálogo: etiquetar acá clasifica todos los productos de esa categoría de una.

---

## Migración y compatibilidad

Por la **regla del vacío** (decisión #6), el despliegue es inocuo: sin rubros creados, sin asignaciones y con `categorias.rubro_id` en null, **todo se ve como hoy**. La separación aparece progresivamente a medida que se cargan los datos. No hay backfill obligatorio ni ventana de inconsistencia.

Orden de carga sugerido para agogo:
1. Crear los dos rubros.
2. Asignar cada sucursal a su rubro (`sucursal_rubros`).
3. Etiquetar las categorías (~10 registros) → el catálogo queda separado.
4. Etiquetar proveedores; dejar sin rubro los que le venden a las dos sucursales.
5. Verificar que cada usuario tenga su asignación en `asignacion_sucursal_caja_usuario` (hoy es lo que determina el default).

Ningún endpoint cambia de contrato: `rubro_id` es siempre opcional y su ausencia mantiene el comportamiento actual.

---

## Riesgos

| Riesgo | Mitigación |
|---|---|
| Un proveedor compartido queda etiquetado a un solo rubro y "desaparece" para la otra sucursal | El filtro es blando: "Todos los rubros" siempre disponible. El form debe decir explícitamente que vacío = todos. |
| Usuarios sin asignación en `asignacion_sucursal_caja_usuario` | `rubro_default_id` cae a null = "Todos los rubros". Se comportan como hoy. |
| Se confunde el filtro de rubro con el de sucursal/depósito en el catálogo | Etiquetas y `ScreenGuia` distintas: rubro = pertenencia, depósito = dónde hay stock. |
| Categorías hijas que heredan del padre | `categorias` tiene `padre_id`. **Decidido: el rubro NO se hereda del padre** — se lee `categorias.rubro_id` de la categoría directa del producto. Si se quiere heredar, se etiquetan las hijas. Mantiene la query simple (sin recursión). |
| Se toma este filtro por un control de acceso | Documentado arriba en "Alcance de seguridad". |

---

## Fases

### Fase 1 — Modelo y ABM de rubros ✅
- [x] Migración `20260908_rubros_unidad_negocio` + modelos Prisma (aplicada y registrada en dev)
- [x] `src/common/utils/rubros.util.ts`: `whereProveedorPorRubro`, `whereProductoPorRubro`, `resolverRubroDefault`
- [x] Módulo `src/rubros/` (CRUD + `PUT /:id/sucursales`), registrado en `AppModule`
- [x] `getMe` devuelve `sucursales_asignadas`, `sucursal_default_id`, `rubros_disponibles`, `rubro_default_id`
- [x] Submódulo `ADM_RUBROS` + 4 privilegios en `seguridad.seed-data.ts` — **no estaba en el plan original**: todo
      controller con `@RequireModule`/`@RequirePermission` necesita su entrada en el catálogo de seguridad.
      Se aplica solo al reiniciar el backend (`SeguridadSeedBootstrap`); hasta entonces los endpoints
      rechazan por permiso faltante.
- [x] ABM en el frontend: `RubrosDesign/` (lista + form con sucursales, color e icono), montado en
      **Configuración → Empresa → Rubros**
- [x] Tests: 23 en verde (10 del util + 13 del servicio). Cubren CRUD, scoping por `empresa_id`, baja lógica,
      reemplazo del set de sucursales, y `rubro_default_id` con 0 asignaciones / 1 rubro / 2 rubros / duplicados por caja.

### Fase 2 — Filtro de proveedores (backend + frontend) ✅
- [x] `rubro_id` opcional en `proveedores.service.ts` (`search`, `findAll`) y `compras.service.ts`
- [x] `PUT /v1/proveedores/:id/rubros` + `rubro_id` como query param en los tres endpoints de búsqueda
- [x] `src/store/RubroStore.jsx` (persistido en localStorage) + `_standards/RubroSelector.jsx` montado en `Layout.jsx`
- [x] `ProveedorSelector.jsx` envía `rubro_id` → cubre las 10 pantallas de una
- [x] Rubros en `ProveedorFormDialog.jsx` (multi-select) y chip por fila en `ProveedoresListConfig.jsx`
- [x] Tests: 56 en verde entre util, proveedores y compras

**Corrección de contrato durante la implementación**: los `where...PorRubro` devuelven el filtro envuelto
en `AND`, no en `OR` como decía el diseño original. Motivo: `proveedores.service` y `compras.service` ya
usan `OR` en el nivel de arriba para la búsqueda por texto (razón social / RUC), y un segundo `OR` lo
pisaba al hacer spread — habría roto la búsqueda por nombre en silencio. Hay un test que cubre
exactamente esa combinación.

### Fase 3 — Catálogo por rubro (backend + frontend) ✅
- [x] `categorias.rubro_id` en ABM (`CategoriasTab.jsx`), DTOs y endpoints de categorías
- [x] **`productos.rubro_id` como override** (migración `20260908_productos_rubro_override`)
- [x] `rubro_id` opcional en `productos.service.ts` + query param en el controller
- [x] Filtro por rubro (sigue el selector del header) y chip de rubro por fila en `ProductosTab.jsx`
- [x] Tests: override, herencia de categoría, producto sin categoría, y combinación con la búsqueda por texto

**La decisión #5 se revirtió con datos reales.** El diseño hacía que el producto heredara el rubro de su
categoría y descartaba un override por YAGNI. Agogo tiene **11.799 productos y 0 categorías**: la herencia
era un no-op para el caso que originó todo el plan. Se agregó `productos.rubro_id` (NULL = heredar de la
categoría, que sigue siendo el caso normal para las empresas que sí categorizan) y el override gana sobre
la categoría, tanto en el filtro como en el chip.

Nota: la captura de pantalla que motivó el pedido (PERECEDEROS / SALSAS / ENLATADOS, 72 productos) era de
**DOBA S.A.**, no de agogo. Vale la pena verificar contra la empresa real antes de asumir la forma del dato.

### Fase 4 — Corrección del contexto de usuario ✅
- [x] `getMe` completa `user.sucursal_id` desde las asignaciones del usuario

**El alcance era mayor que lo estimado**: no eran 2 archivos sino **14** los que leen `user?.sucursal_id`
(Compras, POS Retail, POS Admin, Inventario/Productos, Lotes, Presupuestos, Remisiones, Notas de Crédito,
Pedidos Mayoristas, Orden de Venta, Solicitud de Crédito, Reporte de Lotes, POS). Por eso se corrigió
completando el campo en el backend en vez de tocar los 14 call sites: un cambio, todos arreglados, y los
fallbacks `|| sucursales[0]` siguen cubriendo al usuario sin asignaciones.

⚠️ **Efecto colateral a mirar en la primera prueba**: `pages/POS.jsx:31` usa `enabled: !!user?.sucursal_id`
para `validarDepositoPrincipal`. Esa validación **nunca se ejecutó** hasta ahora. Al existir el campo, empieza
a correr: puede aparecer una advertencia de depósito principal que antes estaba silenciada.

### Fase 5 — Carga de datos de agogo ✅
- [x] Rubros creados: `REGALOS` → sucursal Agogo, `TALLER` → sucursal Taller 8 Center
- [x] **Proveedores**: 39 de 41 asignados derivando del historial de `compra_cab`. 0 ambiguos.
      Los 2 sin compras quedan compartidos.
- [x] **Todo en un solo script**: `scripts/migrate-access/setup-rubros-agogo.ts`. Crea los rubros y los liga
      a su sucursal (por `punto_establecimiento`), crea una categoría raíz por rubro, marca
      `productos.rubro_id` releyendo las mismas fuentes de la migración original, y asigna
      `proveedor_rubros` según el historial de compras. Idempotente, con `--dry`, `--reset`,
      `--solo=<paso>` y `--asignar-categoria`.
      Resultado: Regalería 10.009, Taller 1.784, 6 compartidos, **cobertura 99,9%**.

El script resuelve el prefijo `TALLER-` que aplicó `migrate-productos-taller` a los códigos que chocaban:
detectó exactamente 12, el mismo número que documenta el encabezado de aquel script. **Sólo el rubro cuya
migración aplicó el prefijo lo reclama**; reclamarlo desde los dos lados dejaba al producto original sin
dueño. Un código presente en ambas fuentes se desempata por **stock exclusivo** (36 casos): si sólo tiene
existencia en una sucursal, es de ese rubro. Sin evidencia queda **sin rubro** —visible en los dos— en vez
de adivinar.

**Dato a tener en cuenta**: las 165 compras registradas de agogo son **todas de Taller 8 Center**. Regalería
nunca usó el módulo de compras, así que sus proveedores no tienen historial del cual derivar el rubro y hay
que etiquetarlos a mano desde el ABM cuando haga falta.

### Verificación end-to-end (08/09/2026)

Con el backend recompilado y reiniciado, contra la base real:

| Prueba | Resultado |
|---|---|
| `/auth/me` usuario CAJA (sucursal Agogo) | rubro default = Regalería, `user.sucursal_id` presente |
| `/auth/me` usuario taller | rubro default = Taller |
| `GET /productos` sin rubro / Regalería / Taller | 11.799 / 10.051 / 1.790 |
| `GET /productos?busqueda=bujia` con rubro | 4 en Taller, 0 en Regalería |
| `GET /proveedores` sin rubro / Regalería / Taller | 41 / 2 / 41 |
| `GET /rubros` con cajera / con dueño | 403 / 200 (el permiso funciona) |
| Navegador (Playwright), usuario taller | "1790 productos en catálogo", chip "Taller" por fila, 0 errores de consola |
| Navegador, usuario agogo | "10051 productos en catálogo" |

### Fase 6 — Rubros permitidos por usuario + reporte de productos (15/09/2026)

Pedido: el usuario del taller veía "Todos los rubros" y Regalería en el selector, y el reporte
Productos y Servicios mostraba ventas de la regalería.

- [x] **Rubros permitidos** = alcance por sucursal llevado a rubros (`resolverRubrosPermitidos` en
      `rubros.util.ts`, `rubrosPermitidosUsuario` en `utils/alcance-rubro.ts`). Sin restricción (`null`) para
      acceso elevado, usuario sin asignaciones, o asignado a una sucursal sin rubro configurado (regla del
      vacío). Si no, los rubros de sus sucursales.
- [x] `/auth/me`: `rubros_disponibles` viene recortado a los permitidos y se agrega `rubros_todos`
      (puede elegir "Todos los rubros"). Con un solo rubro permitido, ese es el default.
- [x] `RubroSelector`: sin "Todos" para usuarios restringidos; con un solo rubro se muestra fijo (sin menú).
      `RubroStore.ajustarAPermitidos` corrige una elección guardada que ya no corresponde.
- [x] `GET /facturas/reporte/productos?rubro_id=`: el rubro pedido se valida con `rubrosAConsultar`; un
      usuario restringido queda recortado aunque no mande el parámetro. La pantalla envía el rubro del selector.

**Extendido el mismo día** a catálogo, proveedores, reportes y el inicio:

- [x] `rubrosFiltroUsuario` (utils/alcance-rubro.ts) valida el `rubro_id` en `GET /productos`,
      `GET /productos/search`, `GET /proveedores`, `GET /proveedores/search` y `GET /compras/proveedores/search`.
      `where...PorRubro` aceptan una lista (varios rubros permitidos) y `[]` no deja pasar nada.
- [x] Reportes de documentos: ya filtraban por sucursal (ventas, compras, rentabilidad, stock, lotes,
      remisiones, proveedor), que para un usuario restringido equivale a su rubro. Faltaban dos y se les agregó
      el alcance por sucursal: **Ventas por clientes** (`/facturas/reporte/clientes`: facturas por `dest`,
      recibos por `sucursal_id`) y el **dashboard de compras** (`/compras/dashboard`).
- [x] Inicio (`WelcomeDashboard`): el contador de productos manda el rubro del selector.

Con esto, para un usuario restringido el rubro deja de ser un filtro de conveniencia en todas esas pantallas.

**Rentabilidad re-verificada** (usuario taller veía "Por sucursal: Agogo"):

- Causa en los datos: las facturas `001-001-0000001/2` del 12/09 las emitió un superadmin **sin sucursal asignada**
  en la Caja 1 del taller. La numeración salió de la caja (001) pero el POS tomó el depósito de
  `sucursalActiva`, que caía en la primera sucursal de la lista → descontó 3 × ZF2358 de `DEP001` (Agogo).
- POS Retail y POS Admin: `sucursalActiva` ahora sigue a la sucursal de la caja abierta
  (`sucursalIdDeLaCaja` en `utils/rubros.js`).
- Rentabilidad agrupa "Por sucursal" por el establecimiento de la factura (`dest`), el mismo eje que el alcance;
  el depósito queda de respaldo. El filtro `sucursal_id` se cruza con el alcance (`filtroFiscalPorSucursal`).
- Reporte de Productos: además del rubro, aplica el alcance por sucursal (un producto del taller vendido en la
  regalería es venta de la regalería).

Pendiente / fuera de esto:
- ~~Los reportes de documentos **no siguen el selector** para un usuario sin restricción~~ → resuelto en Fase 7.
- Clientes no tienen rubro (el contador "Clientes" del inicio sigue siendo de la empresa).
- `GET /sync` (catálogo del POS offline) no se recorta en el backend: el POS lo filtra en el cliente.

Verificado contra la base: `taller` → sólo TALLER; `CAJA`/`agogo` → sólo REGALOS; superadmin → los dos + Todos.
Reporte de septiembre para `taller`: 4 ítems (antes 1.205), pidiendo REGALOS: 0.

---

## Fuera de alcance

- **Separación dura / control de acceso por rubro** (ver "Alcance de seguridad").
- **Clientes por rubro**: no se pidió. El modelo lo admitiría con una `cliente_rubros` análoga.
- **Override de rubro a nivel producto** (`productos.rubro_id`): YAGNI, ver decisión #5.
- **Reportes filtrados por rubro**: sólo Productos y Servicios (Fase 6). El resto sigue mostrando la empresa completa.
- **Herencia de rubro entre categorías padre/hija**: descartado a propósito, ver Riesgos.

### Fase 7 — El selector de rubro recorta todo (15/09/2026)

Pedido: un superadmin (o cualquier usuario con más de un rubro) elegía "Taller" en el sidebar y el Dashboard
y el Resumen de Ventas seguían mostrando la empresa entera. Para ellos el selector era sólo una comodidad de
catálogo; los 26 lugares que aplican `alcanceSucursalUsuario` no conocían el rubro elegido.

- [x] **Rubro por request**: el interceptor de axios (`api.config.js`) manda `x-rubro-id` con el rubro del
      `RubroStore` en todas las llamadas (sin rubro = "Todos"). `RubroSelector` invalida todas las queries al
      cambiar, así lo que está en pantalla se vuelve a pedir con el rubro nuevo.
- [x] Backend: `utils/rubro-context.ts` (middleware en `AppModule` + `AsyncLocalStorage`) deja el rubro del
      request disponible sin pasarlo por parámetro. `alcanceSucursalUsuario` lo aplica al final: valida que
      esté entre los permitidos del usuario (si no → alcance vacío, nada visible) y **recorta el alcance a las
      sucursales de ese rubro** (`sucursal_rubros`). Un superadmin con "Taller" pasa de `null` (todo) a
      `{ sucursalIds: [taller], puntosEstablecimiento: ['001'], depositoIds: [...] }`. Misma forma de siempre,
      así los 26 llamadores (listados, reportes, dashboards, inventario) lo respetan sin cambios.
- [x] `exigirAccesoElevado` (Libro IVA / liquidación) usa el alcance **sin** rubro: el libro es de la empresa y
      un superadmin con un rubro elegido tiene que poder sacarlo completo.
- [x] Bugs que salieron al verificar el dashboard, previos a esta fase (afectaban también a usuarios
      restringidos):
      - `/facturas/dashboard`: `buildWhereVentasValidas` pisaba `where.AND` (donde vive el alcance) → mostraba
        la empresa entera. Test `facturas/dashboard-where.spec.ts`.
      - `/presupuestos/dashboard`, `/cobros/cuentas-cobrar/dashboard` y `/tesoreria/movimientos` no aplicaban
        ningún alcance. Ahora sí (presupuestos y recibos por `sucursal_id`, facturas por `dest`, movimientos por
        la sucursal de la caja de la sesión).

Verificado en local con `taller` por HTTP: rubro Taller → 6 facturas de septiembre (antes 380, de toda la
empresa), 21 movimientos, cartera Gs. 79.699.680; rubro Regalería → todo en 0. Con el usuario superadmin de la
empresa (`2009839`) contra la base: sin rubro `null`, Taller → `001`, Regalería → `003`. Tests:
`utils/rubro-context.spec.ts`, `utils/alcance-sucursal.spec.ts`.

Pendiente: el contador "Clientes" del inicio sigue siendo de la empresa (los clientes no tienen rubro).
