# Plan: Cambio de Sucursal Activa por Sesión ("switch de sede")

**Estado:** IMPLEMENTADO y verificado local el 2026-07-29 (código + ~23 tests PHPUnit + verificación Playwright del selector real). Ver memoria [[project_branch_switch_feature]] para el detalle completo de qué se construyó, qué se desvió de este plan (el lock TTL de la §3/§5 se reemplazó por validación en el momento de guardar, más simple) y qué queda pendiente (activar en producción, cerrar cajas reales de Demo, deploy). Este documento queda como registro de la investigación original — la memoria tiene el estado real actualizado.

Historial: dos rondas de investigación (Codex, vista externa) + dos rondas de verificación con agentes (Claude, contra código y BD real) el 2026-07-23.

**Objetivo del feature:** permitir que el propietario del tenant (`id=1`, `isPrincipal()`) y administradores explícitamente autorizados cambien su "sucursal operativa" (almacén/serie/caja activos) sin recargar sesión completa ni afectar otros dispositivos/pestañas, mientras el resto de usuarios (vendedores, integradores, clientes) sigue fijo a su `establishment_id` de siempre.

---

## 1. Cómo se resuelve "sede" hoy (sin el feature)

No existe ningún mecanismo centralizado. Es lectura directa y dispersa de `auth()->user()->establishment_id` en ~15-20 puntos reales de decisión (no en los 245 archivos que solo reciben `establishment_id` como parámetro ya explícito):

- `app/Http/Controllers/Tenant/DocumentController.php:457,462,1675,1879` — arma dropdown de establishments y series filtrando por `$user->establishment_id`.
- `app/Http/Controllers/Tenant/PosController.php:64,217,418,1050,1439 (currentUser())` — `currentUser()` es literal `return auth()->user();`, no es un servicio de contexto.
- `app/Http/Controllers/Tenant/PurchaseController.php:123,1387-1388`
- `app/CoreFacturalo/Facturalo.php:1817` — `Warehouse::where('establishment_id', auth()->user()->establishment_id)->first()` (asume 1 almacén por establecimiento, cierto hoy en el 100% de los casos verificables).
- `app/CoreFacturalo/Requests/Inputs/Common/EstablishmentInput.php:12` — fallback `$establishment_id ?: optional(auth()->user())->establishment_id`.
- `app/Models/Tenant/Establishment.php:184-187` `getCurrentWarehouseId()` — `return $this->warehouse->id`.
- Frontend: `resources/js/store/state.js:22-31,42-55` (`config.establishment`, `config.warehouse_id`) se pueblan una sola vez al cargar la página, no se re-consultan tras un cambio; no hay módulo Vuex `auth` con `user.establishment_id`.
- `resources/js/views/tenant/documents/invoice.vue:1672,2810,2926-2929,3027,3034` — `changeEstablishment()` filtra client-side el array `all_series` por `establishment_id`.

**No hay `session()` de Laravel usado para esto en ningún lado** (grepeado en ambas direcciones, 0 resultados).

---

## 2. Mecanismo de switch que YA existe (solo móvil) — qué copiar y qué NO

- Tabla `user_mobile_establishments` (migración `database/migrations/tenant/2026_05_06_120000_tenant_create_user_mobile_establishments_table.php`): `user_id`, `establishment_id`, unique compuesto, FKs a `users`/`establishments`. Modelo `app/Models/Tenant/UserMobileEstablishment.php`.
- `App\Models\Tenant\User::mobileEstablishments()` (BelongsToMany, `User.php:523-531`) y `allowedMobileEstablishmentIds()` (`User.php:533-544`) — si el pivote está vacío, cae a `[establishment_id]` (un solo elemento).
- Endpoint: `modules/MobileApp/Http/Controllers/Api/V1/SessionController.php@updateEstablishment` (ruta `mobile/v1/session/establishment`), líneas 15-39.
  - **SÍ valida permiso** antes de dejar cambiar: `MobileEstablishmentAccess::canUse($user, $establishmentId)` (línea 23) — contradice la idea de que no hay gate.
  - **PERO escribe directo la columna global**: `SessionController.php:31-32` → `$user->establishment_id = $establishmentId; $user->save();` — mismo campo que usa la web. Cambiar en el móvil cambiaría también la web. **Este patrón de persistencia NO debe copiarse.**
  - Auto-alta en el pivote solo ocurre en login (`AuthController::login`, `modules/MobileApp/Http/Controllers/Api/V1/AuthController.php:39-44`) para el `establishment_id` actual del usuario — no existe UI en ningún lado (web o móvil) para otorgar sedes adicionales a nadie. **Es un gap universal, no específico de ningún tenant.**
