ANALISIS MODULO RT-007: CAJA (ARQUEOS Y CORTES)
Fecha: 2025-12-18
Fase: 2 - Analisis por Modulo
Modulo: RT-007 Caja
Herencia: 10%
Story Points: 28
Prioridad: P0
1. DESCRIPCION GENERAL
1.1 Proposito
Control de efectivo con apertura/cierre de caja, movimientos de efectivo, arqueos y cortes con declaracion por denominacion.
1.2 Funcionalidades Principales
| Funcionalidad |
Descripcion |
Criticidad |
| Apertura caja |
Con fondo inicial |
Critica |
| Cierre caja |
Con conteo |
Critica |
| Movimientos |
Entradas/salidas |
Alta |
| Arqueos |
Parciales |
Media |
| Declaracion |
Por denominacion |
Alta |
| Diferencias |
Control |
Alta |
2. HERENCIA DEL CORE
2.1 Componentes Heredados (10%)
| Componente Core |
% Uso |
Accion |
| financial.payments |
10% |
Referencia |
2.2 Observacion
Este modulo es casi 100% nuevo. No existe gestion de caja en el core. Solo se reutilizan conceptos de pagos.
3. COMPONENTES NUEVOS
3.1 Entidades (TypeORM)
// 1. CashRegister - Caja registradora (ya existe)
@Entity('cash_registers', { schema: 'retail' })
export class CashRegister {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
tenantId: string;
@ManyToOne(() => Branch)
branch: Branch;
@Column()
code: string;
@Column()
name: string;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'enum', enum: PaymentMethod, nullable: true })
defaultPaymentMethod: PaymentMethod;
}
// 2. CashSession - Sesion de caja (extiende POSSession)
// Ya definida en RT-002, aqui se agregan campos de arqueo
// 3. CashMovement - Movimiento de efectivo
@Entity('cash_movements', { schema: 'retail' })
export class CashMovement {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
tenantId: string;
@ManyToOne(() => POSSession)
session: POSSession;
@Column({ type: 'enum', enum: CashMovementType })
movementType: CashMovementType; // in, out
@Column({ type: 'decimal', precision: 12, scale: 2 })
amount: number;
@Column()
reason: string;
@Column({ nullable: true })
notes: string;
@ManyToOne(() => User, { nullable: true })
authorizedBy: User;
@Column({ type: 'timestamptz' })
createdAt: Date;
}
// 4. CashClosing - Corte de caja
@Entity('cash_closings', { schema: 'retail' })
export class CashClosing {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
tenantId: string;
@ManyToOne(() => POSSession)
session: POSSession;
@Column({ type: 'timestamptz' })
closingDate: Date;
// Montos esperados (calculados)
@Column({ type: 'decimal', precision: 12, scale: 2 })
expectedCash: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
expectedCard: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
expectedTransfer: number;
// Montos declarados
@Column({ type: 'decimal', precision: 12, scale: 2 })
declaredCash: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
declaredCard: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
declaredTransfer: number;
// Diferencias
@Column({ type: 'decimal', precision: 12, scale: 2 })
cashDifference: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
cardDifference: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
transferDifference: number;
// Detalle de denominaciones
@Column({ type: 'jsonb', nullable: true })
denominationDetail: DenominationDetail;
@Column({ nullable: true })
notes: string;
@ManyToOne(() => User)
closedBy: User;
@ManyToOne(() => User, { nullable: true })
approvedBy: User;
@Column({ type: 'boolean', default: false })
isApproved: boolean;
}
// 5. CashCount - Arqueo parcial
@Entity('cash_counts', { schema: 'retail' })
export class CashCount {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
tenantId: string;
@ManyToOne(() => POSSession)
session: POSSession;
@Column({ type: 'timestamptz' })
countDate: Date;
@Column({ type: 'decimal', precision: 12, scale: 2 })
expectedAmount: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
countedAmount: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
difference: number;
@Column({ type: 'jsonb', nullable: true })
denominationDetail: DenominationDetail;
@ManyToOne(() => User)
countedBy: User;
@Column({ nullable: true })
notes: string;
}
// Interfaz para denominaciones
interface DenominationDetail {
bills: {
'1000': number;
'500': number;
'200': number;
'100': number;
'50': number;
'20': number;
};
coins: {
'20': number;
'10': number;
'5': number;
'2': number;
'1': number;
'0.50': number;
};
total: number;
}
3.2 Servicios Backend
| Servicio |
Metodos Principales |
| CashRegisterService |
getAll(), getByBranch(), activate(), deactivate() |
| CashSessionService |
open(), close(), getActive(), getSummary() |
| CashMovementService |
createIn(), createOut(), getBySession() |
| CashClosingService |
prepare(), declare(), approve(), reject() |
| CashCountService |
create(), compare() |
3.3 Controladores
@Controller('cash')
export class CashController {
// Cajas registradoras
@Get('registers')
getRegisters(@Query('branchId') branchId: string): Promise<CashRegister[]>;
// Sesiones
@Post('sessions/open')
openSession(@Body() dto: OpenSessionDto): Promise<POSSession>;
@Get('sessions/active')
getActiveSession(): Promise<POSSession>;
@Get('sessions/:id/summary')
getSessionSummary(@Param('id') id: string): Promise<SessionSummary>;
// Movimientos
@Post('movements')
createMovement(@Body() dto: CreateMovementDto): Promise<CashMovement>;
@Get('sessions/:id/movements')
getMovements(@Param('id') id: string): Promise<CashMovement[]>;
// Arqueos
@Post('sessions/:id/count')
createCount(@Param('id') id: string, @Body() dto: CountDto): Promise<CashCount>;
// Corte
@Get('sessions/:id/closing/prepare')
prepareClosing(@Param('id') id: string): Promise<ClosingPreparation>;
@Post('sessions/:id/closing')
closeSession(@Param('id') id: string, @Body() dto: ClosingDto): Promise<CashClosing>;
@Post('closings/:id/approve')
approveClosing(@Param('id') id: string): Promise<CashClosing>;
// Reportes
@Get('reports/daily')
getDailyReport(@Query('date') date: string): Promise<DailyReport>;
@Get('reports/differences')
getDifferencesReport(@Query() filters: DifferenceFilters): Promise<DifferenceReport>;
}
4. FLUJOS DE NEGOCIO
4.1 Flujo de Apertura
1. Cajero selecciona caja
↓
2. Verificar caja disponible (no en uso)
↓
3. Ingresar fondo inicial
↓
4. Sistema crea sesion (status: OPENING)
↓
5. Confirmar apertura
↓
6. Estado: OPENING → OPEN
↓
7. Cajero puede iniciar ventas
4.2 Flujo de Movimiento
1. Cajero solicita retiro/ingreso
↓
2. Ingresar monto y motivo
↓
3. Si retiro > limite:
a. Solicitar autorizacion supervisor
b. Supervisor aprueba
↓
4. Registrar movimiento
↓
5. Actualizar balance de sesion
4.3 Flujo de Corte
1. Cajero solicita cierre
↓
2. Sistema prepara resumen:
- Ventas en efectivo
- Ventas en tarjeta
- Ventas en transferencia
- Movimientos de efectivo
- Fondo inicial
- Esperado en efectivo
↓
3. Cajero cuenta efectivo
↓
4. Declarar por denominacion:
- Billetes: $1000, $500, $200, $100, $50, $20
- Monedas: $20, $10, $5, $2, $1, $0.50
↓
5. Sistema calcula total declarado
↓
6. Calcular diferencia
↓
7. Si diferencia > tolerancia:
a. Registrar motivo
b. Supervisor aprueba/rechaza
↓
8. Estado: OPEN → CLOSED
↓
9. Generar reporte de corte
5. CALCULOS
5.1 Efectivo Esperado
function calculateExpectedCash(session: POSSession): number {
const openingBalance = session.openingBalance;
// Ventas en efectivo
const cashSales = session.orders
.filter(o => o.status === 'done')
.reduce((sum, o) => {
const cashPayments = o.payments.filter(p => p.method === 'cash');
return sum + cashPayments.reduce((s, p) => s + p.amount, 0);
}, 0);
// Cambio dado
const changeGiven = session.orders
.filter(o => o.status === 'done')
.reduce((sum, o) => sum + (o.changeAmount || 0), 0);
// Movimientos
const cashIn = session.movements
.filter(m => m.type === 'in')
.reduce((sum, m) => sum + m.amount, 0);
const cashOut = session.movements
.filter(m => m.type === 'out')
.reduce((sum, m) => sum + m.amount, 0);
return openingBalance + cashSales - changeGiven + cashIn - cashOut;
}
5.2 Declaracion por Denominacion
interface DenominationCount {
denomination: string;
quantity: number;
subtotal: number;
}
function calculateDenominations(counts: DenominationCount[]): number {
return counts.reduce((total, c) => total + c.subtotal, 0);
}
// Ejemplo
const declaration = [
{ denomination: '1000', quantity: 2, subtotal: 2000 },
{ denomination: '500', quantity: 5, subtotal: 2500 },
{ denomination: '200', quantity: 3, subtotal: 600 },
{ denomination: '100', quantity: 10, subtotal: 1000 },
{ denomination: '50', quantity: 8, subtotal: 400 },
{ denomination: '20', quantity: 15, subtotal: 300 },
// Monedas
{ denomination: '10', quantity: 20, subtotal: 200 },
{ denomination: '5', quantity: 10, subtotal: 50 },
{ denomination: '2', quantity: 5, subtotal: 10 },
{ denomination: '1', quantity: 10, subtotal: 10 },
{ denomination: '0.50', quantity: 20, subtotal: 10 },
];
// Total: $7,080
6. TABLAS DDL
6.1 Tablas
-- Ya definidas parcialmente, agregar:
CREATE TABLE retail.cash_closings (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
session_id UUID NOT NULL REFERENCES retail.pos_sessions(id),
closing_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Esperados
expected_cash DECIMAL(12,2) NOT NULL,
expected_card DECIMAL(12,2) NOT NULL DEFAULT 0,
expected_transfer DECIMAL(12,2) NOT NULL DEFAULT 0,
-- Declarados
declared_cash DECIMAL(12,2) NOT NULL,
declared_card DECIMAL(12,2) NOT NULL DEFAULT 0,
declared_transfer DECIMAL(12,2) NOT NULL DEFAULT 0,
-- Diferencias
cash_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_cash - expected_cash) STORED,
card_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_card - expected_card) STORED,
transfer_difference DECIMAL(12,2) GENERATED ALWAYS AS (declared_transfer - expected_transfer) STORED,
-- Detalle
denomination_detail JSONB,
notes TEXT,
-- Auditoria
closed_by UUID NOT NULL REFERENCES auth.users(id),
approved_by UUID REFERENCES auth.users(id),
is_approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE retail.cash_counts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
session_id UUID NOT NULL REFERENCES retail.pos_sessions(id),
count_date TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expected_amount DECIMAL(12,2) NOT NULL,
counted_amount DECIMAL(12,2) NOT NULL,
difference DECIMAL(12,2) GENERATED ALWAYS AS (counted_amount - expected_amount) STORED,
denomination_detail JSONB,
counted_by UUID NOT NULL REFERENCES auth.users(id),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
7. DEPENDENCIAS
7.1 Dependencias de Retail
| Modulo |
Tipo |
| RT-001 Fundamentos |
Prerequisito |
| RT-002 POS |
Sesiones de caja |
7.2 Bloquea a
| Modulo |
Razon |
| RT-008 Reportes |
Reportes de caja |
8. CRITERIOS DE ACEPTACION
8.1 Funcionales
8.2 Auditoria
9. ESTIMACION DETALLADA
| Componente |
SP Backend |
SP Frontend |
Total |
| Entities + Migrations |
3 |
- |
3 |
| CashSessionService |
5 |
- |
5 |
| CashMovementService |
3 |
- |
3 |
| CashClosingService |
5 |
- |
5 |
| CashCountService |
2 |
- |
2 |
| Controllers |
3 |
- |
3 |
| Opening UI |
- |
2 |
2 |
| Movements UI |
- |
2 |
2 |
| Closing UI |
- |
3 |
3 |
| TOTAL |
21 |
7 |
28 |
Estado: ANALISIS COMPLETO
Bloqueado por: RT-001, RT-002