Skip to content

E4 — Múltiples cajas por sucursal: diseño técnico

Extiende doc/plans/2026-07-31-e4-modulo-caja-diseno-tecnico.md — esa versión asumía una caja por sucursal (cash_boxes.station_id con unique()). Pedido de Carlos (2026-08-04): una sucursal puede tener varias cajas físicas operando en paralelo. Decisiones tomadas en conversación antes de este diseño:

  1. Cajas numeradas automático ("Caja 1", "Caja 2"...), sin nombre custom — el Admin agrega con un botón "+ agregar caja", no las nombra a mano.
  2. El Backoffice elige explícitamente en qué caja entrar cada vez que abre la pantalla "Caja" (si hay más de una disponible) — sin recordar la última usada entre sesiones.
  3. El pago desde "Pago" descuenta de la caja que el operador abrió a su propio nombre (se deriva de la CashSession OPEN con opened_by_user_id = el usuario actual, sin campo nuevo) — asume una caja abierta por persona a la vez; cambio de turno = cerrar y volver a abrir.
  4. El reporte de Cajas en Reportes muestra resumen (una fila por caja) + detalle (movimientos completos), ambos exportables.

⚠️ Parcialmente desactualizado (2026-08-05). Las 4 decisiones de arriba siguen vigentes. Lo que cambió tras la revisión con el cliente: §2.3 applyCashBoxDiscount deja de exigir TC vigente (los países que pagan en USD descuentan el monto nominal) y deja de estar precedido por la validación de prefondeo. Ver ADR-007 y 2026-08-05-e4b-ajustes-caja-reunion-cliente.md.

1. Modelo de datos

1.1 cash_boxes (ALTER)

- DROP unique(station_id)   -- ya no es 1:1
+ ADD number integer NOT NULL DEFAULT 1   -- "Caja {number}" dentro de esa sucursal
+ ADD UNIQUE (station_id, number)

Backfill: las filas existentes (creadas bajo el modelo 1:1) quedan con number = 1 — es exactamente lo que ya son ("la caja" de esa sucursal, ahora explícitamente "Caja 1").

1.2 Resto de las tablas (cash_sessions, cash_movements, exchange_rates)

Sin cambios — ya cuelgan de cash_box_id, no de station_id. El modelo de ledger inmutable (ADR-005) no necesita tocarse: cada caja ya es una entidad independiente con su propio historial, solo que hasta ahora nunca había dos por sucursal.

2. Backend

2.1 CashBoxService

php
// Reemplaza el criterio de "una por estación" — ahora crea la #1 si la
// sucursal todavía no tiene ninguna (mismo trigger que hoy: alta de
// sucursal en país con modulo_caja, o activación del flag).
public function ensureCashBoxForStation(Station $station): CashBox
{
    return CashBox::firstOrCreate(
        ['station_id' => $station->id, 'number' => 1],
    );
}

// Nuevo — "+ agregar caja" desde ABM Estaciones. Numeración secuencial,
// nunca reutiliza números de cajas borradas (no hay borrado en este alcance).
public function addCashBoxForStation(Station $station): CashBox
{
    $nextNumber = CashBox::where('station_id', $station->id)->max('number') + 1;

    return CashBox::create(['station_id' => $station->id, 'number' => $nextNumber]);
}

balance() no cambia (ya opera sobre un CashBox puntual).

2.2 CashBoxController

  • GET /caja/disponibles (reemplaza el rol de mi-sucursal como entry point de la pantalla Caja — mi-sucursal se elimina, nada más la usaba): devuelve todas las cajas de todas las sucursales a las que el usuario tiene caja:read (mismo scoping que ya existe vía PermissionService::allowedStationIds), con saldo y estado de sesión de cada una:
    json
    { "data": [
      { "cash_box_id": "...", "number": 1, "station": {"id":"...","name":"Central"}, "balance": 500.00, "session": {...} | null },
      { "cash_box_id": "...", "number": 2, "station": {"id":"...","name":"Central"}, "balance": 0.00, "session": null }
    ]}
    El resto de los endpoints (apertura, cierre, fondeo, ajuste, movimientos, reporte) no cambian de firma — ya reciben {cashBox} por ruta, siempre operaron sobre una caja puntual. Solo cambia cómo el frontend llega a elegir cuál.
  • resolveActiveStation() se borra (ya no tiene sentido resolver "la" sucursal activa — la pantalla ahora lista todas las cajas accesibles, sin intentar adivinar una).

2.3 TransactionController::applyCashBoxDiscount (el cambio más delicado — plata real)

Hoy: CashBox::where('station_id', $resolvedStationId)->first() — asumía una sola caja posible. Con varias cajas por sucursal, de cuál sale la plata no se puede derivar del PDV, se deriva de quién está pagando:

php
$openSession = CashSession::where('status', 'OPEN')
    ->where('opened_by_user_id', $user->id)
    ->lockForUpdate()
    ->first();

if (! $openSession) {
    return response()->json([
        'code' => 'NO_CASH_BOX_OPEN_FOR_USER',
        'message' => 'No tenés ninguna caja abierta a tu nombre — abrí una desde la pantalla Caja antes de pagar.',
    ], 422);
}

$cashBox = $openSession->cashBox;
// resto del método IGUAL que hoy (TC vigente, cálculo de amount_local, etc.)
// — ya no hace falta resolver $cashBox por station_id, se usa directo.

