SPEC-PRESUPUESTOS-REVISIONES
Información del Documento
| Campo |
Valor |
| Código Gap |
GAP-MGN-008-002 |
| Módulo |
MGN-008 (Presupuestos y Control) |
| Título |
Sistema de Revisiones y Versionamiento de Presupuestos |
| Prioridad |
P1 |
| Complejidad |
Alta |
| Referencia Odoo |
account_budget, base_revision (OCA) |
1. Resumen Ejecutivo
1.1 Descripción del Gap
El sistema requiere capacidad de gestionar revisiones formales de presupuestos aprobados, manteniendo historial completo de versiones, permitiendo comparación entre original y revisado, y cumpliendo con requisitos de auditoría.
1.2 Justificación de Negocio
- Gobernanza: Control formal de cambios a presupuestos aprobados
- Auditoría: Trazabilidad completa de modificaciones
- Análisis: Comparación de escenarios original vs revisado
- Cumplimiento: Documentación de razones de cambio
1.3 Alcance
- Workflow de estados para presupuestos
- Sistema de revisiones con versionamiento
- Aprobaciones multi-nivel para cambios
- Tracking completo de modificaciones
- Reportes de comparación entre versiones
2. Análisis de Referencia (Odoo)
2.1 Estados del Presupuesto en Odoo
# Odoo: addons/account_budget/models/crossovered_budget.py
class CrossoveredBudget(models.Model):
_name = 'crossovered.budget'
_inherit = ['mail.thread']
state = fields.Selection([
('draft', 'Draft'),
('confirm', 'Confirmed'),
('validate', 'Validated'),
('done', 'Done'),
('cancel', 'Cancelled')
], string='Status', default='draft', readonly=True, tracking=True)
def action_budget_confirm(self):
self.write({'state': 'confirm'})
def action_budget_draft(self):
self.write({'state': 'draft'})
def action_budget_validate(self):
self.write({'state': 'validate'})
def action_budget_cancel(self):
self.write({'state': 'cancel'})
def action_budget_done(self):
self.write({'state': 'done'})
2.2 Limitación: Sin Sistema de Revisiones Nativo
Hallazgo crítico: Odoo 17 Community no incluye sistema de revisiones nativo para presupuestos.
Opciones disponibles:
- Reset to Draft: Permite edición directa pero sobrescribe datos
- Copy Budget: Crea nuevo presupuesto manualmente
- OCA base_revision: Módulo abstracto para implementar revisiones
2.3 Patrón OCA base_revision
# OCA: server-ux/base_revision
class Revision(models.AbstractModel):
_name = 'base.revision'
_description = 'Abstract Revision Mixin'
revision = fields.Integer(
string='Revision',
default=0,
copy=False,
readonly=True
)
previous_revision_id = fields.Many2one(
comodel_name='self',
string='Previous Revision',
readonly=True,
copy=False
)
revision_ids = fields.One2many(
comodel_name='self',
inverse_name='previous_revision_id',
string='Revisions',
readonly=True
)
unrevisioned_name = fields.Char(
compute='_compute_unrevisioned_name',
store=True
)
@api.depends('name', 'revision')
def _compute_unrevisioned_name(self):
for rec in self:
if rec.name:
rec.unrevisioned_name = rec.name.split(' - Rev')[0]
def action_create_revision(self):
"""Crea nueva revisión del documento."""
self.ensure_one()
vals = self._prepare_revision_vals()
new_revision = self.copy(vals)
self.message_post(body=_("New revision %s created.") % new_revision.name)
return new_revision
def _prepare_revision_vals(self):
return {
'name': f"{self.unrevisioned_name} - Rev{self.revision + 1}",
'revision': self.revision + 1,
'previous_revision_id': self.id,
'state': 'draft'
}
2.4 Tracking con mail.thread
# Odoo hereda mail.thread para tracking automático
class CrossoveredBudget(models.Model):
_inherit = ['mail.thread']
# Campos con tracking=True
name = fields.Char(tracking=True)
state = fields.Selection(..., tracking=True)
# Cada cambio se registra en chatter
# Ejemplo de mensaje automático:
# "State: Draft → Confirmed by John Doe on 2024-01-15 10:30:00"
3. Especificación Técnica
3.1 Modelo de Datos
-- =====================================================
-- PRESUPUESTO MAESTRO CON REVISIONES
-- =====================================================
-- Estados del presupuesto
CREATE TYPE budget_state_type AS ENUM (
'draft', -- Borrador, editable
'pending_approval', -- Pendiente de aprobación
'approved', -- Aprobado, no editable
'active', -- Activo, en uso para comparación
'revised', -- Reemplazado por nueva revisión
'closed', -- Cerrado al final del período
'cancelled' -- Cancelado
);
CREATE TABLE budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Identificación
name VARCHAR(255) NOT NULL,
code VARCHAR(50),
description TEXT,
-- Período
fiscal_year_id UUID REFERENCES fiscal_years(id),
date_from DATE NOT NULL,
date_to DATE NOT NULL,
-- Estado y workflow
state budget_state_type NOT NULL DEFAULT 'draft',
-- Versionamiento
revision_number INTEGER NOT NULL DEFAULT 0,
previous_revision_id UUID REFERENCES budgets(id),
is_current_revision BOOLEAN DEFAULT TRUE,
revision_reason TEXT, -- Justificación de la revisión
-- Totales calculados
total_planned DECIMAL(20, 4) DEFAULT 0,
total_practical DECIMAL(20, 4) DEFAULT 0,
total_committed DECIMAL(20, 4) DEFAULT 0,
-- Aprobaciones
submitted_at TIMESTAMPTZ,
submitted_by UUID REFERENCES users(id),
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES users(id),
approval_notes TEXT,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
-- Restricciones
CONSTRAINT uq_budget_code_tenant UNIQUE (code, tenant_id, revision_number),
CONSTRAINT chk_dates CHECK (date_to >= date_from)
);
-- Índices
CREATE INDEX idx_budgets_tenant_state ON budgets(tenant_id, state);
CREATE INDEX idx_budgets_fiscal_year ON budgets(fiscal_year_id);
CREATE INDEX idx_budgets_revision_chain ON budgets(previous_revision_id);
CREATE INDEX idx_budgets_current ON budgets(tenant_id, is_current_revision)
WHERE is_current_revision = TRUE;
-- =====================================================
-- LÍNEAS DE PRESUPUESTO
-- =====================================================
CREATE TABLE budget_lines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
budget_id UUID NOT NULL REFERENCES budgets(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Estructura presupuestaria
budget_position_id UUID NOT NULL REFERENCES budget_positions(id),
analytic_account_id UUID REFERENCES analytic_accounts(id),
cost_center_id UUID REFERENCES cost_centers(id),
-- Período de la línea
date_from DATE NOT NULL,
date_to DATE NOT NULL,
-- Montos
planned_amount DECIMAL(20, 4) NOT NULL DEFAULT 0,
practical_amount DECIMAL(20, 4) DEFAULT 0, -- Calculado
theoretical_amount DECIMAL(20, 4) DEFAULT 0, -- Calculado
committed_amount DECIMAL(20, 4) DEFAULT 0, -- Calculado
-- Distribución mensual (opcional)
monthly_distribution JSONB, -- {"01": 8333, "02": 8333, ...}
-- Notas
notes TEXT,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id),
CONSTRAINT uq_budget_line UNIQUE (budget_id, budget_position_id, analytic_account_id, cost_center_id)
);
-- =====================================================
-- HISTORIAL DE REVISIONES
-- =====================================================
CREATE TABLE budget_revisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Referencias
original_budget_id UUID NOT NULL REFERENCES budgets(id),
new_budget_id UUID NOT NULL REFERENCES budgets(id),
-- Metadatos de revisión
revision_number INTEGER NOT NULL,
revision_type revision_type_enum NOT NULL,
reason TEXT NOT NULL,
justification TEXT,
-- Cambios realizados
changes_summary JSONB NOT NULL,
/*
{
"lines_added": 2,
"lines_modified": 5,
"lines_removed": 0,
"total_planned_before": 100000,
"total_planned_after": 120000,
"variance_amount": 20000,
"variance_percent": 20.0,
"line_changes": [
{
"line_id": "...",
"analytic_account": "Marketing",
"before": 10000,
"after": 15000,
"change": 5000,
"percent": 50.0
}
]
}
*/
-- Aprobación
requires_approval BOOLEAN DEFAULT TRUE,
approval_tier approval_tier_type,
approved_at TIMESTAMPTZ,
approved_by UUID REFERENCES users(id),
approval_notes TEXT,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id)
);
CREATE TYPE revision_type_enum AS ENUM (
'minor_adjustment', -- Ajuste menor (<5%)
'budget_increase', -- Incremento de presupuesto
'budget_decrease', -- Reducción de presupuesto
'reallocation', -- Reasignación entre líneas
'emergency', -- Revisión de emergencia
'annual_update' -- Actualización anual planificada
);
CREATE TYPE approval_tier_type AS ENUM (
'none', -- Sin aprobación requerida
'manager', -- Solo gerente
'finance', -- Gerente financiero
'director', -- Director
'board' -- Junta directiva
);
-- =====================================================
-- HISTORIAL DE CAMBIOS (AUDIT TRAIL)
-- =====================================================
CREATE TABLE budget_change_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
budget_id UUID NOT NULL REFERENCES budgets(id) ON DELETE CASCADE,
budget_line_id UUID REFERENCES budget_lines(id) ON DELETE SET NULL,
-- Tipo de cambio
change_type change_type_enum NOT NULL,
-- Valores
field_name VARCHAR(100),
old_value TEXT,
new_value TEXT,
old_value_numeric DECIMAL(20, 4),
new_value_numeric DECIMAL(20, 4),
-- Contexto
change_reason TEXT,
session_id VARCHAR(100),
ip_address INET,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID NOT NULL REFERENCES users(id)
);
CREATE TYPE change_type_enum AS ENUM (
'create',
'update',
'delete',
'state_change',
'approval',
'revision_create',
'line_add',
'line_modify',
'line_remove'
);
CREATE INDEX idx_budget_change_logs_budget ON budget_change_logs(budget_id, created_at DESC);
CREATE INDEX idx_budget_change_logs_user ON budget_change_logs(created_by, created_at DESC);
-- =====================================================
-- APROBACIONES MULTI-NIVEL
-- =====================================================
CREATE TABLE budget_approvals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
budget_id UUID NOT NULL REFERENCES budgets(id) ON DELETE CASCADE,
revision_id UUID REFERENCES budget_revisions(id),
-- Nivel de aprobación
approval_tier approval_tier_type NOT NULL,
sequence INTEGER NOT NULL DEFAULT 1,
-- Estado
status approval_status_type NOT NULL DEFAULT 'pending',
-- Aprobador
approver_user_id UUID REFERENCES users(id),
approver_role VARCHAR(100),
-- Decisión
decision approval_decision_type,
decision_at TIMESTAMPTZ,
decision_notes TEXT,
-- Delegación
delegated_to UUID REFERENCES users(id),
delegated_at TIMESTAMPTZ,
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
due_date TIMESTAMPTZ
);
CREATE TYPE approval_status_type AS ENUM (
'pending',
'in_review',
'approved',
'rejected',
'delegated',
'expired'
);
CREATE TYPE approval_decision_type AS ENUM (
'approve',
'reject',
'request_changes',
'delegate'
);
CREATE INDEX idx_budget_approvals_pending ON budget_approvals(tenant_id, status)
WHERE status = 'pending';
-- =====================================================
-- SNAPSHOTS PARA COMPARACIÓN
-- =====================================================
CREATE TABLE budget_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
budget_id UUID NOT NULL REFERENCES budgets(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
-- Identificación
snapshot_type snapshot_type_enum NOT NULL,
snapshot_date DATE NOT NULL,
description TEXT,
-- Datos completos del presupuesto al momento del snapshot
budget_data JSONB NOT NULL,
/*
{
"header": { name, code, state, ... },
"lines": [
{ position, analytic, planned, practical, theoretical, ... }
],
"totals": {
"planned": 100000,
"practical": 45000,
"theoretical": 50000,
"variance": -5000,
"execution_percent": 45.0
}
}
*/
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id)
);
CREATE TYPE snapshot_type_enum AS ENUM (
'pre_revision', -- Antes de crear revisión
'post_approval', -- Después de aprobación
'monthly_close', -- Cierre mensual
'quarterly_close', -- Cierre trimestral
'annual_close', -- Cierre anual
'manual' -- Snapshot manual
);
CREATE INDEX idx_budget_snapshots ON budget_snapshots(budget_id, snapshot_date DESC);
3.2 Servicios de Dominio
# app/modules/budget/domain/services/budget_revision_service.py
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List, Dict
from enum import Enum
import copy
from app.core.exceptions import DomainException
from app.core.logging import get_logger
from app.core.events import EventBus
logger = get_logger(__name__)
class BudgetState(str, Enum):
DRAFT = "draft"
PENDING_APPROVAL = "pending_approval"
APPROVED = "approved"
ACTIVE = "active"
REVISED = "revised"
CLOSED = "closed"
CANCELLED = "cancelled"
class RevisionType(str, Enum):
MINOR_ADJUSTMENT = "minor_adjustment"
BUDGET_INCREASE = "budget_increase"
BUDGET_DECREASE = "budget_decrease"
REALLOCATION = "reallocation"
EMERGENCY = "emergency"
ANNUAL_UPDATE = "annual_update"
class ApprovalTier(str, Enum):
NONE = "none"
MANAGER = "manager"
FINANCE = "finance"
DIRECTOR = "director"
BOARD = "board"
class BudgetRevisionService:
"""Servicio de dominio para gestión de revisiones de presupuestos."""
# Umbrales para determinar nivel de aprobación
APPROVAL_THRESHOLDS = {
"minor_percent": Decimal("5"), # <5% = sin aprobación o solo manager
"manager_percent": Decimal("10"), # 5-10% = manager
"finance_percent": Decimal("20"), # 10-20% = finance
"director_percent": Decimal("50"), # 20-50% = director
# >50% = board
}
def __init__(
self,
budget_repository: "BudgetRepository",
revision_repository: "BudgetRevisionRepository",
approval_repository: "BudgetApprovalRepository",
snapshot_repository: "BudgetSnapshotRepository",
changelog_repository: "ChangeLogRepository",
notification_service: "NotificationService",
event_bus: EventBus
):
self._budget_repo = budget_repository
self._revision_repo = revision_repository
self._approval_repo = approval_repository
self._snapshot_repo = snapshot_repository
self._changelog_repo = changelog_repository
self._notification_service = notification_service
self._event_bus = event_bus
async def create_revision(
self,
tenant_id: str,
budget_id: str,
reason: str,
justification: Optional[str],
user_id: str,
revision_type: Optional[RevisionType] = None
) -> "Budget":
"""
Crea una nueva revisión de un presupuesto aprobado.
Args:
tenant_id: ID del tenant
budget_id: ID del presupuesto a revisar
reason: Razón de la revisión
justification: Justificación detallada
user_id: Usuario que crea la revisión
Returns:
Nuevo presupuesto (revisión)
"""
# Obtener presupuesto original
original = await self._budget_repo.get_with_lines(budget_id)
if not original:
raise DomainException(
code="BUDGET_NOT_FOUND",
message=f"Budget {budget_id} not found"
)
# Validar que puede ser revisado
if original.state not in [BudgetState.APPROVED, BudgetState.ACTIVE]:
raise DomainException(
code="INVALID_STATE_FOR_REVISION",
message=f"Budget in state '{original.state}' cannot be revised. "
f"Only approved or active budgets can be revised."
)
# Crear snapshot del original antes de revisión
await self._create_snapshot(original, "pre_revision", user_id)
# Crear nuevo presupuesto como copia
new_revision = await self._create_budget_copy(
original=original,
revision_number=original.revision_number + 1,
reason=reason,
user_id=user_id
)
# Registrar la revisión
revision_record = await self._revision_repo.create(
tenant_id=tenant_id,
original_budget_id=original.id,
new_budget_id=new_revision.id,
revision_number=new_revision.revision_number,
revision_type=revision_type or RevisionType.MINOR_ADJUSTMENT,
reason=reason,
justification=justification,
changes_summary={
"lines_added": 0,
"lines_modified": 0,
"lines_removed": 0,
"total_planned_before": float(original.total_planned),
"total_planned_after": float(original.total_planned),
"variance_amount": 0,
"variance_percent": 0
},
created_by=user_id
)
# Marcar original como revisado
await self._budget_repo.update(
budget_id=original.id,
state=BudgetState.REVISED,
is_current_revision=False
)
# Registrar en changelog
await self._log_change(
budget_id=original.id,
change_type="revision_create",
field_name="state",
old_value=original.state,
new_value=BudgetState.REVISED,
change_reason=reason,
user_id=user_id
)
# Emitir evento
await self._event_bus.publish(
"budget.revision.created",
{
"original_id": original.id,
"revision_id": new_revision.id,
"revision_number": new_revision.revision_number,
"reason": reason
}
)
logger.info(
"Budget revision created",
original_id=original.id,
revision_id=new_revision.id,
revision_number=new_revision.revision_number,
user_id=user_id
)
return new_revision
async def _create_budget_copy(
self,
original: "Budget",
revision_number: int,
reason: str,
user_id: str
) -> "Budget":
"""Crea una copia del presupuesto como nueva revisión."""
# Construir nombre con número de revisión
base_name = original.name.split(' - Rev')[0]
new_name = f"{base_name} - Rev{revision_number}"
# Crear nuevo presupuesto
new_budget = await self._budget_repo.create(
tenant_id=original.tenant_id,
name=new_name,
code=f"{original.code}-R{revision_number}" if original.code else None,
description=original.description,
fiscal_year_id=original.fiscal_year_id,
date_from=original.date_from,
date_to=original.date_to,
state=BudgetState.DRAFT,
revision_number=revision_number,
previous_revision_id=original.id,
is_current_revision=True,
revision_reason=reason,
total_planned=original.total_planned,
created_by=user_id
)
# Copiar líneas
for line in original.lines:
await self._budget_repo.create_line(
budget_id=new_budget.id,
tenant_id=original.tenant_id,
budget_position_id=line.budget_position_id,
analytic_account_id=line.analytic_account_id,
cost_center_id=line.cost_center_id,
date_from=line.date_from,
date_to=line.date_to,
planned_amount=line.planned_amount,
monthly_distribution=line.monthly_distribution,
notes=line.notes,
created_by=user_id
)
return new_budget
async def submit_for_approval(
self,
budget_id: str,
user_id: str
) -> "Budget":
"""
Envía presupuesto o revisión para aprobación.
Determina automáticamente el nivel de aprobación requerido
basado en el cambio respecto a la versión anterior.
"""
budget = await self._budget_repo.get_with_lines(budget_id)
if not budget:
raise DomainException(code="BUDGET_NOT_FOUND")
if budget.state != BudgetState.DRAFT:
raise DomainException(
code="INVALID_STATE",
message="Only draft budgets can be submitted for approval"
)
# Determinar nivel de aprobación requerido
approval_tier = await self._determine_approval_tier(budget)
# Actualizar estado
await self._budget_repo.update(
budget_id=budget.id,
state=BudgetState.PENDING_APPROVAL,
submitted_at=datetime.utcnow(),
submitted_by=user_id
)
# Crear solicitud de aprobación
await self._create_approval_request(
budget=budget,
approval_tier=approval_tier,
user_id=user_id
)
# Actualizar resumen de cambios si es revisión
if budget.previous_revision_id:
await self._update_revision_changes_summary(budget)
# Notificar aprobadores
await self._notify_approvers(budget, approval_tier)
logger.info(
"Budget submitted for approval",
budget_id=budget.id,
approval_tier=approval_tier,
user_id=user_id
)
return budget
async def _determine_approval_tier(self, budget: "Budget") -> ApprovalTier:
"""Determina el nivel de aprobación requerido."""
if not budget.previous_revision_id:
# Presupuesto nuevo, siempre requiere finance o superior
if budget.total_planned > Decimal("100000"):
return ApprovalTier.DIRECTOR
return ApprovalTier.FINANCE
# Es una revisión, calcular varianza
original = await self._budget_repo.get(budget.previous_revision_id)
if not original:
return ApprovalTier.FINANCE
variance_amount = abs(budget.total_planned - original.total_planned)
if original.total_planned > 0:
variance_percent = (variance_amount / original.total_planned) * 100
else:
variance_percent = Decimal("100") if variance_amount > 0 else Decimal("0")
# Determinar tier según porcentaje
if variance_percent <= self.APPROVAL_THRESHOLDS["minor_percent"]:
return ApprovalTier.MANAGER
elif variance_percent <= self.APPROVAL_THRESHOLDS["manager_percent"]:
return ApprovalTier.MANAGER
elif variance_percent <= self.APPROVAL_THRESHOLDS["finance_percent"]:
return ApprovalTier.FINANCE
elif variance_percent <= self.APPROVAL_THRESHOLDS["director_percent"]:
return ApprovalTier.DIRECTOR
else:
return ApprovalTier.BOARD
async def approve(
self,
budget_id: str,
approval_id: str,
user_id: str,
notes: Optional[str] = None
) -> "Budget":
"""Aprueba un presupuesto o revisión."""
budget = await self._budget_repo.get(budget_id)
if not budget:
raise DomainException(code="BUDGET_NOT_FOUND")
approval = await self._approval_repo.get(approval_id)
if not approval:
raise DomainException(code="APPROVAL_NOT_FOUND")
# Validar que el usuario puede aprobar
await self._validate_approver(approval, user_id)
# Registrar aprobación
await self._approval_repo.update(
approval_id=approval.id,
status="approved",
decision="approve",
decision_at=datetime.utcnow(),
decision_notes=notes
)
# Verificar si hay más aprobaciones pendientes
pending_approvals = await self._approval_repo.count_pending(budget_id)
if pending_approvals == 0:
# Todas las aprobaciones completadas
await self._finalize_approval(budget, user_id)
logger.info(
"Budget approval recorded",
budget_id=budget_id,
approval_id=approval_id,
user_id=user_id
)
return await self._budget_repo.get(budget_id)
async def _finalize_approval(self, budget: "Budget", user_id: str):
"""Finaliza el proceso de aprobación."""
# Actualizar estado
await self._budget_repo.update(
budget_id=budget.id,
state=BudgetState.APPROVED,
approved_at=datetime.utcnow(),
approved_by=user_id
)
# Crear snapshot post-aprobación
await self._create_snapshot(budget, "post_approval", user_id)
# Emitir evento
await self._event_bus.publish(
"budget.approved",
{
"budget_id": budget.id,
"revision_number": budget.revision_number
}
)
async def reject(
self,
budget_id: str,
approval_id: str,
user_id: str,
reason: str
) -> "Budget":
"""Rechaza un presupuesto o revisión."""
budget = await self._budget_repo.get(budget_id)
approval = await self._approval_repo.get(approval_id)
await self._validate_approver(approval, user_id)
# Registrar rechazo
await self._approval_repo.update(
approval_id=approval.id,
status="rejected",
decision="reject",
decision_at=datetime.utcnow(),
decision_notes=reason
)
# Retornar a draft
await self._budget_repo.update(
budget_id=budget.id,
state=BudgetState.DRAFT
)
# Notificar al creador
await self._notification_service.send_notification(
user_id=budget.created_by,
notification_type="in_app",
subject=f"Budget {budget.name} Rejected",
body_text=f"Your budget was rejected: {reason}"
)
return await self._budget_repo.get(budget_id)
async def get_revision_history(
self,
budget_id: str
) -> List["BudgetRevision"]:
"""
Obtiene el historial completo de revisiones de un presupuesto.
"""
# Encontrar el presupuesto raíz
budget = await self._budget_repo.get(budget_id)
root_id = await self._find_root_budget(budget_id)
# Obtener todas las revisiones en orden
return await self._revision_repo.find_by_original(
original_id=root_id,
include_nested=True
)
async def _find_root_budget(self, budget_id: str) -> str:
"""Encuentra el presupuesto original de la cadena de revisiones."""
current = await self._budget_repo.get(budget_id)
while current.previous_revision_id:
current = await self._budget_repo.get(current.previous_revision_id)
return current.id
async def compare_revisions(
self,
budget_id_1: str,
budget_id_2: str
) -> "RevisionComparison":
"""
Compara dos versiones de un presupuesto.
"""
budget_1 = await self._budget_repo.get_with_lines(budget_id_1)
budget_2 = await self._budget_repo.get_with_lines(budget_id_2)
if not budget_1 or not budget_2:
raise DomainException(code="BUDGET_NOT_FOUND")
# Construir comparación
comparison = RevisionComparison(
budget_1=budget_1,
budget_2=budget_2
)
# Comparar totales
comparison.total_planned_diff = budget_2.total_planned - budget_1.total_planned
comparison.total_planned_percent = (
(comparison.total_planned_diff / budget_1.total_planned * 100)
if budget_1.total_planned > 0 else Decimal("0")
)
# Comparar líneas
lines_1 = {self._line_key(l): l for l in budget_1.lines}
lines_2 = {self._line_key(l): l for l in budget_2.lines}
all_keys = set(lines_1.keys()) | set(lines_2.keys())
for key in all_keys:
line_1 = lines_1.get(key)
line_2 = lines_2.get(key)
if line_1 and line_2:
# Línea modificada
diff = line_2.planned_amount - line_1.planned_amount
if diff != 0:
comparison.line_changes.append({
"key": key,
"type": "modified",
"before": float(line_1.planned_amount),
"after": float(line_2.planned_amount),
"diff": float(diff),
"percent": float(diff / line_1.planned_amount * 100) if line_1.planned_amount else 0
})
elif line_1:
# Línea eliminada
comparison.line_changes.append({
"key": key,
"type": "removed",
"before": float(line_1.planned_amount),
"after": 0,
"diff": float(-line_1.planned_amount)
})
else:
# Línea añadida
comparison.line_changes.append({
"key": key,
"type": "added",
"before": 0,
"after": float(line_2.planned_amount),
"diff": float(line_2.planned_amount)
})
return comparison
def _line_key(self, line: "BudgetLine") -> str:
"""Genera clave única para una línea de presupuesto."""
return f"{line.budget_position_id}:{line.analytic_account_id or ''}:{line.cost_center_id or ''}"
async def _create_snapshot(
self,
budget: "Budget",
snapshot_type: str,
user_id: str
):
"""Crea snapshot del estado actual del presupuesto."""
budget_data = {
"header": {
"name": budget.name,
"code": budget.code,
"state": budget.state,
"revision_number": budget.revision_number,
"date_from": str(budget.date_from),
"date_to": str(budget.date_to)
},
"lines": [
{
"position_id": line.budget_position_id,
"analytic_id": line.analytic_account_id,
"planned": float(line.planned_amount),
"practical": float(line.practical_amount),
"theoretical": float(line.theoretical_amount)
}
for line in budget.lines
],
"totals": {
"planned": float(budget.total_planned),
"practical": float(budget.total_practical),
"committed": float(budget.total_committed)
}
}
await self._snapshot_repo.create(
budget_id=budget.id,
tenant_id=budget.tenant_id,
snapshot_type=snapshot_type,
snapshot_date=date.today(),
budget_data=budget_data,
created_by=user_id
)
async def _log_change(
self,
budget_id: str,
change_type: str,
field_name: str,
old_value: any,
new_value: any,
change_reason: Optional[str],
user_id: str
):
"""Registra cambio en el log de auditoría."""
await self._changelog_repo.create(
budget_id=budget_id,
change_type=change_type,
field_name=field_name,
old_value=str(old_value) if old_value else None,
new_value=str(new_value) if new_value else None,
change_reason=change_reason,
created_by=user_id
)
class RevisionComparison:
"""Resultado de comparación entre dos revisiones."""
def __init__(self, budget_1: "Budget", budget_2: "Budget"):
self.budget_1 = budget_1
self.budget_2 = budget_2
self.total_planned_diff: Decimal = Decimal("0")
self.total_planned_percent: Decimal = Decimal("0")
self.line_changes: List[Dict] = []
def to_dict(self) -> Dict:
return {
"budget_1": {
"id": self.budget_1.id,
"name": self.budget_1.name,
"revision": self.budget_1.revision_number,
"total_planned": float(self.budget_1.total_planned)
},
"budget_2": {
"id": self.budget_2.id,
"name": self.budget_2.name,
"revision": self.budget_2.revision_number,
"total_planned": float(self.budget_2.total_planned)
},
"summary": {
"total_planned_diff": float(self.total_planned_diff),
"total_planned_percent": float(self.total_planned_percent),
"lines_added": len([c for c in self.line_changes if c["type"] == "added"]),
"lines_modified": len([c for c in self.line_changes if c["type"] == "modified"]),
"lines_removed": len([c for c in self.line_changes if c["type"] == "removed"])
},
"line_changes": self.line_changes
}
3.3 API REST
# app/modules/budget/api/v1/budget_revisions.py
from datetime import date
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Depends, Query, Path, HTTPException, status
from pydantic import BaseModel, Field
from app.core.auth import get_current_user, require_permissions
from app.modules.budget.domain.services.budget_revision_service import (
BudgetRevisionService,
BudgetState,
RevisionType
)
router = APIRouter(prefix="/budgets", tags=["Budget Revisions"])
# =====================================================
# SCHEMAS
# =====================================================
class CreateRevisionRequest(BaseModel):
"""Request para crear revisión."""
reason: str = Field(..., min_length=10)
justification: Optional[str] = None
revision_type: Optional[str] = None
class SubmitForApprovalRequest(BaseModel):
"""Request para enviar a aprobación."""
notes: Optional[str] = None
class ApprovalDecisionRequest(BaseModel):
"""Request para decisión de aprobación."""
decision: str = Field(..., enum=["approve", "reject", "request_changes"])
notes: Optional[str] = None
class BudgetResponse(BaseModel):
"""Respuesta de presupuesto."""
id: str
name: str
code: Optional[str]
state: str
revision_number: int
previous_revision_id: Optional[str]
is_current_revision: bool
total_planned: Decimal
total_practical: Decimal
date_from: date
date_to: date
created_at: str
created_by: str
class RevisionHistoryResponse(BaseModel):
"""Respuesta de historial de revisiones."""
revision_number: int
budget_id: str
budget_name: str
revision_type: str
reason: str
changes_summary: dict
created_at: str
created_by: str
approved_at: Optional[str]
approved_by: Optional[str]
class RevisionComparisonResponse(BaseModel):
"""Respuesta de comparación de revisiones."""
budget_1: dict
budget_2: dict
summary: dict
line_changes: List[dict]
class ApprovalResponse(BaseModel):
"""Respuesta de aprobación."""
id: str
budget_id: str
approval_tier: str
status: str
approver_user_id: Optional[str]
decision: Optional[str]
decision_at: Optional[str]
decision_notes: Optional[str]
class ChangeLogResponse(BaseModel):
"""Respuesta de log de cambios."""
id: str
budget_id: str
change_type: str
field_name: Optional[str]
old_value: Optional[str]
new_value: Optional[str]
change_reason: Optional[str]
created_at: str
created_by: str
# =====================================================
# ENDPOINTS - REVISIONES
# =====================================================
@router.post("/{budget_id}/revisions", response_model=BudgetResponse)
@require_permissions(["budget:revise"])
async def create_revision(
budget_id: str = Path(...),
request: CreateRevisionRequest = None,
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Crea una nueva revisión de un presupuesto aprobado.
La revisión se crea como copia del original en estado draft,
permitiendo modificaciones. El presupuesto original se marca
como 'revised' y deja de ser la versión actual.
"""
revision = await revision_service.create_revision(
tenant_id=current_user.tenant_id,
budget_id=budget_id,
reason=request.reason,
justification=request.justification,
user_id=current_user.id,
revision_type=RevisionType(request.revision_type) if request.revision_type else None
)
return BudgetResponse(**revision.to_dict())
@router.get("/{budget_id}/revisions", response_model=List[RevisionHistoryResponse])
async def get_revision_history(
budget_id: str = Path(...),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Obtiene el historial completo de revisiones de un presupuesto.
Retorna todas las revisiones desde el presupuesto original,
incluyendo metadata de cada revisión.
"""
revisions = await revision_service.get_revision_history(budget_id)
return [RevisionHistoryResponse(**r.to_dict()) for r in revisions]
@router.get("/{budget_id}/revisions/compare", response_model=RevisionComparisonResponse)
async def compare_revisions(
budget_id: str = Path(...),
compare_with: str = Query(..., description="ID del presupuesto a comparar"),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Compara dos versiones de un presupuesto.
Retorna diferencias en totales y cambios línea por línea.
"""
comparison = await revision_service.compare_revisions(
budget_id_1=budget_id,
budget_id_2=compare_with
)
return RevisionComparisonResponse(**comparison.to_dict())
# =====================================================
# ENDPOINTS - WORKFLOW Y APROBACIONES
# =====================================================
@router.post("/{budget_id}/submit", response_model=BudgetResponse)
@require_permissions(["budget:submit"])
async def submit_for_approval(
budget_id: str = Path(...),
request: SubmitForApprovalRequest = None,
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Envía presupuesto para aprobación.
El sistema determina automáticamente el nivel de aprobación
requerido basado en el monto y porcentaje de cambio.
"""
budget = await revision_service.submit_for_approval(
budget_id=budget_id,
user_id=current_user.id
)
return BudgetResponse(**budget.to_dict())
@router.get("/{budget_id}/approvals", response_model=List[ApprovalResponse])
async def get_approvals(
budget_id: str = Path(...),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""Lista las aprobaciones de un presupuesto."""
approvals = await revision_service.get_approvals(budget_id)
return [ApprovalResponse(**a.to_dict()) for a in approvals]
@router.post("/{budget_id}/approvals/{approval_id}/decide", response_model=BudgetResponse)
@require_permissions(["budget:approve"])
async def decide_approval(
budget_id: str = Path(...),
approval_id: str = Path(...),
request: ApprovalDecisionRequest = None,
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Registra decisión de aprobación.
Si se aprueba y no hay más aprobaciones pendientes,
el presupuesto pasa a estado 'approved'.
"""
if request.decision == "approve":
budget = await revision_service.approve(
budget_id=budget_id,
approval_id=approval_id,
user_id=current_user.id,
notes=request.notes
)
elif request.decision == "reject":
if not request.notes:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Rejection requires notes"
)
budget = await revision_service.reject(
budget_id=budget_id,
approval_id=approval_id,
user_id=current_user.id,
reason=request.notes
)
else:
budget = await revision_service.request_changes(
budget_id=budget_id,
approval_id=approval_id,
user_id=current_user.id,
notes=request.notes
)
return BudgetResponse(**budget.to_dict())
# =====================================================
# ENDPOINTS - ESTADO Y LIFECYCLE
# =====================================================
@router.post("/{budget_id}/activate", response_model=BudgetResponse)
@require_permissions(["budget:activate"])
async def activate_budget(
budget_id: str = Path(...),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Activa un presupuesto aprobado.
El presupuesto pasa a estado 'active' y comienza a
compararse contra asientos contables reales.
"""
budget = await revision_service.activate(
budget_id=budget_id,
user_id=current_user.id
)
return BudgetResponse(**budget.to_dict())
@router.post("/{budget_id}/close", response_model=BudgetResponse)
@require_permissions(["budget:close"])
async def close_budget(
budget_id: str = Path(...),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Cierra un presupuesto al final del período.
Crea un snapshot final y marca el presupuesto como 'closed'.
"""
budget = await revision_service.close(
budget_id=budget_id,
user_id=current_user.id
)
return BudgetResponse(**budget.to_dict())
@router.post("/{budget_id}/reset-to-draft", response_model=BudgetResponse)
@require_permissions(["budget:reset"])
async def reset_to_draft(
budget_id: str = Path(...),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Retorna presupuesto a estado draft.
Solo disponible para presupuestos pending_approval o approved
(antes de activar). Requiere permiso especial.
"""
budget = await revision_service.reset_to_draft(
budget_id=budget_id,
user_id=current_user.id
)
return BudgetResponse(**budget.to_dict())
# =====================================================
# ENDPOINTS - AUDITORÍA
# =====================================================
@router.get("/{budget_id}/changelog", response_model=List[ChangeLogResponse])
async def get_changelog(
budget_id: str = Path(...),
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
change_type: Optional[str] = Query(None),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Obtiene el log de cambios del presupuesto.
Incluye todos los cambios realizados: creación, modificaciones,
cambios de estado, aprobaciones, etc.
"""
changes = await revision_service.get_changelog(
budget_id=budget_id,
from_date=from_date,
to_date=to_date,
change_type=change_type
)
return [ChangeLogResponse(**c.to_dict()) for c in changes]
@router.get("/{budget_id}/snapshots")
async def get_snapshots(
budget_id: str = Path(...),
snapshot_type: Optional[str] = Query(None),
revision_service: BudgetRevisionService = Depends(),
current_user = Depends(get_current_user)
):
"""
Lista snapshots del presupuesto.
Los snapshots son copias del estado del presupuesto en
momentos específicos (pre-revisión, post-aprobación, etc.).
"""
snapshots = await revision_service.get_snapshots(
budget_id=budget_id,
snapshot_type=snapshot_type
)
return snapshots
4. Reglas de Negocio
4.1 Estados y Transiciones
| Estado |
Transiciones Permitidas |
Acciones Disponibles |
draft |
→ pending_approval, → cancelled |
Editar, Submit, Cancel |
pending_approval |
→ approved, → draft |
Approve, Reject, Reset |
approved |
→ active, → revised, → draft |
Activate, Revise, Reset |
active |
→ revised, → closed |
Revise, Close |
revised |
- |
Solo lectura |
closed |
- |
Solo lectura |
cancelled |
- |
Solo lectura |
4.2 Niveles de Aprobación
| Variación |
Nivel Requerido |
| < 5% |
Manager |
| 5% - 10% |
Manager |
| 10% - 20% |
Finance Manager |
| 20% - 50% |
Director |
| > 50% |
Board/Executive |
4.3 Reglas de Revisión
| Regla |
Descripción |
| RN-REV-001 |
Solo presupuestos approved o active pueden revisarse |
| RN-REV-002 |
Una revisión hereda todas las líneas del original |
| RN-REV-003 |
El original se marca como revised al crear revisión |
| RN-REV-004 |
Solo una revisión puede ser current_revision=true |
| RN-REV-005 |
Cada revisión incrementa revision_number |
| RN-REV-006 |
La razón de revisión es obligatoria |
4.4 Reglas de Auditoría
| Regla |
Descripción |
| RN-AUD-001 |
Todo cambio de estado se registra en changelog |
| RN-AUD-002 |
Crear snapshot antes de cada revisión |
| RN-AUD-003 |
Crear snapshot después de cada aprobación |
| RN-AUD-004 |
Los snapshots son inmutables |
| RN-AUD-005 |
El changelog incluye usuario, fecha y razón |
5. Pruebas
5.1 Casos de Prueba
# tests/unit/budget/test_budget_revision_service.py
import pytest
from decimal import Decimal
from datetime import date
from unittest.mock import AsyncMock, MagicMock
from app.modules.budget.domain.services.budget_revision_service import (
BudgetRevisionService,
BudgetState,
ApprovalTier
)
class TestBudgetRevisionService:
"""Tests para BudgetRevisionService."""
@pytest.fixture
def service(self):
return BudgetRevisionService(
budget_repository=AsyncMock(),
revision_repository=AsyncMock(),
approval_repository=AsyncMock(),
snapshot_repository=AsyncMock(),
changelog_repository=AsyncMock(),
notification_service=AsyncMock(),
event_bus=AsyncMock()
)
async def test_create_revision_from_approved(self, service):
"""Debe crear revisión desde presupuesto aprobado."""
original = MagicMock(
id="budget-1",
tenant_id="tenant-1",
name="Budget 2024",
code="BUD-2024",
state=BudgetState.APPROVED,
revision_number=0,
total_planned=Decimal("100000"),
lines=[
MagicMock(
budget_position_id="pos-1",
analytic_account_id="anal-1",
planned_amount=Decimal("50000")
)
]
)
service._budget_repo.get_with_lines.return_value = original
service._budget_repo.create.return_value = MagicMock(
id="budget-2",
revision_number=1
)
revision = await service.create_revision(
tenant_id="tenant-1",
budget_id="budget-1",
reason="Adjustment for Q2",
justification="Market conditions changed",
user_id="user-1"
)
assert revision.revision_number == 1
service._snapshot_repo.create.assert_called_once()
async def test_create_revision_fails_for_draft(self, service):
"""No debe crear revisión desde borrador."""
original = MagicMock(state=BudgetState.DRAFT)
service._budget_repo.get_with_lines.return_value = original
with pytest.raises(Exception) as exc:
await service.create_revision(
tenant_id="tenant-1",
budget_id="budget-1",
reason="Test",
justification=None,
user_id="user-1"
)
assert "cannot be revised" in str(exc.value)
async def test_determine_approval_tier_minor_change(self, service):
"""Cambio <5% debe requerir solo manager."""
budget = MagicMock(
previous_revision_id="budget-orig",
total_planned=Decimal("102000")
)
original = MagicMock(total_planned=Decimal("100000"))
service._budget_repo.get.return_value = original
tier = await service._determine_approval_tier(budget)
assert tier == ApprovalTier.MANAGER
async def test_determine_approval_tier_major_change(self, service):
"""Cambio >50% debe requerir board."""
budget = MagicMock(
previous_revision_id="budget-orig",
total_planned=Decimal("200000") # 100% increase
)
original = MagicMock(total_planned=Decimal("100000"))
service._budget_repo.get.return_value = original
tier = await service._determine_approval_tier(budget)
assert tier == ApprovalTier.BOARD
async def test_compare_revisions(self, service):
"""Debe comparar correctamente dos revisiones."""
budget_1 = MagicMock(
id="v1",
name="Budget 2024",
revision_number=0,
total_planned=Decimal("100000"),
lines=[
MagicMock(
budget_position_id="pos-1",
analytic_account_id="anal-1",
cost_center_id=None,
planned_amount=Decimal("50000")
),
MagicMock(
budget_position_id="pos-2",
analytic_account_id="anal-1",
cost_center_id=None,
planned_amount=Decimal("50000")
)
]
)
budget_2 = MagicMock(
id="v2",
name="Budget 2024 - Rev1",
revision_number=1,
total_planned=Decimal("120000"),
lines=[
MagicMock(
budget_position_id="pos-1",
analytic_account_id="anal-1",
cost_center_id=None,
planned_amount=Decimal("60000") # +10000
),
MagicMock(
budget_position_id="pos-2",
analytic_account_id="anal-1",
cost_center_id=None,
planned_amount=Decimal("50000") # unchanged
),
MagicMock(
budget_position_id="pos-3",
analytic_account_id="anal-1",
cost_center_id=None,
planned_amount=Decimal("10000") # new
)
]
)
service._budget_repo.get_with_lines.side_effect = [budget_1, budget_2]
comparison = await service.compare_revisions("v1", "v2")
assert comparison.total_planned_diff == Decimal("20000")
assert len([c for c in comparison.line_changes if c["type"] == "modified"]) == 1
assert len([c for c in comparison.line_changes if c["type"] == "added"]) == 1
6. Reportes
6.1 Reporte de Comparación de Revisiones
-- Vista para comparación de revisiones
CREATE OR REPLACE VIEW v_budget_revision_comparison AS
WITH revision_chain AS (
SELECT
b.id,
b.name,
b.revision_number,
b.previous_revision_id,
b.total_planned,
b.total_practical,
b.state,
b.created_at,
b.approved_at
FROM budgets b
WHERE b.is_current_revision = TRUE
OR b.state = 'revised'
)
SELECT
curr.id AS current_id,
curr.name AS current_name,
curr.revision_number AS current_revision,
curr.total_planned AS current_planned,
prev.id AS previous_id,
prev.name AS previous_name,
prev.revision_number AS previous_revision,
prev.total_planned AS previous_planned,
(curr.total_planned - prev.total_planned) AS variance_amount,
CASE
WHEN prev.total_planned > 0
THEN ((curr.total_planned - prev.total_planned) / prev.total_planned * 100)
ELSE 0
END AS variance_percent
FROM revision_chain curr
LEFT JOIN revision_chain prev ON curr.previous_revision_id = prev.id;
6.2 Reporte de Historial de Cambios
-- Vista para historial de cambios
CREATE OR REPLACE VIEW v_budget_audit_trail AS
SELECT
bcl.id,
bcl.budget_id,
b.name AS budget_name,
bcl.change_type,
bcl.field_name,
bcl.old_value,
bcl.new_value,
bcl.change_reason,
bcl.created_at,
u.name AS changed_by
FROM budget_change_logs bcl
JOIN budgets b ON bcl.budget_id = b.id
JOIN users u ON bcl.created_by = u.id
ORDER BY bcl.created_at DESC;
7. Métricas y Monitoreo
7.1 Métricas Clave
| Métrica |
Tipo |
Descripción |
budget_revisions_total |
Counter |
Total de revisiones creadas |
budget_approvals_pending |
Gauge |
Aprobaciones pendientes |
budget_approval_time_seconds |
Histogram |
Tiempo de aprobación |
budget_revision_variance_percent |
Histogram |
Porcentaje de varianza en revisiones |
8. Referencias