Estado: DISEÑO APROBADO CON MODIFICACIÓN ARQUITECTÓNICA — MODELO FINAL
Fecha: 2026-08-20
Sprint anterior: 0.2-C POS directo APROBADO
Modificación aprobada: La venta vinculada a sesión debe existir en estado abierta desde el primer consumo, no crearse únicamente al finalizar.
Verificado directamente contra Supabase:
| Constraint | Estado real |
|---|---|
ventas.estado CHECK |
'abierta', 'cerrada', 'anulada' ✅ |
venta_items.tipo CHECK |
'tiempo', 'producto' ✅ |
UNIQUE(venta_id, line_no) |
Enforced ✅ |
ventas.sesion_id FK→sesiones(id) |
Enforced ✅ |
ventas.sesion_id UNIQUE |
Enforced (1:1) ✅ |
sesiones.estado |
'activa', 'pausada', 'finalizada', 'cancelada' |
sesiones.finalizada BOOLEAN |
Se conserva |
sesiones.productos JSONB |
Se conserva como cache |
Confirmado: estado='abierta' es válido en ventas. No requiere migración de schema.
| # | Flujo | Archivo | Problema clave |
|---|---|---|---|
| 1 | Agregar producto a sesión | useSalas.js | READ-MODIFY-WRITE stock, no crea venta ni items |
| 2 | Agregar múltiples productos | useSalas.js | Race condition paralela |
| 3 | Finalizar sesión | useSalas.js | No crea venta_items, no atómico, doble finalización posible |
| 4 | Anular sesión | useSalas.js | No devuelve stock |
| 5 | Editar sesión admin | useSalas.js | Sin rol, no atómico, no actualiza venta |
| 6 | Eliminar venta | Ventas.jsx | DELETE físico, pierde auditoría |
| 7 | Editar venta | Ventas.jsx | READ-MODIFY-WRITE, sincroniza sesión manual |
| 8 | _registrarVentaContable | useSalas.js | No crea items, reintento FK manual |
sesiones
├── id UUID PK
├── estado VARCHAR ('activa','pausada','finalizada','cancelada')
├── finalizada BOOLEAN
├── productos JSONB DEFAULT '[]' ← cache operativo
├── total_productos DECIMAL
├── total_general DECIMAL
├── metodo_pago VARCHAR
├── monto_* NUMERIC
└── (sin FK a ventas)
ventas
├── id UUID PK
├── sesion_id UUID UNIQUE FK→sesiones ← 1:1
├── estado VARCHAR ('abierta','cerrada','anulada')
├── subtotal_tiempo DECIMAL
├── subtotal_productos DECIMAL
├── total DECIMAL
├── metodo_pago VARCHAR
├── monto_* NUMERIC
└── idempotency_key TEXT
venta_items
├── id UUID PK
├── venta_id UUID FK→ventas CASCADE
├── line_no INT NOT NULL
├── tipo VARCHAR ('tiempo','producto')
├── producto_id UUID FK→productos
├── cantidad NUMERIC
├── precio_unitario NUMERIC
├── subtotal NUMERIC
└── UNIQUE(venta_id, line_no)
movimientos_stock
├── referencia TEXT ← será venta_id (uniforme)
├── tipo VARCHAR ('venta','entrada','salida','devolucion','ajuste','merma')
└── usuario_id UUID
| Dato | En sesión | En venta | En venta_items |
|---|---|---|---|
| Productos | ✅ JSONB | ❌ | ❌ no se crean |
| Total productos | ✅ | ✅ | — |
| Total general | ✅ | ✅ | — |
| Método pago | ✅ | ✅ | — |
SESIÓN (activa)
│
└── VENTA ABIERTA (estado='abierta') ← creada al primer consumo
│
├── venta_items tipo='producto' ← creados al agregar productos
├── stock descontado ← al agregar productos
├── movimientos_stock ← referencia=venta_id
│
└── FINALIZAR SESIÓN
├── venta_item tipo='tiempo' agregado
├── venta estado='cerrada'
├── sesión estado='finalizada'
└── totales calculados
1. Sesión activa, sin venta
↓
2. Primer producto agregado
→ crear venta (estado='abierta', sesion_id=X)
→ crear venta_item (tipo='producto')
→ descontar stock + movimiento (referencia=venta_id)
→ actualizar sesión.productos (cache)
↓
3. Productos adicionales
→ recuperar venta abierta (mismo sesion_id)
→ agregar venta_items
→ descontar stock + movimientos
→ actualizar cache
↓
4. Finalizar sesión
→ agregar venta_item tipo='tiempo'
→ actualizar venta: estado='cerrada', totales, método pago
→ actualizar sesión: estado='finalizada', finalizada=true
→ NO descontar stock (ya descontado)
↓
5a. Devolver/anular venta
→ venta estado='anulada', total=0
→ devolver stock + movimientos 'devolucion'
→ si venta.sesion_id NOT NULL → sesión estado='cancelada'
↓
5b. Editar venta
→ calcular diferencia items
→ ajustar stock (devolver/descontar)
→ reemplazar venta_items
→ actualizar venta + sesión (cache)
| Estado | Significado | Transiciones |
|---|---|---|
abierta |
Venta de sesión activa, productos agregándose | → cerrada (finalizar) o anulada (devolver) |
cerrada |
Sesión finalizada, venta completa | → anulada (devolver) |
anulada |
Venta cancelada, stock devuelto | terminal |
| Dato | Fuente de verdad | Cache |
|---|---|---|
| Productos consumidos | venta_items (tipo=’producto’) |
sesiones.productos JSONB |
| Total productos | ventas.subtotal_productos |
sesiones.total_productos |
| Total general | ventas.total |
sesiones.total_general |
| Método pago | ventas.metodo_pago |
sesiones.metodo_pago |
| Stock | productos.stock |
— |
| Trazabilidad | movimientos_stock.referencia = venta_id |
— |
Al agregar producto (como hoy). La venta abierta registra los items pero el stock se descuenta en ese momento, no al finalizar. finalizar_sesion NO toca stock.
agregar_productos_sesionPropósito: Agregar productos a sesión activa. Crea/recupera venta abierta, crea venta_items, descuenta stock atómicamente.
CREATE OR REPLACE FUNCTION agregar_productos_sesion(
p_sesion_id UUID,
p_items JSONB, -- [{ producto_id, cantidad }]
p_idempotency_key TEXT DEFAULT NULL
) RETURNS TABLE(
status TEXT,
venta_id UUID,
sesion_id UUID,
items_agregados INT,
total_productos NUMERIC,
mensaje TEXT
)
Lógica:
auth.uid() not null → resolver usuario_id_realSELECT * FROM sesiones WHERE id=p_sesion_id FOR UPDATEsesion.estado = 'activa' AND sesion.finalizada = false
→ si no: ERROR_SESION_NO_ACTIVASELECT * FROM ventas WHERE sesion_id=p_sesion_id AND estado='abierta'INSERT INTO ventas (sesion_id, sala_id, usuario_id, cliente, estacion, fecha_inicio, estado='abierta', metodo_pago='efectivo', total=0)p_items:
aplicar_movimiento_stock(producto_id, cantidad, 'venta', venta_id, usuario_id)productos (server-side)INSERT INTO venta_items (venta_id, line_no, tipo='producto', producto_id, descripcion, cantidad, precio_unitario, subtotal)ventas.subtotal_productos y ventas.totalsesiones.productos (cache JSONB) y sesiones.total_productosOK_IDEMPOTENTENo hace:
finalizar_sesionPropósito: Finalizar sesión atómicamente. Cierra la venta abierta existente, agrega item de tiempo, calcula totales. NO descuenta stock.
CREATE OR REPLACE FUNCTION finalizar_sesion(
p_sesion_id UUID,
p_metodo_pago VARCHAR,
p_monto_efectivo NUMERIC DEFAULT NULL,
p_monto_transferencia NUMERIC DEFAULT NULL,
p_monto_tarjeta NUMERIC DEFAULT NULL,
p_monto_digital NUMERIC DEFAULT NULL,
p_notas TEXT DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL
) RETURNS TABLE(
status TEXT,
venta_id UUID,
sesion_id UUID,
total_tiempo NUMERIC,
total_productos NUMERIC,
total NUMERIC,
mensaje TEXT
)
Lógica:
auth.uid() → resolver usuario_id_realSELECT * FROM sesiones WHERE id=p_sesion_id FOR UPDATEestado='activa' AND finalizada=false
→ si ya finalizada: ERROR_SESION_FINALIZADAregistrar_venta_pos)tarifa_tiempo = tarifa_base + costo_adicional (o monto manual si modo libre)SELECT * FROM ventas WHERE sesion_id=p_sesion_id AND estado='abierta'total_productos = SUM(venta_items.subtotal) WHERE venta_id=venta.id AND tipo='producto'INSERT INTO venta_items (venta_id, line_no=next, tipo='tiempo', descripcion='Tiempo de juego', cantidad=1, precio_unitario=tarifa_tiempo, subtotal=tarifa_tiempo)UPDATE ventas SET estado='cerrada', subtotal_tiempo=tarifa_tiempo, subtotal_productos=total_productos, total=tarifa_tiempo+total_productos-descuento, metodo_pago=p_metodo_pago, monto_*=p_monto_*, fecha_cierre=NOW()INSERT INTO ventas (sesion_id, estado='cerrada', subtotal_tiempo=tarifa_tiempo, subtotal_productos=0, total=tarifa_tiempo, ...)UPDATE sesiones SET estado='finalizada', finalizada=true, fecha_fin=NOW(), total_tiempo=tarifa_tiempo, total_productos=total_productos, total_general=total, metodo_pago=p_metodo_pago, monto_*=p_monto_*agregar_productos_sesion)OK_IDEMPOTENTEeditar_ventaPropósito: Editar venta existente, ajustando stock por diferencia. Funciona para ventas POS y ventas de sesión.
CREATE OR REPLACE FUNCTION editar_venta(
p_venta_id UUID,
p_items JSONB, -- [{ producto_id, cantidad }] (lista completa nueva)
p_metodo_pago VARCHAR DEFAULT NULL,
p_monto_efectivo NUMERIC DEFAULT NULL,
p_monto_transferencia NUMERIC DEFAULT NULL,
p_monto_tarjeta NUMERIC DEFAULT NULL,
p_monto_digital NUMERIC DEFAULT NULL,
p_descuento NUMERIC DEFAULT 0,
p_notas TEXT DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL
) RETURNS TABLE(
status TEXT,
venta_id UUID,
total NUMERIC,
stock_ajustes JSONB,
mensaje TEXT
)
Lógica:
obtener_rol_actual() IN (‘administrador’,’supervisor’)
→ si no: ERROR_SIN_PERMISOSELECT * FROM ventas WHERE id=p_venta_id FOR UPDATEestado ≠ 'anulada' → si anulada: ERROR_VENTA_ANULADA{producto_id: cantidad}p_itemsdelta = cantidad_anterior - cantidad_nuevadelta > 0: aplicar_movimiento_stock(pid, delta, 'devolucion', venta_id) (devolver)delta < 0: aplicar_movimiento_stock(pid, |delta|, 'venta', venta_id) (descontar)productos (server-side)DELETE FROM venta_items WHERE venta_id=p_venta_id AND tipo='producto'INSERT nuevos venta_items (tipo=’producto’) con line_no secuencialsubtotal_productos y totalUPDATE ventas (total, método pago, montos, descuento, notas)ventas.sesion_id IS NOT NULL:
UPDATE sesiones SET productos=cache, total_productos, total_general, metodo_pago, monto_*devolver_venta (extender v3)Propósito: Anular venta, devolver stock, cancelar sesión solo si venta.sesion_id IS NOT NULL.
CREATE OR REPLACE FUNCTION devolver_venta(
p_venta_id UUID,
p_motivo TEXT DEFAULT NULL,
p_idempotency_key TEXT DEFAULT NULL
) RETURNS TABLE(
status TEXT,
venta_id UUID,
sesion_id UUID,
stock_devuelto JSONB,
mensaje TEXT
)
Lógica:
SELECT * FROM ventas WHERE id=p_venta_id FOR UPDATEestado ≠ 'anulada' → si anulada: ERROR_YA_DEVUELTAproducto_id NOT NULL y cantidad > 0aplicar_movimiento_stock(producto_id, cantidad, 'devolucion', venta_id)UPDATE ventas SET estado='anulada', total=0, subtotal_productos=0, monto_*=NULLventas.sesion_id IS NOT NULL:
UPDATE sesiones SET estado='cancelada', finalizada=true, total_general=0, total_productos=0, total_tiempo=0, metodo_pago=NULL, monto_*=NULLDiferencia con v3: v3 no manejaba sesión. Ahora cancela sesión solo cuando sesion_id IS NOT NULL.
Todas usan aplicar_movimiento_stock (motor interno, sin GRANT directo).
1. Leer venta_items actuales (tipo='producto')
→ mapa: { producto_id: cantidad }
2. Recibir nueva lista (p_items)
→ mapa: { producto_id: cantidad_nueva }
3. Para cada producto_id en unión:
delta = cantidad_anterior - cantidad_nueva
if delta > 0: aplicar_movimiento_stock(pid, delta, 'devolucion', venta_id)
if delta < 0: aplicar_movimiento_stock(pid, |delta|, 'venta', venta_id)
4. Recalcular precios desde productos (server-side)
5. DELETE + INSERT venta_items (tipo='producto')
6. UPDATE venta (total, método pago, etc.)
7. Si sesion_id NOT NULL: UPDATE sesiones (cache)
| Antes | Después | Stock |
|---|---|---|
| 2 gaseosas | 1 gaseosa | +1 |
| 1 gaseosa | 3 gaseosas | -2 |
| 1 gaseosa | 0 | +1 |
| 0 | 2 gaseosas | -2 |
| 2 gaseosas | 2 gaseosas | sin cambio |
1. Validar estado ≠ 'anulada' → ERROR_YA_DEVUELTA si ya lo está
2. Leer venta_items (tipo='producto')
3. Devolver stock via aplicar_movimiento_stock('devolucion')
4. UPDATE venta SET estado='anulada', total=0
5. Si sesion_id IS NOT NULL:
UPDATE sesion SET estado='cancelada', finalizada=true, totales=0
| Aspecto | Actual | Propuesto |
|---|---|---|
| Operación | DELETE físico | UPDATE estado=’anulada’ |
| Auditoría | Perdida | Conservada |
| Stock | READ-MODIFY-WRITE | RPC atómica |
| Doble devolución | Posible | ERROR_YA_DEVUELTA |
| Sesión | No se actualiza | Cancelada solo si sesion_id NOT NULL |
BEGIN
1. SELECT sesión FOR UPDATE → validar activa, no finalizada
2. Validar auth, permisos
3. Calcular tarifa_tiempo
4. Validar método pago + montos
5. Buscar venta abierta (estado='abierta')
6. Si existe:
- calcular total_productos desde venta_items
- agregar venta_item tipo='tiempo'
- UPDATE venta estado='cerrada', totales, método pago
7. Si no existe (sin productos):
- INSERT venta estado='cerrada' + item tiempo
8. NO descontar stock
9. UPDATE sesión estado='finalizada', finalizada=true, totales
10. RETURN status=OK, venta_id, total
COMMIT (o ROLLBACK si falla)
| Operación | Key scope | Retry |
|---|---|---|
agregar_productos_sesion |
Por batch | Misma key + mismo payload → OK_IDEMPOTENTE |
finalizar_sesion |
Por finalización | Misma key → OK_IDEMPOTENTE (no doble) |
editar_venta |
Por edición | Misma key + mismo payload → OK_IDEMPOTENTE |
devolver_venta |
Por devolución | Misma key → OK_IDEMPOTENTE (no doble) |
Conflicto: misma key + payload diferente → ERROR_IDEMPOTENCIA_CONFLICTO.
-- rollback-0.2-d.sql
DROP FUNCTION IF EXISTS agregar_productos_sesion(UUID, JSONB, TEXT);
DROP FUNCTION IF EXISTS finalizar_sesion(UUID, VARCHAR, NUMERIC, NUMERIC, NUMERIC, NUMERIC, TEXT, TEXT);
DROP FUNCTION IF EXISTS editar_venta(UUID, JSONB, VARCHAR, NUMERIC, NUMERIC, NUMERIC, NUMERIC, NUMERIC, TEXT, TEXT);
-- Restaurar devolver_venta a v3 (re-ejecutar rpc-stock-v3.sql para esa función)
-- NO eliminar columnas nuevas si tienen datos
USE_SESSION_RPC_V3 = false en sessionService.js| Archivo | Propósito |
|---|---|
src/lib/sessionService.js |
Adaptador delgado para RPCs de sesión |
docs/database/rpc-sesion-v4.sql |
SQL con 4 RPCs |
docs/database/rollback/rollback-0.2-d.sql |
Rollback SQL |
| Archivo | Cambio |
|---|---|
src/hooks/useSalas.js |
agregarProductos → sessionService, finalizarSesion → sessionService, anularSesion → sessionService, editarSesionAdmin → sessionService |
src/components/salas/ModalTienda.jsx |
Modo sesión → sessionService |
src/components/salas/ModalAgregarProductos.jsx |
Usar sessionService |
src/components/salas/ModalFinalizarSesion.jsx |
Usar sessionService.finalizarSesion |
src/components/salas/ModalEditarSesionAdmin.jsx |
Usar sessionService.editarVenta |
src/pages/Ventas.jsx |
eliminar() → sessionService.devolverVenta, guardarEdicion() → sessionService.editarVenta |
| Archivo | Razón |
|---|---|
src/lib/posService.js |
POS directo ya migrado |
src/lib/databaseService.js |
Capa CRUD |
src/store/useGameStore.js |
No afecta |
docs/database/rpc-stock-v3.sql |
SQL v3 estable |
sesiones.finalizada |
Se conserva |
sesiones.productos JSONB |
Cache operativo |
| # | Test | Esperado |
|---|---|---|
| 1 | Agregar producto a sesión activa | venta abierta creada, venta_item creado, stock descontado, movimiento creado (ref=venta_id) |
| 2 | Agregar múltiples productos | N items, stock correcto, misma venta |
| 3 | Agregar producto a sesión sin productos previos | venta abierta creada en primer consumo |
| 4 | Agregar producto sin stock | ERROR_STOCK_INSUFICIENTE, sin cambios |
| 5 | Agregar producto a sesión finalizada | ERROR_SESION_NO_ACTIVA |
| 6 | Finalizar sesión con productos | venta cerrada, item tiempo agregado, sesión finalizada, stock sin cambio |
| 7 | Finalizar sesión sin productos | venta cerrada, sólo item tiempo |
| 8 | Doble finalización (misma key) | OK_IDEMPOTENTE |
| 9 | Finalizar sesión ya finalizada | ERROR_SESION_FINALIZADA |
| 10 | Editar venta: reducir cantidad | stock +delta, items actualizados |
| 11 | Editar venta: aumentar cantidad | stock -delta, items actualizados |
| 12 | Editar venta: eliminar item | stock +cantidad |
| 13 | Editar venta: agregar item | stock -cantidad |
| 14 | Editar venta anulada | ERROR_VENTA_ANULADA |
| 15 | Devolver venta con sesión | estado=anulada, stock devuelto, sesión cancelada |
| 16 | Devolver venta sin sesión (POS) | estado=anulada, stock devuelto, sesión sin cambio |
| 17 | Doble devolución | ERROR_YA_DEVUELTA |
| 18 | Rollback atomicidad | sin cambios en DB |
| 19 | Concurrencia: 2 operaciones mismo producto | consistencia |
| 20 | anon rechazado | ERROR_NO_AUTENTICADO |
| # | Test | Esperado |
|---|---|---|
| R1 | Sesión activa funciona | iniciar, agregar tiempo, trasladar |
| R2 | Agregar producto a sesión | producto visible, stock descontado, venta abierta |
| R3 | Finalizar sesión | cobro correcto, venta cerrada |
| R4 | Anular sesión | stock devuelto, sesión cancelada |
| R5 | Editar venta | stock ajustado, venta actualizada |
| R6 | Devolver venta | stock devuelto, venta anulada |
| R7 | Dashboard | totales correctos |
| R8 | Reportes | ventas visibles |
| R9 | CierreTurno | ventas incluidas |
| R10 | Stock | movimientos visibles |
| R11 | Build PASS | sin errores |
1. SQL: crear 4 RPCs (rpc-sesion-v4.sql)
2. Tests backend (20 tests)
3. sessionService.js (adaptador delgado)
4. Integración agregar_productos_sesion
5. Integración finalizar_sesion
6. Integración editar_venta
7. Integración devolver_venta
8. Tests de regresión (11 tests)
9. Build
10. Producción controlada
11. Retirar legacy (sprint posterior)
| Problema | Solución |
|---|---|
| Stock descontado al agregar, no se devuelve al anular | devolver_venta devuelve stock + cancela sesión |
| Venta contable sin items | venta abierta crea items desde el primer consumo |
| Doble finalización | idempotency_key |
| Doble devolución | ERROR_YA_DEVUELTA |
| READ-MODIFY-WRITE stock | RPC atómica via aplicar_movimiento_stock |
| DELETE físico de venta | UPDATE estado=’anulada’ |
| Inconsistencia sesión ↔ venta | venta abierta sincronizada desde primer consumo |
| Trazabilidad inconsistente | movimientos_stock.referencia=venta_id uniforme |
| Sin validación de rol en edición | RPC valida rol (admin/supervisor) |
| Precio manipulable | RPC recalcula desde productos |
| Venta creada sólo al finalizar | Venta abierta desde primer consumo (modificación aprobada) |
✅ una sesión puede consumir productos
✅ venta abierta existe desde el primer consumo
✅ los productos quedan vinculados a venta_items
✅ stock correcto (descontado al agregar)
✅ no hay doble descuento
✅ finalización cierra venta abierta + agrega tiempo
✅ venta consistente con sesión
✅ edición segura (ajuste stock por diferencia)
✅ devolución segura (anula, no borra)
✅ no hay doble devolución
✅ no hay doble finalización
✅ idempotencia
✅ rollback atómico
✅ auditoría/trazabilidad (referencia=venta_id)
✅ reportes existentes no se rompen
✅ caja existente no se rompen
No se escribe código hasta aprobación explícita para implementar.