- Flutter: `mobile/lib/src/features/settings/presentation/profile_screen.dart:112-149` `_switchEstablishment()` → `apiClientProvider.updateActiveEstablishment(...)`.

**Recomendación:** reutilizar `user_mobile_establishments` como pivote de permisos para el piloto (renombrar después vía migración a `user_establishments` si se quiere un nombre genérico); NO duplicar tabla web+móvil.

---

## 3. Arquitectura propuesta (validada, híbrida)

```
Usuario en BD
  └── establishment_id = sede base permanente (no se toca)

Sesión web (servidor, compartida entre pestañas del mismo navegador)
  ├── active_establishment_id
  ├── active_warehouse_id
  └── lock de contexto (ver §5, reusa patrón TTL de PosParkedSale, NO contador de versión)

Middleware / Servicio EstablishmentContext
  ├── valida autorización (pivote + isPrincipal())
  ├── carga sede/almacén activos desde sesión (si el flag está activo para el tenant), si no cae al establishment_id fijo de siempre
  ├── actualiza auth()->user()->establishment_id Y auth()->user()->establishment (relación precargada) en memoria para request actual — código heredado sigue funcionando sin tocarlo
  └── expone el contexto real a los ~15-20 puntos críticos listados en §1

Escrituras críticas (documentos, compras, POS, caja)
  └── validan explícitamente: contexto activo === document.establishment_id === series.establishment_id === warehouse.establishment_id
```

**Por qué híbrido y no una sola opción:**
- Cambiar `users.establishment_id` directo (como hace móvil hoy): afecta otros dispositivos/pestañas — descartado.
- Reemplazar las ~245 referencias una por una: excesivo, la mayoría ya reciben `establishment_id` explícito por parámetro, no hace falta tocarlas.
- Solo pisar el objeto en memoria (`auth()->user()`): ayuda al código heredado a "verse" correcto sin refactor masivo, pero NO protege contra payloads manipulados desde el cliente ni valida nada — por eso las escrituras críticas necesitan validación explícita server-side además del parche en memoria.

**Concurrencia entre pestañas — mecanismo concreto:** el codebase ya tiene un patrón probado de "lock con dueño + expiración" en `PosParkedSale` (`PosController.php:1219-1241`: columnas `locked_by_user_id` + `lock_expires_at`, `isLockActive()`, sweeper de locks vencidos). **Reusar exactamente ese patrón** para el contexto de sede en vez de inventar un contador de versión numérico (que no tiene precedente en el código): la operación transaccional adquiere el lock, valida que la sede activa coincide con la que tenía al cargar el formulario, guarda, libera. El cambio de sede intenta adquirir el mismo lock; si una venta lo tiene tomado, el cambio espera o la venta vieja recibe 409 al intentar guardar con sede ya cambiada.

Precedente adicional de locking encontrado (para las escrituras de stock): `InventoryTrait.php:367-369` ya hace "lock row primero, verificar stock antes de modificar" sobre `ItemWarehouse`; `SafeLotPivotRebuildService.php:70-91` bloquea filas de `items`, `item_warehouse`, `inventory_kardex`, `item_lots_group` en secuencia — mismo estilo a replicar.

---

## 4. Bugs activos encontrados — independientes del feature, corregir YA

### 4.1 `InventoryTrait::findWarehouse()` — crea almacenes silenciosamente y a veces ignora la sede del documento original

