Skip to content

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

ComponenteDescripción
SaleVenta normal; client=null para consumidor final
SalePaymentMúltiples pagos por venta (split payment)
ProductCard, CategoryFilter, ProductGridComponentes visuales de catálogo ya existentes
JournalEntry source=saleAuto-journal contable al crear la venta
MercadoPagoPOSTerminal QR de MercadoPago ya integrado

Lo nuevo

ComponenteQué agrega
CashRegisterSessionSesión de caja con estado open/closed
RegisterMovementRegistro de cada flujo de efectivo en la sesión
CashReconciliationArqueo al cierre: declarado vs. sistema
PaymentMethod choicesEnum estándar en SalePayment.payment_method
PosCheckout.tsxFlujo de pago propio con split-payment y vuelto
PosDashboard.tsxTerminal POS fullscreen independiente
RegisterPanel.tsxGestió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 cuenta

RegisterMovement

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_total

PaymentMethod choices en SalePayment

Antes de este módulo, payment_method era un CharField libre sin validación. Ahora tiene choices explícitos:

ValorDescripción
CASHEfectivo
CARDTarjeta de débito/crédito
TRANSFERTransferencia bancaria
CHECKCheque
DEPOSITDepósito bancario
QR_MPQR MercadoPago
QR_GALIOQR GalioPay
OTHEROtro

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

graphql
# 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!): CashReconciliation

Mutations

graphql
# 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
): MovementResult

Estructura 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.tsx

Consideraciones 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.