Punto de Venta — Arquitectura y Diseño
Decisiones de diseño
El módulo POS está construido de forma conservadora: reutiliza la infraestructura existente de ventas, pagos e inventario sin modificar ninguna pantalla existente. Las nuevas rutas (/pos y /pos/register) son páginas independientes.
Lo que se reutiliza directamente
| Componente | Descripción |
|---|---|
Sale | Venta normal; client=null para consumidor final |
SalePayment | Múltiples pagos por venta (split payment) |
ProductCard, CategoryFilter, ProductGrid | Componentes visuales de catálogo ya existentes |
JournalEntry source=sale | Auto-journal contable al crear la venta |
MercadoPagoPOS | Terminal QR de MercadoPago ya integrado |
Lo nuevo
| Componente | Qué agrega |
|---|---|
CashRegisterSession | Sesión de caja con estado open/closed |
RegisterMovement | Registro de cada flujo de efectivo en la sesión |
CashReconciliation | Arqueo al cierre: declarado vs. sistema |
PaymentMethod choices | Enum estándar en SalePayment.payment_method |
PosCheckout.tsx | Flujo de pago propio con split-payment y vuelto |
PosDashboard.tsx | Terminal POS fullscreen independiente |
RegisterPanel.tsx | Gestión de apertura, cierre y movimientos de caja |
Modelo de datos
CashRegisterSession
Representa un turno de caja. Solo puede haber una sesión abierta por organización a la vez (validado en la mutation openCashRegister).
CashRegisterSession
├── organization (FK)
├── opened_by (FK User)
├── opened_at (auto)
├── opening_cash (Decimal) ← efectivo inicial declarado
├── status: open | closed
├── closed_by (FK User, nullable)
├── closed_at (nullable)
├── closing_notes
└── bank_account (FK BankAccount, nullable) ← caja física como cuentaRegisterMovement
Un movimiento es cualquier flujo de dinero durante la sesión: ventas cobradas, retiros, caja chica, devoluciones.
RegisterMovement
├── session (FK CashRegisterSession)
├── amount (Decimal)
├── direction: in | out
├── reason: sale | withdrawal | petty_cash | refund | opening | other
├── notes
├── sale (FK Sale, nullable) ← vincula el movimiento a una venta específica
└── created_by (FK User)Al cobrar una venta desde el POS, se crea automáticamente un RegisterMovement(direction='in', reason='sale') que vincula la venta a la sesión activa.
CashReconciliation
Snapshot del cierre. Almacena los valores declarados por el cajero y los valores computados desde SalePayment, para calcular la diferencia.
CashReconciliation
├── session (OneToOne CashRegisterSession)
│
├── declared_cash, declared_card, declared_transfer, declared_check, declared_qr
│ └── Ingresados manualmente por el cajero al cierre
│
├── system_cash, system_card, system_transfer, system_check, system_qr
│ └── Calculados desde SalePayment de las ventas de la sesión
│
├── notes
└── created_by
Properties calculadas:
declared_total = sum(declared_*)
system_total = sum(system_*)
discrepancy = declared_total − system_totalPaymentMethod choices en SalePayment
Antes de este módulo, payment_method era un CharField libre sin validación. Ahora tiene choices explícitos:
| Valor | Descripción |
|---|---|
CASH | Efectivo |
CARD | Tarjeta de débito/crédito |
TRANSFER | Transferencia bancaria |
CHECK | Cheque |
DEPOSIT | Depósito bancario |
QR_MP | QR MercadoPago |
QR_GALIO | QR GalioPay |
OTHER | Otro |
La migración es no-breaking: los valores existentes en la base de datos se conservan.
Flujo de una venta POS
1. Cajero abre sesión
openCashRegister(openingCash) →
CashRegisterSession(status=open) +
RegisterMovement(direction=in, reason=opening)
2. Vendedor arma carrito y cobra
createSale(items, clientId=null) → Sale
registerSalePayment(saleId, amount, method) × N → SalePayment × N
registerMovement(sessionId, amount, direction=in, reason=sale) → RegisterMovement
3. Cajero cierra sesión
closeCashRegister(sessionId, declared*) →
CashReconciliation(declared_*, system_*) +
CashRegisterSession(status=closed)API GraphQL
Queries
# Sesión activa del tenant
getActiveRegisterSession: CashRegisterSession
# Historial de sesiones con filtro de fechas
getRegisterSessions(dateFrom: Date, dateTo: Date): [CashRegisterSession]
# Arqueo de una sesión específica
getCashReconciliation(sessionId: ID!): CashReconciliationMutations
# Abrir sesión (falla si ya hay una abierta)
openCashRegister(openingCash: Decimal!, bankAccountId: ID): OpenResult
# Cerrar sesión con arqueo
closeCashRegister(
sessionId: ID!
declaredCash: Decimal
declaredCard: Decimal
declaredTransfer: Decimal
declaredCheck: Decimal
declaredQr: Decimal
notes: String
): CloseResult
# Registrar movimiento manual
registerMovement(
sessionId: ID!
amount: Decimal!
direction: String! # "in" | "out"
reason: String # "sale" | "withdrawal" | "petty_cash" | "refund" | "other"
notes: String
): MovementResultEstructura de archivos
backend/
pos/
__init__.py
apps.py
models.py ← CashRegisterSession, RegisterMovement, CashReconciliation
schema.py ← GraphQL: Query + Mutation
admin.py
migrations/
0001_initial.py
frontend/
features/pos/
PosDashboard.tsx ← Terminal POS fullscreen (/pos)
RegisterPanel.tsx ← Gestión de caja (/pos/register)
api/
operations.ts ← Queries/mutations GQL del módulo
components/
PosCheckout.tsx ← Flujo de pago + split + vuelto
app/pos/
page.tsx ← Ruta /pos
register/
page.tsx ← Ruta /pos/register
components/pos/ ← Componentes compartidos (existentes, sin modificar)
ProductCard.tsx
CategoryFilter.tsx
ProductGrid.tsxConsideraciones de implementación
Concurrencia de sesiones
La validación de "solo una sesión abierta" se hace en la mutation con un .filter(status='open').exists() antes de crear. En entornos con alta concurrencia, esto podría generar una condición de carrera. Para producción se puede agregar un select_for_update() o un unique constraint parcial a nivel de DB.
Cómputo de totales del sistema
Los totales del sistema en CashReconciliation se calculan en el momento del cierre con get_system_totals_by_method(), que agrega SalePayment vinculados a la sesión vía RegisterMovement. Son un snapshot inmutable del momento del cierre.
Multi-terminal
El modelo actual asume una caja por organización. Para múltiples terminales simultáneos, se puede extender CashRegisterSession con un campo terminal_id y la validación pasa de "una sesión por organización" a "una sesión por terminal".
Integración contable futura
El campo bank_account en CashRegisterSession permite vincular la caja física a una cuenta contable. Al cerrar la sesión, se puede emitir un JournalEntry(source='cash_register') usando el mismo patrón de signals.py que usa el módulo de compras.