`modules/Inventory/Traits/InventoryTrait.php:296-316`:
```php
public function findWarehouse($establishment_id = null)
{
    if ($establishment_id) {
        $establishment = Establishment::find($establishment_id);
    } else {
        $user = auth()->user();
        $establishment = $user ? $user->establishment : Establishment::orderBy('id')->first();
    }
    if (!$establishment) {
        throw new Exception('findWarehouse: no establishment available...');
    }
    return Warehouse::firstOrCreate([
        'establishment_id' => $establishment->id
    ], [
        'description' => 'Almacén ' . $establishment->description
    ]);
}
```
34 call sites (sale, sale_note, order_note, purchase, void/anulación, dispatch, item creation — inventario completo en `InventoryKardexServiceProvider.php`, `InventoryVoidedServiceProvider.php`, `InventoryChangeServiceProvider.php`, `InventoryTrait.php` helpers internos).

**El caso general (`voided()`, línea 46) SÍ pasa bien el establishment del documento original** para ítems normales — sigue la convención correcta de "anulación usa almacén del documento".

**Pero hay un agujero real, no teórico:**
- `voidedDocumentItemSet()` (`InventoryTrait.php:619-633`) — usado por anulación de ítems tipo kit/set — llama `findWarehouse()` **sin argumento** (línea 629) aunque `$document` está disponible 2 líneas antes.
- `processIndividualDocumentItem()` (`InventoryTrait.php:774-794`) — usado por `document_item_delete()` (`InventoryKardexServiceProvider.php:766-782`) — mismo problema: cae a `findWarehouse()` sin argumento cuando `$document_item->warehouse_id` es null.
- Esto es real porque `warehouse_id` es null en TODO ítem tipo kit/set (siempre) y en filas legacy previas al refactor de "lotes per-warehouse".
- Consecuencia: si un admin anula/borra un ítem-kit de un documento de otra sede, el sistema escribe stock/kardex en SU almacén actual (no el del documento), y si esa sede no tiene almacén, **crea uno nuevo silenciosamente vía `firstOrCreate`**.

**Fix recomendado:** en ambos métodos, threadear `$document->establishment_id` explícitamente antes de llamar `findWarehouse()`, igual que ya hace `voided()` para ítems no-kit. Esto reduce el riesgo del switch de sede (que hará más frecuente el escenario de "admin anula documento de otra sede") pero es un bug ya explotable hoy sin el feature nuevo.

### 4.2 `Functions::findSeries()` — rama `series_id` no valida pertenencia

`app/CoreFacturalo/Requests/Web/Validation/Functions.php:95-107`:
```php
public static function findSeries($inputs)
{
    if (key_exists('series', $inputs)) {
        return Series::query()
            ->where('number', $inputs['series'])
            ->where('document_type_id', $inputs['document_type_id'])
            ->where('establishment_id', $inputs['establishment_id'])
            ->first();
    } else {
        if (!$inputs['series_id']) throw new Exception("La serie no existe");
        return Series::find($inputs['series_id']);
    }
}
```
La rama `number`+`document_type_id`+`establishment_id` valida bien. La rama `series_id` es un `Series::find()` desnudo — sin chequear `establishment_id` ni `document_type_id`. Un payload manipulado con `series_id` de otra sede pasaría sin problema.

**Fix recomendado:** unificar en una sola validación que siempre confirme: serie existe, está activa, pertenece al tipo documental, pertenece a la sede operativa — sin importar si llegó `series_id` o `number`.

### 4.3 `DocumentRequest.php` y `PurchaseRequest.php` — sin cruce de validación

- `app/Http/Requests/Tenant/DocumentRequest.php:28-30` — `establishment_id` es `required` pero nada lo cruza contra `series`/`warehouse`.
- `app/Http/Requests/Tenant/PurchaseRequest.php:14-35` — ni siquiera existe el campo `establishment_id` en las reglas, ni validación de `items.*.warehouse_id`.

**Fix recomendado:** agregar reglas que exijan `document.establishment_id === series.establishment_id === warehouse.establishment_id` para documentos, y validar `establishment_id`+`items.*.warehouse_id` pertenecen al tenant en compras.

---

## 5. Caja — riesgo específico que Codex identificó y quedó confirmado

