erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-PRESUPUESTOS-REVISIONES.md

55 KiB

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:

  1. Reset to Draft: Permite edición directa pero sobrescribe datos
  2. Copy Budget: Crea nuevo presupuesto manualmente
  3. 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