Nota: esto desacopla "a qué PDV se atribuye la transacción" ($resolvedStationId, sigue calculándose igual, sin cambios) de "qué caja física paga" — son dos cosas distintas ahora. No se valida que coincidan (un operador con acceso a 2 sucursales podría, en teoría, tener abierta la caja de una sucursal y pagar una transacción atribuida a otra) — no pedido, no se agrega esa validación cruzada; si aparece como problema real en el uso, es un ajuste chico después.

CASH_BOX_NOT_FOUND y CASH_SESSION_CLOSED (los códigos de error viejos de este método) se reemplazan por NO_CASH_BOX_OPEN_FOR_USER — ya no hay un "PDV resuelto → su caja" que pueda fallar en esos dos pasos por separado, es un solo chequeo.

2.4 ABM Estaciones — alta de cajas

POST /admin/estaciones/{station}/cajas — gate abm_estaciones:write (Admin, mismo criterio que el resto de ABM Estaciones). Llama a CashBoxService::addCashBoxForStation(), audita CASH_BOX_ADDED. GET /admin/estaciones/{station}/cajas — gate abm_estaciones:read, lista las cajas de esa sucursal (número, fecha de alta) para mostrar en el detalle del PDV.

2.5 Reporte de Cajas (ReportesController o controller nuevo CajaReportesController)

Mismo país-scoping y permiso (reportes) que Estado de Situación/Volumen.

GET /reportes/cajas — resumen: por país (+ filtro opcional de PDV, igual criterio que Estado de Situación), una fila por caja:

station, number, balance (actual, vía CashBoxService), session_status (OPEN/CLOSED),
totals_del_periodo: { fondeo, ajuste, pago_count, pago_total }

totals_del_periodo = SUM(amount_local) de cash_movements agrupado por cash_box_id y type, filtrado por rango de fecha (con hora, mismo criterio que el reporte por-caja de E4 anterior) — no por created_at de la sesión, por movimiento individual (una caja puede tener varias sesiones en el rango).

GET /reportes/cajas/detalle — mismos filtros (país, PDV opcional, rango), devuelve el log completo de movimientos de todas las cajas que matchean, ordenado por fecha — generaliza CashBoxController::reporte() (hoy atado a una sola {cashBox}) a un filtro por país/PDV. Reusar ReportExportService para exports xlsx/pdf de ambos (resumen y detalle) — mismo membrete que el resto de Reportes/Auditoría/Caja.

3. Frontend

3.1 CajaView.vue — selector de caja

Reemplaza la carga automática de "mi sucursal" por:

  1. cajaApi.disponibles() al montar.
  2. Si data.length === 0 → mensaje actual ("no se pudo determinar...").
  3. Si data.length === 1 → auto-selecciona esa caja, va directo a la vista operativa de siempre (preserva el comportamiento actual para el caso común de una sola caja — no agrega fricción donde no hace falta).
  4. Si data.length > 1 → pantalla de selección (lista de tarjetas: sucursal + "Caja N" + saldo + estado abierta/cerrada) — clickear una lleva a la vista operativa de esa caja específica. Sin persistencia entre sesiones (decisión #2) — cada vez que se entra a "Caja" con más de una disponible, se vuelve a elegir. Botón "Cambiar de caja" visible en la vista operativa para volver al selector sin recargar la página.

El resto de CajaView.vue (apertura, cierre, movimiento manual, reporte, tabla de movimientos) no cambia de lógica — ya opera sobre un cashBoxId puntual, solo que ahora ese id sale del selector en vez de mi-sucursal.

3.2 ABM Estaciones — gestión de cajas

En EstacionesView.vue, cada fila de sucursal (con modulo_caja activo en su país) muestra la cantidad de cajas y un botón "+ agregar caja" (o una sub-lista expandible) — llama a POST /admin/estaciones/{id}/cajas. Alcance mínimo: no hace falta nombrarlas ni desactivarlas (no pedido), solo ver cuántas hay y agregar.

3.3 Reportes → tab "Cajas"

Nueva tab en el TabbedSectionLayout de Reportes (/reportes/cajas), mismo patrón visual que Estado de Situación: selector de país (+ PDV opcional) y rango de fecha con hora, tabla resumen (una fila por caja) con export, y una sección/tab interna de detalle con el log completo (paginado, igual criterio que las otras tablas de Reportes) + su propio export.

4. Tests obligatorios

  • ensureCashBoxForStation es idempotente para la caja #1 (no duplica si ya existe).
  • addCashBoxForStation numera secuencial (1, 2, 3...), incluso si se llama para sucursales distintas en paralelo (números independientes por sucursal).
  • GET /caja/disponibles devuelve todas las cajas del alcance del usuario, de todas las sucursales asignadas — no solo una.
  • Pago: con 2 cajas abiertas en la misma sucursal por 2 usuarios distintos, cada pago descuenta de la caja que abrió ese usuario, nunca la del otro (test explícito, es el caso central de este cambio).
  • Pago sin ninguna caja abierta a nombre del usuario → 422 NO_CASH_BOX_OPEN_FOR_USER, aunque la sucursal tenga otras cajas abiertas por otros usuarios.
  • Reporte de Cajas: el resumen no mezcla totales entre cajas de la misma sucursal (cada fila es una caja); el detalle incluye movimientos de todas las cajas que matchean el filtro.
  • Regresión: todo el flujo actual (una sola caja por sucursal, que es como está sembrado todo el dev/test data hoy) sigue funcionando exactamente igual — ninguno de los 200 tests existentes de E4 debería requerir cambios de comportamiento, solo los que tocan directamente applyCashBoxDiscount/mi-sucursal por el cambio de endpoint.

Documentación viva — se actualiza junto con el código, no es un anexo aparte.