`app/Models/Tenant/Cash.php` — tabla `cash` **no tiene `establishment_id` ni `warehouse_id`**. `scopeWhereActive` (líneas 163-169):
```php
public function scopeWhereActive($query)
{
    return $query->where([
        ['user_id', auth()->user()->id],
        ['state', true],
    ]);
}
```
Solo `user_id`+`state`. Escenario de riesgo real:
1. Admin abre caja en sede Bayóvar.
2. Sesión vence / cierra navegador.
3. Vuelve a loguear, sistema recupera su sede base (ej. Puente, si esa quedó como `establishment_id` fijo o como última activa).
4. Encuentra la MISMA caja abierta (porque el scope solo mira `user_id`), pero cree estar en Puente.
5. Puede registrar venta de Puente dentro de una caja que en realidad abrió en Bayóvar.

**Fix obligatorio antes de habilitar el switch:** agregar `establishment_id`+`warehouse_id` a `cash`. Al reabrir sesión con caja abierta, recuperar automáticamente ESA sede como contexto activo y prohibir cambiar hasta cerrar la caja (no solo "bloquear el selector mientras hay caja abierta" — también hay que anclar el contexto al reloguear). También agregar transacción con lock para impedir abrir dos cajas simultáneas del mismo usuario (mismo estilo lock que `PosParkedSale`).

`PosParkedSale` SÍ tiene `establishment_id`+`cash_id` (migración `2026_03_09_000010_create_pos_parked_sales_table.php:18-19`) y ya valida en lectura (`PosController.php:1190-1198` `assertParkedSaleBelongsToCurrentCash()` — 404 si no coincide). Riesgo real ahí: al cambiar de sede a mitad, ventas aparcadas de la sede anterior quedan huérfanas/invisibles (no se corrompen ni se filtran cruzadas, mejor caso que caja). **Bloquear el switch si hay parked sales pendientes de la sede activa.**

---

## 6. Escala real y modelo de roles

- Grep real: **245 archivos** referencian `establishment_id` (`app/`=106, `modules/`=139) — más del doble de lo estimado inicialmente (~97), pero la superficie de "resolución de contexto" real (no solo recepción de parámetro) es de ~15-20 puntos, listados en §1.
- `app/Models/Tenant/User.php:1249-1252` — `isPrincipal()` ya existe: `return $this->id === 1 && $this->type === 'admin';` — usado para dar permisos totales implícitos al dueño del tenant.
- `isAdmin()` (`User.php:815-818`) — cualquier `type==='admin'`, no solo el id=1.
- Enum real de `users.type` (migración `2019_04_30_140509_tenant_add_type_to_users.php:18`): **solo `admin`/`seller`** (no existe `'client'` real en BD, aunque hay una rama muerta que lo menciona en `User::getCollectionData()`).
- Provisión del tenant: `app/Http/Controllers/System/ClientController.php@store()/tables()` — el primer insert en la tabla `users` recién migrada del tenant nuevo, garantiza `id=1` para el owner creado por el superadmin.
- **No existe pivote web de multi-sede** — solo el de móvil (`user_mobile_establishments`), candidato a reusar/renombrar (§2).
- Patrón de gating ya usado en el código: `abort_unless($user->isAdmin(), 403, ...)` server-side (ej. `CashMovementController.php:109,112,198,233`), flag `is_admin` agregado al payload JSON para el frontend, `v-if="user.type === 'admin'"` en Vue (`login/index.vue:11`, `users/form1.vue`).

---

## 7. Feature flag: mecanismo per-tenant a reusar (confirmado, ya existe)

Todos los flags per-tenant existentes (`is_veterinary`, `enable_atc_code`, `enable_editable_sale_price`, `active_warehouse_prices_per_presentation`) siguen el mismo patrón — **`branch_switch_enabled` debe seguir exactamente este, no inventar nada nuevo:**

1. Columna boolean en tabla `configurations` (BD del tenant), migración con `Schema::hasColumn(...)` guard, default `false` — ejemplo real: `database/migrations/tenant/2026_06_22_130000_add_is_veterinary_to_configurations.php`.
2. Modelo `App\Models\Tenant\Configuration` — agregar a `$fillable` (~línea 158-339) y a `getCollectionData()` (~línea 409-479, serializa a JSON para Vue) + accessor tipo `isBranchSwitchEnabled()` (~línea 787).
3. Backend lee con `Configuration::first()->isBranchSwitchEnabled()`; ejemplo de gate de middleware ya existente: `app/Http/Middleware/EnsureVeterinaryModule.php:29-33`.
4. Frontend lee `this.configuration.branch_switch_enabled` desde el objeto de config cargado al boot (ejemplo: `resources/js/views/tenant/pos/index.vue:2291`).
5. Toggle vía UI admin ya existente: `resources/js/views/tenant/configurations/form.vue` (`<el-switch v-model="form.is_veterinary">` línea 255 y análogos) → `ConfigurationController.php` (`getConfigurationModel()` línea 41).

**Cero deploy nuevo por tenant** — solo prender el switch en el panel de configuración de Demo, luego Cuidarte.

Flags globales (`STRICT_FEFO`, `API_STRICT_AUTH`) usan `env()` directo (ej. `InventoryTrait.php:93`) — **NO seguir ese patrón**, no permite scoping per-tenant.

---

## 8. Gate temporal del switch móvil durante el piloto (diseño confirmado, riesgo mínimo)

No conviene habilitar simultáneamente el selector web y el switch móvil actual para el mismo admin multi-sede (móvil sigue escribiendo `users.establishment_id` global). Diseño de bloqueo:

En `SessionController.php`, antes de `canUse()` (línea 22-29), agregar:
```php
if ($user->isAdmin()
    && count($user->allowedMobileEstablishmentIds()) > 1
    && (bool) optional(\App\Models\Tenant\Configuration::first())->branch_switch_enabled) {
    return $this->errorResponse(
        'El cambio de sede desde la app está deshabilitado temporalmente para administradores con acceso a varias sedes. Use la web.',
        ['establishment_id' => ['branch_switch_web_only']],
        409
    );
}
```
- No pone la lógica dentro de `MobileEstablishmentAccess` (helper puro reusado por otros call sites) — el gate va en el controller.
- Usuarios de una sola sede y vendedores: la condición nunca se cumple, cero impacto.
- Flutter YA maneja cualquier respuesta no-200 genérica como toast (`profile_screen.dart:119-125` + `api_client.dart` rama genérica de `_requestMap`) — **usar 409 específicamente** (no 401/419, que fuerzan logout; no 5xx). **Cero cambios necesarios en Flutter.**

---

## 9. Listados — qué cambia y qué no (verificado módulo por módulo)

| Pantalla | Comportamiento hoy verificado | Acción propuesta |
|---|---|---|
| `/inventory` | Muestra TODAS las sucursales siempre, sin selector/dropdown de sede en absoluto (`modules/Inventory/Http/Controllers/InventoryController.php` — sin filtro por warehouse en la query). Cada fila trae su `warehouse_id`. | Mostrar por defecto la sede activa; permitir "Todas"; en "Todas" solo habilitar ingreso/salida/ajuste en filas de la sede activa, mostrar "Cambiar a esta sucursal" para filas de otra sede. |
| `/items` (Productos) | **Corregido 2026-07-29** (revisión independiente de Codex): el patrón "rol-based" del commit `a76d2a26` (`app/Support/WarehouseScopeAccess.php`, ya retirado) dejó de considerarse correcto — cualquier `type==='admin'` veía TODAS las sedes del tenant sin importar cuántas tuviera realmente asignadas, y "estar en la sede matriz" (dato de ubicación para el catálogo maestro de precios, sin relación con autorización) funcionaba como si fuera un permiso de seguridad. Reemplazado por `EstablishmentContext::viewableWarehouseIds()`/`viewableEstablishmentIds()` (dueño=todas, admin=solo las asignadas en `user_mobile_establishments`, con whitelist real validada también en el backend, no solo en las opciones del filtro). | Ya no es el patrón de referencia por rol — el patrón de referencia ahora es `EstablishmentContext` como único punto de verdad, ver fila de Kardex/Lotes/Reportes. |
| Kardex | **Corregido 2026-07-29**: `ReportKardexController` decidía "Todos"/acceso por `$user->type==='admin'` (options list Y el endpoint `records()` real detrás de la grilla, que no tenía NINGÚN chequeo de pertenencia). Ahora usa `EstablishmentContext::viewableWarehouseIds()` en `filter()`, `getRecords()`/`data()`, `getData()` (pdf/excel) y `warehousesForItem()` — "Todos" = solo las autorizadas, `warehouse_id` ajeno enviado a mano → 403. | Cerrado. |
| Lotes (`item_lots_group_warehouse`) | **Corregido 2026-07-29**: `resolveWarehouseId()` aceptaba cualquier `warehouse_id` de un admin sin validar pertenencia. Ahora valida contra `EstablishmentContext::viewableWarehouseIds()` (403 si no pertenece) y "Todas" filtra solo las autorizadas en `records()`/`summary()`. | Cerrado. |
| Precios por sucursal | `resources/js/views/tenant/items/branch_pricing.vue` — grid comparativo intencional de TODAS las sedes lado a lado (cada columna = 1 `warehouse_id`). | Sin cambios, no es candidato. |
| Compras | `PurchaseController.php:135` ya devuelve `Warehouse::get()` (todas) al form de creación; `distributeToBranches()` (líneas 289-319) ya existe para redistribuir explícito. | Recepción normal usa almacén activo por defecto; distribución sigue siendo acción explícita. Sin refactor grande. |
| Traslados | `modules/Inventory/Models/InventoryTransfer.php:20-21,57-58` — `warehouse_id`/`warehouse_destination_id` explícitos, **sin columna `establishment_id`** en la tabla. | Inmune al switch, sin cambios. |
| Series (backend) | `Functions::findSeries()` ya toma `establishment_id`/`series_id` del payload, no siempre re-deriva de `Auth::user()` al escribir (ver bug de la rama `series_id`, §4.2). | Frontend debe mandar la sede activa de sesión en el payload; backend ya lo soporta salvo el fix de §4.2. |

---

## 10. Datos de producción citados por Codex — NO VERIFICADOS, requieren confirmación real

Intento de verificación read-only desde esta máquina de desarrollo: **sin acceso** a la base de datos de producción (vive en contenedor Docker del server `5.161.118.178`, solo alcanzable desde adentro; no hay túnel SSH activo desde este equipo). El registro central local (`enterfarmaplus.websites`/`hostnames`) solo tiene 2 tenants de prueba con dominios `.test` — no refleja producción en absoluto.

**Cifras que quedan pendientes de confirmar contra el server real antes de actuar sobre ellas:**
- 19 tenants totales.
- Solo Demo y Cuidarte con más de una sucursal.
- Todos los establecimientos con exactamente 1 almacén (sin duplicados).
- Cuidarte: 2 sucursales, 2 admins, 0 filas en `user_mobile_establishments`.
- Demo: 2 sucursales, 1 admin, 1 fila en el pivote, **6 cajas abiertas antiguas**.
- Demo sin ventas POS aparcadas actualmente.
- 13 ventas aparcadas en otro tenant de una sola sede.

**Antes de "cerrar las 6 cajas antiguas de Demo" o cualquier acción sobre estos datos, hay que confirmar los números reales conectando desde el server o pidiendo que se corra el conteo ahí.**

---

## 11. Plan de implementación (orden recomendado)

1. **Verificar cifras reales de producción** (server, no esta máquina) — bloqueante para decidir alcance del piloto.
2. Fix independiente (bug activo hoy, sin depender del feature): `voidedDocumentItemSet()`/`processIndividualDocumentItem()` deben recibir `$document->establishment_id` explícito (§4.1).
3. Fix independiente: `Functions::findSeries()` rama `series_id` debe validar `establishment_id`+`document_type_id` (§4.2).
4. `Cash`: agregar `establishment_id`+`warehouse_id`; recuperar contexto automáticamente al reloguear con caja abierta; lock transaccional contra doble apertura (§5).
5. Migración + modelo: `Configuration.branch_switch_enabled` siguiendo patrón existente (§7).
6. Servicio `EstablishmentContext` + middleware — resuelve sede activa desde sesión (si flag activo) o cae al `establishment_id` fijo; parchea `auth()->user()` en memoria para el request actual.
7. Lock de contexto reusando el patrón TTL de `PosParkedSale` (§3) — NO contador de versión.
8. Cross-validar `DocumentRequest`/`PurchaseRequest` (establishment↔series↔warehouse, §4.3).
9. Gate temporal del switch móvil (409, §8) — solo si el flag está activo para el tenant.
10. Apuntar Kardex/Lotes (§9) al `EstablishmentContext` nuevo.
11. Selector en encabezado + confirmación + auditoría (usuario, origen, destino, fecha, IP).
12. Activar `branch_switch_enabled` SOLO en Demo.
13. Cerrar cajas antiguas de Demo (tras confirmar cifras reales, punto 1), usar Sucursal 2 existente (no crear una nueva salvo que también se quiera probar alta de establecimiento).
14. Probar matriz completa: compra, venta, POS+caja, anulación (incluyendo ítem kit/set), devolución, traslado, lote, Kardex, reportes, 2 pestañas simultáneas, sesión expirada con caja abierta, payload manipulado (series_id ajeno), móvil bloqueado.
15. Verificar cero diferencias en stock/lote/Kardex tras cada prueba.
16. Recién entonces, preparar piloto Cuidarte (requiere además llenar el pivote de sedes permitidas para sus 2 admins).

---

## 12. Referencia rápida de archivos clave

**Resolución de contexto (a centralizar):**
`app/Http/Controllers/Tenant/DocumentController.php`, `PosController.php` (`currentUser()`), `PurchaseController.php`, `QuotationController.php`, `app/CoreFacturalo/Facturalo.php`, `app/CoreFacturalo/Requests/Inputs/Common/EstablishmentInput.php`, `app/Models/Tenant/Establishment.php` (`getCurrentWarehouseId`).

**Permisos/roles:** `app/Models/Tenant/User.php` (`isPrincipal()`, `isAdmin()`, `allowedMobileEstablishmentIds()`), `app/Models/Tenant/UserMobileEstablishment.php`.

**Caja/POS:** `app/Models/Tenant/Cash.php`, `app/Models/Tenant/PosParkedSale.php`, `app/Http/Controllers/Tenant/PosController.php` (locks TTL líneas 1219-1241, 823-999).

**Validación a endurecer:** `app/Http/Requests/Tenant/DocumentRequest.php`, `app/Http/Requests/Tenant/PurchaseRequest.php`, `app/CoreFacturalo/Requests/Web/Validation/Functions.php` (`findSeries`).

**Bug activo a corregir:** `modules/Inventory/Traits/InventoryTrait.php` (`findWarehouse:296`, `voidedDocumentItemSet:619`, `processIndividualDocumentItem:774`).

**Flags:** `app/Models/Tenant/Configuration.php`, `app/Http/Controllers/Tenant/ConfigurationController.php`, `resources/js/views/tenant/configurations/form.vue`, ejemplo de gate: `app/Http/Middleware/EnsureVeterinaryModule.php`.

**Móvil:** `modules/MobileApp/Http/Controllers/Api/V1/SessionController.php`, `modules/MobileApp/Support/MobileEstablishmentAccess.php`, `modules/MobileApp/Http/Controllers/Api/V1/AuthController.php`, `mobile/lib/src/features/settings/presentation/profile_screen.dart`.

**Corregido 2026-07-29 (revisión independiente de Codex):** el patrón rol-based (`app/Support/WarehouseScopeAccess.php`, ahora retirado) dejó de considerarse correcto — no distinguía "es admin" de "tiene realmente esa sede asignada", y el chequeo de sede matriz funcionaba como bypass de seguridad. Único punto de verdad ahora: `App\Services\EstablishmentContext::viewableEstablishmentIds()` / `viewableWarehouseIds()` / `canViewMultipleEstablishments()`, aplicado con whitelist real (rechaza 403 un id enviado a mano fuera de lo autorizado, no solo oculta la opción en el frontend) en: `resources/js/views/tenant/items/index.vue` + `app/Http/Controllers/Tenant/ItemController.php`, `modules/Inventory/Http/Controllers/ReportKardexController.php`, `modules/Inventory/Http/Controllers/LotsGroupController.php`, y `modules/Report/Traits/ReportTrait.php` (choke point compartido por los ~22 controllers de `modules/Report`).
