erp-core/docs/04-modelado/especificaciones-tecnicas/transversal/SPEC-ALERTAS-PRESUPUESTO.md

53 KiB

SPEC-ALERTAS-PRESUPUESTO

Información del Documento

Campo Valor
Código Gap GAP-MGN-008-001
Módulo MGN-008 (Presupuestos y Control)
Título Alertas y Notificaciones por Exceso de Presupuesto
Prioridad P1
Complejidad Media
Referencia Odoo account_budget, mail.thread

1. Resumen Ejecutivo

1.1 Descripción del Gap

El sistema requiere alertas proactivas y notificaciones automáticas cuando el gasto real se aproxima o excede el presupuesto asignado, incluyendo validaciones que pueden bloquear transacciones sobre presupuesto.

1.2 Justificación de Negocio

  • Control preventivo: Alertar antes de exceder, no después
  • Gobernanza financiera: Asegurar cumplimiento de límites aprobados
  • Visibilidad en tiempo real: Dashboards con estado del presupuesto
  • Decisiones informadas: Permitir acciones correctivas a tiempo

1.3 Alcance

  • Cálculo automático de ejecución presupuestaria
  • Alertas por umbrales configurables (80%, 90%, 100%)
  • Notificaciones por email y en aplicación
  • Validaciones de bloqueo opcionales
  • Dashboards de estado presupuestario

2. Análisis de Referencia (Odoo)

2.1 Campos de Control en crossovered.budget.lines

# Odoo: addons/account_budget/models/crossovered_budget.py

class CrossoveredBudgetLines(models.Model):
    _name = 'crossovered.budget.lines'

    # Monto planificado (entrada manual)
    planned_amount = fields.Float(
        string='Planned Amount',
        required=True
    )

    # Monto real (calculado desde asientos contables)
    practical_amount = fields.Float(
        string='Practical Amount',
        compute='_compute_practical_amount',
        store=True
    )

    # Monto teórico (calculado por proporción temporal)
    theoritical_amount = fields.Float(
        string='Theoretical Amount',
        compute='_compute_theoritical_amount'
    )

    # Porcentaje de ejecución
    percentage = fields.Float(
        string='Achievement',
        compute='_compute_percentage'
    )

    def _compute_practical_amount(self):
        """Suma de asientos contables reales."""
        for line in self:
            acc_ids = line.general_budget_id.account_ids.ids
            date_from = line.date_from
            date_to = line.date_to

            if line.analytic_account_id:
                # Con cuenta analítica
                analytic_line_obj = self.env['account.analytic.line']
                domain = [
                    ('account_id', '=', line.analytic_account_id.id),
                    ('date', '>=', date_from),
                    ('date', '<=', date_to),
                    ('general_account_id', 'in', acc_ids)
                ]
                result = analytic_line_obj.search(domain)
                line.practical_amount = sum(result.mapped('amount'))
            else:
                # Sin cuenta analítica
                line.practical_amount = 0.0

    def _compute_theoritical_amount(self):
        """Monto esperado según avance temporal."""
        today = fields.Date.today()
        for line in self:
            if line.paid_date:
                if today <= line.paid_date:
                    theo_amt = 0.0
                else:
                    theo_amt = line.planned_amount
            else:
                line_timedelta = line.date_to - line.date_from
                elapsed_timedelta = today - line.date_from

                if elapsed_timedelta.days < 0:
                    theo_amt = 0.0
                elif line_timedelta.days == 0 or elapsed_timedelta.days >= line_timedelta.days:
                    theo_amt = line.planned_amount
                else:
                    theo_amt = (elapsed_timedelta.days / line_timedelta.days) * line.planned_amount

            line.theoritical_amount = theo_amt

    def _compute_percentage(self):
        """Porcentaje de ejecución respecto al teórico."""
        for line in self:
            if line.theoritical_amount != 0.0:
                line.percentage = (line.practical_amount / line.theoritical_amount) * 100
            else:
                line.percentage = 0.0

2.2 Limitación: Sin Alertas Nativas

Hallazgo crítico: Odoo 17 NO incluye sistema nativo de alertas. Solo proporciona:

  • Campos computed para tracking
  • Vistas con colores por porcentaje
  • Reportes de cumplimiento

Requiere extensión para:

  • Email notifications
  • In-app notifications
  • Validaciones de bloqueo
  • Alertas proactivas

2.3 Extensiones Disponibles (Marketplace)

Módulo Funcionalidad
dev_budget_exceed_notification Email al exceder presupuesto
account_budget_alert_app Validaciones y bloqueos
budget_warning_threshold Alertas por umbrales

3. Especificación Técnica

3.1 Modelo de Datos

-- =====================================================
-- CONFIGURACIÓN DE ALERTAS POR PRESUPUESTO
-- =====================================================

CREATE TABLE budget_alert_configs (
    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,

    -- Umbrales de alerta (porcentaje del presupuesto)
    warning_threshold DECIMAL(5, 2) NOT NULL DEFAULT 80.00,
    critical_threshold DECIMAL(5, 2) NOT NULL DEFAULT 95.00,
    exceed_threshold DECIMAL(5, 2) NOT NULL DEFAULT 100.00,

    -- Comportamiento al exceder
    exceed_action exceed_action_type NOT NULL DEFAULT 'warn',

    -- Notificaciones
    notify_on_warning BOOLEAN DEFAULT TRUE,
    notify_on_critical BOOLEAN DEFAULT TRUE,
    notify_on_exceed BOOLEAN DEFAULT TRUE,
    notify_daily_summary BOOLEAN DEFAULT FALSE,

    -- Destinatarios
    notification_user_ids UUID[] DEFAULT '{}',
    notification_email_extra TEXT[],  -- Emails adicionales externos

    -- Frecuencia de recordatorios
    reminder_frequency reminder_frequency_type DEFAULT 'once',
    last_warning_sent_at TIMESTAMPTZ,
    last_critical_sent_at TIMESTAMPTZ,
    last_exceed_sent_at TIMESTAMPTZ,

    -- 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_alert_config UNIQUE (budget_id, tenant_id)
);

-- Tipo de acción al exceder
CREATE TYPE exceed_action_type AS ENUM (
    'ignore',       -- Sin restricción
    'warn',         -- Warning popup, permite continuar
    'soft_block',   -- Requiere justificación para continuar
    'hard_block',   -- Bloquea completamente
    'approval'      -- Requiere aprobación de supervisor
);

-- Frecuencia de recordatorios
CREATE TYPE reminder_frequency_type AS ENUM (
    'once',         -- Solo una vez por nivel
    'daily',        -- Diariamente mientras esté en ese nivel
    'weekly',       -- Semanalmente
    'on_change'     -- Cada vez que cambie el monto
);

-- =====================================================
-- HISTORIAL DE ALERTAS
-- =====================================================

CREATE TABLE budget_alerts (
    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 y nivel de alerta
    alert_type budget_alert_type NOT NULL,
    alert_level alert_level_type NOT NULL,

    -- Valores al momento de la alerta
    planned_amount DECIMAL(20, 4) NOT NULL,
    practical_amount DECIMAL(20, 4) NOT NULL,
    percentage DECIMAL(10, 4) NOT NULL,
    threshold_triggered DECIMAL(5, 2) NOT NULL,

    -- Contexto adicional
    trigger_document_type VARCHAR(100),  -- 'invoice', 'purchase_order', etc.
    trigger_document_id UUID,
    trigger_amount DECIMAL(20, 4),       -- Monto que disparó la alerta

    -- Estado de la alerta
    status alert_status_type NOT NULL DEFAULT 'active',
    acknowledged_at TIMESTAMPTZ,
    acknowledged_by UUID REFERENCES users(id),
    resolution_notes TEXT,

    -- Notificaciones enviadas
    notification_sent BOOLEAN DEFAULT FALSE,
    notification_sent_at TIMESTAMPTZ,
    notification_recipients TEXT[],

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TYPE budget_alert_type AS ENUM (
    'threshold_reached',   -- Alcanzó umbral configurado
    'budget_exceeded',     -- Excedió 100%
    'rapid_consumption',   -- Consumo más rápido de lo esperado
    'stale_budget',        -- Presupuesto sin movimiento en X días
    'negative_variance',   -- Varianza negativa significativa
    'line_exceeded'        -- Línea específica excedida
);

CREATE TYPE alert_level_type AS ENUM (
    'info',       -- Informativo
    'warning',    -- Advertencia
    'critical',   -- Crítico
    'exceeded'    -- Excedido
);

CREATE TYPE alert_status_type AS ENUM (
    'active',        -- Alerta activa
    'acknowledged',  -- Usuario reconoció
    'resolved',      -- Resuelta (volvió a niveles normales)
    'dismissed',     -- Descartada manualmente
    'superseded'     -- Reemplazada por alerta más reciente
);

-- Índices para consultas frecuentes
CREATE INDEX idx_budget_alerts_active
    ON budget_alerts(tenant_id, status, created_at DESC)
    WHERE status = 'active';

CREATE INDEX idx_budget_alerts_budget
    ON budget_alerts(budget_id, created_at DESC);

-- =====================================================
-- REGLAS DE VALIDACIÓN POR TENANT
-- =====================================================

CREATE TABLE budget_validation_rules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,

    -- Aplicación
    rule_name VARCHAR(200) NOT NULL,
    description TEXT,
    is_active BOOLEAN DEFAULT TRUE,

    -- Condiciones
    applies_to_document_types TEXT[] DEFAULT '{"invoice", "purchase_order"}',
    applies_to_budget_positions UUID[],  -- Posiciones presupuestarias específicas
    min_amount_threshold DECIMAL(20, 4) DEFAULT 0,  -- Ignorar montos pequeños

    -- Umbrales específicos de la regla
    warning_at_percent DECIMAL(5, 2) DEFAULT 80.00,
    block_at_percent DECIMAL(5, 2) DEFAULT 100.00,

    -- Excepciones
    exempt_user_ids UUID[],  -- Usuarios que pueden saltarse esta regla
    exempt_analytic_accounts UUID[],

    -- Acción
    action_type exceed_action_type NOT NULL DEFAULT 'warn',
    requires_approval_from_role VARCHAR(100),

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_by UUID REFERENCES users(id),

    CONSTRAINT uq_validation_rule UNIQUE (tenant_id, rule_name)
);

-- =====================================================
-- COLAS DE NOTIFICACIÓN
-- =====================================================

CREATE TABLE budget_notification_queue (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    alert_id UUID NOT NULL REFERENCES budget_alerts(id) ON DELETE CASCADE,

    -- Tipo de notificación
    notification_type notification_type_enum NOT NULL,

    -- Destinatarios
    recipient_user_id UUID REFERENCES users(id),
    recipient_email VARCHAR(255),

    -- Contenido
    subject VARCHAR(500),
    body_html TEXT,
    body_text TEXT,

    -- Estado de envío
    status notification_status_type NOT NULL DEFAULT 'pending',
    scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    sent_at TIMESTAMPTZ,
    error_message TEXT,
    retry_count INTEGER DEFAULT 0,

    -- Auditoría
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TYPE notification_type_enum AS ENUM (
    'email',
    'in_app',
    'sms',
    'webhook'
);

CREATE TYPE notification_status_type AS ENUM (
    'pending',
    'sending',
    'sent',
    'failed',
    'cancelled'
);

-- Índice para procesamiento de cola
CREATE INDEX idx_notification_queue_pending
    ON budget_notification_queue(status, scheduled_at)
    WHERE status = 'pending';

3.2 Servicios de Dominio

# app/modules/budget/domain/services/budget_alert_service.py

from datetime import datetime, date, timedelta
from decimal import Decimal
from typing import Optional, List, Dict
from enum import Enum

from app.core.exceptions import DomainException
from app.core.logging import get_logger
from app.core.events import EventBus

logger = get_logger(__name__)


class AlertLevel(str, Enum):
    INFO = "info"
    WARNING = "warning"
    CRITICAL = "critical"
    EXCEEDED = "exceeded"


class ExceedAction(str, Enum):
    IGNORE = "ignore"
    WARN = "warn"
    SOFT_BLOCK = "soft_block"
    HARD_BLOCK = "hard_block"
    APPROVAL = "approval"


class BudgetAlertService:
    """Servicio de dominio para alertas presupuestarias."""

    def __init__(
        self,
        budget_repository: "BudgetRepository",
        alert_repository: "BudgetAlertRepository",
        config_repository: "BudgetAlertConfigRepository",
        notification_service: "NotificationService",
        event_bus: EventBus
    ):
        self._budget_repo = budget_repository
        self._alert_repo = alert_repository
        self._config_repo = config_repository
        self._notification_service = notification_service
        self._event_bus = event_bus

    async def evaluate_budget_status(
        self,
        tenant_id: str,
        budget_id: str,
        trigger_context: Optional[Dict] = None
    ) -> List["BudgetAlert"]:
        """
        Evalúa el estado de un presupuesto y genera alertas si corresponde.

        Args:
            tenant_id: ID del tenant
            budget_id: ID del presupuesto
            trigger_context: Contexto del documento que disparó la evaluación

        Returns:
            Lista de alertas generadas
        """
        budget = await self._budget_repo.get_with_lines(budget_id)
        if not budget:
            raise DomainException(
                code="BUDGET_NOT_FOUND",
                message=f"Budget {budget_id} not found"
            )

        config = await self._config_repo.get_by_budget(budget_id)
        if not config:
            # Usar configuración por defecto
            config = self._get_default_config(tenant_id, budget_id)

        alerts_generated = []

        # Evaluar presupuesto general
        total_planned = sum(line.planned_amount for line in budget.lines)
        total_practical = sum(line.practical_amount for line in budget.lines)

        if total_planned > 0:
            percentage = (total_practical / total_planned) * 100

            alert = await self._check_threshold_and_create_alert(
                tenant_id=tenant_id,
                budget_id=budget_id,
                budget_line_id=None,
                config=config,
                planned_amount=total_planned,
                practical_amount=total_practical,
                percentage=percentage,
                trigger_context=trigger_context
            )

            if alert:
                alerts_generated.append(alert)

        # Evaluar cada línea individualmente
        for line in budget.lines:
            if line.planned_amount > 0:
                line_percentage = (line.practical_amount / line.planned_amount) * 100

                line_alert = await self._check_threshold_and_create_alert(
                    tenant_id=tenant_id,
                    budget_id=budget_id,
                    budget_line_id=line.id,
                    config=config,
                    planned_amount=line.planned_amount,
                    practical_amount=line.practical_amount,
                    percentage=line_percentage,
                    trigger_context=trigger_context
                )

                if line_alert:
                    alerts_generated.append(line_alert)

        # Enviar notificaciones si hay nuevas alertas
        for alert in alerts_generated:
            await self._send_alert_notification(alert, config)

        return alerts_generated

    async def _check_threshold_and_create_alert(
        self,
        tenant_id: str,
        budget_id: str,
        budget_line_id: Optional[str],
        config: "BudgetAlertConfig",
        planned_amount: Decimal,
        practical_amount: Decimal,
        percentage: Decimal,
        trigger_context: Optional[Dict]
    ) -> Optional["BudgetAlert"]:
        """Verifica umbrales y crea alerta si corresponde."""

        # Determinar nivel de alerta
        alert_level = self._determine_alert_level(percentage, config)

        if not alert_level:
            # Porcentaje dentro de lo normal, verificar si hay alerta previa que resolver
            await self._resolve_previous_alerts(budget_id, budget_line_id)
            return None

        # Verificar si ya existe alerta activa del mismo nivel
        existing_alert = await self._alert_repo.find_active_alert(
            budget_id=budget_id,
            budget_line_id=budget_line_id,
            alert_level=alert_level
        )

        if existing_alert:
            # Ya existe alerta, verificar si debe enviarse recordatorio
            if self._should_send_reminder(existing_alert, config):
                await self._send_alert_notification(existing_alert, config)
            return None

        # Crear nueva alerta
        threshold_triggered = self._get_threshold_for_level(alert_level, config)

        alert = await self._alert_repo.create(
            tenant_id=tenant_id,
            budget_id=budget_id,
            budget_line_id=budget_line_id,
            alert_type="threshold_reached" if percentage < 100 else "budget_exceeded",
            alert_level=alert_level,
            planned_amount=planned_amount,
            practical_amount=practical_amount,
            percentage=percentage,
            threshold_triggered=threshold_triggered,
            trigger_document_type=trigger_context.get("document_type") if trigger_context else None,
            trigger_document_id=trigger_context.get("document_id") if trigger_context else None,
            trigger_amount=trigger_context.get("amount") if trigger_context else None
        )

        # Marcar alertas anteriores de menor nivel como superseded
        await self._supersede_lower_alerts(budget_id, budget_line_id, alert_level)

        # Emitir evento
        await self._event_bus.publish(
            "budget.alert.created",
            {
                "alert_id": alert.id,
                "budget_id": budget_id,
                "level": alert_level,
                "percentage": float(percentage)
            }
        )

        logger.warning(
            "Budget alert created",
            alert_id=alert.id,
            budget_id=budget_id,
            level=alert_level,
            percentage=float(percentage)
        )

        return alert

    def _determine_alert_level(
        self,
        percentage: Decimal,
        config: "BudgetAlertConfig"
    ) -> Optional[AlertLevel]:
        """Determina el nivel de alerta según porcentaje."""
        if percentage >= config.exceed_threshold:
            return AlertLevel.EXCEEDED
        elif percentage >= config.critical_threshold:
            return AlertLevel.CRITICAL
        elif percentage >= config.warning_threshold:
            return AlertLevel.WARNING
        else:
            return None

    def _get_threshold_for_level(
        self,
        level: AlertLevel,
        config: "BudgetAlertConfig"
    ) -> Decimal:
        """Obtiene el umbral configurado para un nivel."""
        if level == AlertLevel.EXCEEDED:
            return config.exceed_threshold
        elif level == AlertLevel.CRITICAL:
            return config.critical_threshold
        elif level == AlertLevel.WARNING:
            return config.warning_threshold
        return Decimal("0")

    async def _send_alert_notification(
        self,
        alert: "BudgetAlert",
        config: "BudgetAlertConfig"
    ):
        """Envía notificación de alerta."""
        # Verificar si debe notificar según nivel
        should_notify = (
            (alert.alert_level == AlertLevel.WARNING and config.notify_on_warning) or
            (alert.alert_level == AlertLevel.CRITICAL and config.notify_on_critical) or
            (alert.alert_level == AlertLevel.EXCEEDED and config.notify_on_exceed)
        )

        if not should_notify:
            return

        # Construir notificación
        notification_data = self._build_notification_content(alert)

        # Enviar a usuarios configurados
        for user_id in config.notification_user_ids:
            await self._notification_service.send_notification(
                user_id=user_id,
                notification_type="in_app",
                **notification_data
            )

            # También email
            await self._notification_service.send_notification(
                user_id=user_id,
                notification_type="email",
                **notification_data
            )

        # Enviar a emails adicionales
        for email in config.notification_email_extra or []:
            await self._notification_service.send_email(
                to_email=email,
                **notification_data
            )

        # Actualizar alerta
        await self._alert_repo.update(
            alert_id=alert.id,
            notification_sent=True,
            notification_sent_at=datetime.utcnow()
        )

    def _build_notification_content(self, alert: "BudgetAlert") -> Dict:
        """Construye contenido de la notificación."""
        level_emoji = {
            AlertLevel.WARNING: "Warning",
            AlertLevel.CRITICAL: "Critical",
            AlertLevel.EXCEEDED: "Alert - Budget Exceeded"
        }

        subject = f"{level_emoji.get(alert.alert_level, 'Info')}: Budget {alert.percentage:.1f}%"

        body = f"""
Budget Alert - {alert.alert_level.value.upper()}

Budget: {alert.budget_id}
Current Status: {alert.percentage:.1f}% consumed

Planned Amount: ${alert.planned_amount:,.2f}
Actual Amount: ${alert.practical_amount:,.2f}
Remaining: ${alert.planned_amount - alert.practical_amount:,.2f}

Threshold Triggered: {alert.threshold_triggered}%
"""

        if alert.trigger_document_type:
            body += f"\nTriggered by: {alert.trigger_document_type} ({alert.trigger_document_id})"
            if alert.trigger_amount:
                body += f"\nDocument Amount: ${alert.trigger_amount:,.2f}"

        return {
            "subject": subject,
            "body_text": body,
            "body_html": body.replace("\n", "<br>"),
            "priority": "high" if alert.alert_level == AlertLevel.EXCEEDED else "normal"
        }

    async def _resolve_previous_alerts(
        self,
        budget_id: str,
        budget_line_id: Optional[str]
    ):
        """Resuelve alertas previas si el presupuesto volvió a niveles normales."""
        active_alerts = await self._alert_repo.find_active_alerts(
            budget_id=budget_id,
            budget_line_id=budget_line_id
        )

        for alert in active_alerts:
            await self._alert_repo.update(
                alert_id=alert.id,
                status="resolved",
                resolution_notes="Budget returned to normal levels"
            )

    async def _supersede_lower_alerts(
        self,
        budget_id: str,
        budget_line_id: Optional[str],
        current_level: AlertLevel
    ):
        """Marca alertas de menor nivel como superseded."""
        level_order = [AlertLevel.INFO, AlertLevel.WARNING, AlertLevel.CRITICAL, AlertLevel.EXCEEDED]
        current_index = level_order.index(current_level)

        for level in level_order[:current_index]:
            alerts = await self._alert_repo.find_active_alerts(
                budget_id=budget_id,
                budget_line_id=budget_line_id,
                alert_level=level
            )
            for alert in alerts:
                await self._alert_repo.update(
                    alert_id=alert.id,
                    status="superseded"
                )


class BudgetValidationService:
    """Servicio para validación de transacciones contra presupuesto."""

    def __init__(
        self,
        budget_repository: "BudgetRepository",
        alert_service: BudgetAlertService,
        rule_repository: "ValidationRuleRepository"
    ):
        self._budget_repo = budget_repository
        self._alert_service = alert_service
        self._rule_repo = rule_repository

    async def validate_transaction(
        self,
        tenant_id: str,
        document_type: str,
        analytic_account_id: str,
        account_ids: List[str],
        amount: Decimal,
        date: date,
        user_id: str
    ) -> "ValidationResult":
        """
        Valida una transacción contra el presupuesto.

        Args:
            tenant_id: ID del tenant
            document_type: Tipo de documento ('invoice', 'purchase_order', etc.)
            analytic_account_id: Cuenta analítica
            account_ids: Cuentas contables involucradas
            amount: Monto de la transacción
            date: Fecha de la transacción
            user_id: Usuario que realiza la transacción

        Returns:
            ValidationResult con el resultado y acción requerida
        """
        # Buscar líneas de presupuesto que aplican
        budget_lines = await self._budget_repo.find_lines_by_criteria(
            tenant_id=tenant_id,
            analytic_account_id=analytic_account_id,
            account_ids=account_ids,
            date=date
        )

        if not budget_lines:
            # Sin presupuesto, permitir
            return ValidationResult(
                is_valid=True,
                action=ExceedAction.IGNORE,
                message="No budget found for this transaction"
            )

        # Verificar cada línea
        results = []
        for line in budget_lines:
            result = await self._validate_against_line(
                line=line,
                amount=amount,
                document_type=document_type,
                user_id=user_id,
                tenant_id=tenant_id
            )
            results.append(result)

        # Retornar el resultado más restrictivo
        return self._get_most_restrictive_result(results)

    async def _validate_against_line(
        self,
        line: "BudgetLine",
        amount: Decimal,
        document_type: str,
        user_id: str,
        tenant_id: str
    ) -> "ValidationResult":
        """Valida contra una línea de presupuesto específica."""
        # Obtener reglas aplicables
        rules = await self._rule_repo.find_applicable_rules(
            tenant_id=tenant_id,
            document_type=document_type,
            budget_position_id=line.general_budget_id
        )

        if not rules:
            # Sin reglas, usar configuración por defecto
            rules = [self._get_default_rule()]

        # Calcular nuevo total
        new_total = line.practical_amount + amount
        new_percentage = (new_total / line.planned_amount * 100) if line.planned_amount > 0 else 0

        # Evaluar cada regla
        for rule in rules:
            # Verificar exempciones
            if user_id in (rule.exempt_user_ids or []):
                continue

            if amount < rule.min_amount_threshold:
                continue

            # Verificar umbrales
            if new_percentage >= rule.block_at_percent:
                if rule.action_type == ExceedAction.HARD_BLOCK:
                    return ValidationResult(
                        is_valid=False,
                        action=ExceedAction.HARD_BLOCK,
                        message=f"Transaction would exceed budget limit ({new_percentage:.1f}%)",
                        budget_line_id=line.id,
                        current_percentage=float(new_percentage),
                        remaining_amount=max(0, line.planned_amount - line.practical_amount)
                    )

                elif rule.action_type == ExceedAction.APPROVAL:
                    return ValidationResult(
                        is_valid=False,
                        action=ExceedAction.APPROVAL,
                        message=f"Transaction requires approval (would reach {new_percentage:.1f}%)",
                        budget_line_id=line.id,
                        current_percentage=float(new_percentage),
                        requires_approval_from=rule.requires_approval_from_role
                    )

                elif rule.action_type == ExceedAction.SOFT_BLOCK:
                    return ValidationResult(
                        is_valid=True,
                        action=ExceedAction.SOFT_BLOCK,
                        message=f"Budget would be exceeded ({new_percentage:.1f}%). Justification required.",
                        budget_line_id=line.id,
                        requires_justification=True
                    )

            elif new_percentage >= rule.warning_at_percent:
                return ValidationResult(
                    is_valid=True,
                    action=ExceedAction.WARN,
                    message=f"This transaction will bring budget to {new_percentage:.1f}%",
                    budget_line_id=line.id,
                    current_percentage=float(new_percentage)
                )

        # Pasó todas las validaciones
        return ValidationResult(is_valid=True, action=ExceedAction.IGNORE)

    def _get_most_restrictive_result(
        self,
        results: List["ValidationResult"]
    ) -> "ValidationResult":
        """Retorna el resultado más restrictivo."""
        action_priority = {
            ExceedAction.HARD_BLOCK: 4,
            ExceedAction.APPROVAL: 3,
            ExceedAction.SOFT_BLOCK: 2,
            ExceedAction.WARN: 1,
            ExceedAction.IGNORE: 0
        }

        return max(results, key=lambda r: action_priority.get(r.action, 0))


class ValidationResult:
    """Resultado de validación presupuestaria."""

    def __init__(
        self,
        is_valid: bool,
        action: ExceedAction,
        message: str = "",
        budget_line_id: Optional[str] = None,
        current_percentage: Optional[float] = None,
        remaining_amount: Optional[Decimal] = None,
        requires_justification: bool = False,
        requires_approval_from: Optional[str] = None
    ):
        self.is_valid = is_valid
        self.action = action
        self.message = message
        self.budget_line_id = budget_line_id
        self.current_percentage = current_percentage
        self.remaining_amount = remaining_amount
        self.requires_justification = requires_justification
        self.requires_approval_from = requires_approval_from

    def to_dict(self) -> Dict:
        return {
            "is_valid": self.is_valid,
            "action": self.action.value,
            "message": self.message,
            "budget_line_id": self.budget_line_id,
            "current_percentage": self.current_percentage,
            "remaining_amount": float(self.remaining_amount) if self.remaining_amount else None,
            "requires_justification": self.requires_justification,
            "requires_approval_from": self.requires_approval_from
        }

3.3 Scheduler para Alertas Proactivas

# app/modules/budget/infrastructure/scheduler/alert_scheduler.py

from datetime import datetime, timedelta
from typing import List
import asyncio

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger

from app.core.logging import get_logger
from app.modules.budget.domain.services.budget_alert_service import (
    BudgetAlertService,
    AlertLevel
)

logger = get_logger(__name__)


class BudgetAlertScheduler:
    """
    Scheduler para evaluación periódica de presupuestos.

    Ejecuta verificaciones proactivas para detectar presupuestos
    que se acercan a sus límites sin esperar transacciones.
    """

    def __init__(
        self,
        alert_service: BudgetAlertService,
        budget_repository: "BudgetRepository",
        tenant_service: "TenantService"
    ):
        self._alert_service = alert_service
        self._budget_repo = budget_repository
        self._tenant_service = tenant_service
        self._scheduler = AsyncIOScheduler()

    async def start(self):
        """Inicia el scheduler."""
        # Verificación cada 4 horas durante horario laboral
        self._scheduler.add_job(
            self._evaluate_all_budgets,
            trigger=CronTrigger(hour='8,12,16', minute=0),
            id="budget_alert_check",
            name="Budget Alert Evaluation",
            replace_existing=True
        )

        # Resumen diario a las 8 AM
        self._scheduler.add_job(
            self._send_daily_summary,
            trigger=CronTrigger(hour=8, minute=0),
            id="budget_daily_summary",
            name="Budget Daily Summary",
            replace_existing=True
        )

        # Verificación de alertas estancadas (weekly)
        self._scheduler.add_job(
            self._check_stale_alerts,
            trigger=CronTrigger(day_of_week='mon', hour=9, minute=0),
            id="stale_alert_check",
            name="Stale Alert Check",
            replace_existing=True
        )

        self._scheduler.start()
        logger.info("Budget alert scheduler started")

    async def _evaluate_all_budgets(self):
        """Evalúa todos los presupuestos activos."""
        try:
            tenants = await self._tenant_service.get_all_active()

            for tenant in tenants:
                await self._evaluate_tenant_budgets(tenant.id)

        except Exception as e:
            logger.exception("Error evaluating budgets")

    async def _evaluate_tenant_budgets(self, tenant_id: str):
        """Evalúa presupuestos de un tenant."""
        today = datetime.now().date()

        # Obtener presupuestos activos
        active_budgets = await self._budget_repo.find_active_budgets(
            tenant_id=tenant_id,
            date=today
        )

        semaphore = asyncio.Semaphore(5)

        async def evaluate_with_limit(budget):
            async with semaphore:
                try:
                    await self._alert_service.evaluate_budget_status(
                        tenant_id=tenant_id,
                        budget_id=budget.id
                    )
                except Exception as e:
                    logger.error(
                        f"Error evaluating budget {budget.id}: {e}",
                        tenant_id=tenant_id
                    )

        await asyncio.gather(*[
            evaluate_with_limit(b) for b in active_budgets
        ])

    async def _send_daily_summary(self):
        """Envía resumen diario de estado presupuestario."""
        try:
            tenants = await self._tenant_service.get_all_active()

            for tenant in tenants:
                await self._send_tenant_summary(tenant.id)

        except Exception as e:
            logger.exception("Error sending daily summary")

    async def _send_tenant_summary(self, tenant_id: str):
        """Genera y envía resumen para un tenant."""
        today = datetime.now().date()

        # Obtener estadísticas
        stats = await self._budget_repo.get_budget_stats(
            tenant_id=tenant_id,
            date=today
        )

        # Obtener alertas activas
        active_alerts = await self._alert_service._alert_repo.count_by_level(
            tenant_id=tenant_id,
            status="active"
        )

        # Solo enviar si hay algo que reportar
        if active_alerts.get("total", 0) == 0 and not stats.get("at_risk"):
            return

        # Construir y enviar resumen
        summary = self._build_daily_summary(stats, active_alerts)

        # Obtener configuraciones que tienen resumen diario habilitado
        configs = await self._alert_service._config_repo.find_with_daily_summary(
            tenant_id=tenant_id
        )

        for config in configs:
            await self._alert_service._notification_service.send_summary(
                user_ids=config.notification_user_ids,
                summary=summary
            )

    def _build_daily_summary(self, stats: dict, alerts: dict) -> dict:
        """Construye contenido del resumen diario."""
        return {
            "subject": f"Budget Status Summary - {datetime.now().strftime('%Y-%m-%d')}",
            "total_budgets": stats.get("total", 0),
            "healthy_budgets": stats.get("healthy", 0),
            "at_risk_budgets": stats.get("at_risk", 0),
            "exceeded_budgets": stats.get("exceeded", 0),
            "active_alerts": {
                "warning": alerts.get(AlertLevel.WARNING, 0),
                "critical": alerts.get(AlertLevel.CRITICAL, 0),
                "exceeded": alerts.get(AlertLevel.EXCEEDED, 0)
            },
            "total_active_alerts": alerts.get("total", 0)
        }

    async def _check_stale_alerts(self):
        """Verifica alertas sin acción por mucho tiempo."""
        try:
            cutoff = datetime.utcnow() - timedelta(days=7)

            stale_alerts = await self._alert_service._alert_repo.find_stale(
                older_than=cutoff,
                status="active"
            )

            for alert in stale_alerts:
                # Escalar o enviar recordatorio
                logger.warning(
                    f"Stale alert detected: {alert.id}",
                    budget_id=alert.budget_id,
                    days_old=(datetime.utcnow() - alert.created_at).days
                )

        except Exception as e:
            logger.exception("Error checking stale alerts")

3.4 API REST

# app/modules/budget/api/v1/budget_alerts.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.core.pagination import PaginatedResponse, PaginationParams
from app.modules.budget.domain.services.budget_alert_service import (
    BudgetAlertService,
    BudgetValidationService,
    AlertLevel,
    ExceedAction
)

router = APIRouter(prefix="/budget-alerts", tags=["Budget Alerts"])


# =====================================================
# SCHEMAS
# =====================================================

class AlertConfigRequest(BaseModel):
    """Request para configuración de alertas."""
    warning_threshold: Decimal = Field(default=80.00, ge=0, le=100)
    critical_threshold: Decimal = Field(default=95.00, ge=0, le=100)
    exceed_threshold: Decimal = Field(default=100.00, ge=0, le=100)
    exceed_action: str = Field(default="warn")
    notify_on_warning: bool = True
    notify_on_critical: bool = True
    notify_on_exceed: bool = True
    notification_user_ids: List[str] = []
    notification_email_extra: List[str] = []


class AlertConfigResponse(BaseModel):
    """Respuesta de configuración de alertas."""
    budget_id: str
    warning_threshold: Decimal
    critical_threshold: Decimal
    exceed_threshold: Decimal
    exceed_action: str
    notify_on_warning: bool
    notify_on_critical: bool
    notify_on_exceed: bool
    notification_user_ids: List[str]
    notification_email_extra: List[str]


class AlertResponse(BaseModel):
    """Respuesta de alerta."""
    id: str
    budget_id: str
    budget_line_id: Optional[str]
    alert_type: str
    alert_level: str
    planned_amount: Decimal
    practical_amount: Decimal
    percentage: Decimal
    threshold_triggered: Decimal
    status: str
    created_at: str
    acknowledged_at: Optional[str]
    acknowledged_by: Optional[str]


class AlertAcknowledgeRequest(BaseModel):
    """Request para reconocer alerta."""
    notes: Optional[str] = None


class ValidationRequest(BaseModel):
    """Request para validar transacción."""
    document_type: str
    analytic_account_id: str
    account_ids: List[str]
    amount: Decimal
    date: date


class ValidationResponse(BaseModel):
    """Respuesta de validación."""
    is_valid: bool
    action: str
    message: str
    budget_line_id: Optional[str]
    current_percentage: Optional[float]
    remaining_amount: Optional[float]
    requires_justification: bool
    requires_approval_from: Optional[str]


class BudgetStatusResponse(BaseModel):
    """Estado general del presupuesto."""
    budget_id: str
    budget_name: str
    total_planned: Decimal
    total_practical: Decimal
    total_committed: Decimal
    total_available: Decimal
    percentage: float
    status: str  # healthy, warning, critical, exceeded
    active_alerts: int


# =====================================================
# ENDPOINTS
# =====================================================

@router.get("/budgets/{budget_id}/status", response_model=BudgetStatusResponse)
async def get_budget_status(
    budget_id: str = Path(...),
    alert_service: BudgetAlertService = Depends(),
    current_user = Depends(get_current_user)
):
    """Obtiene el estado actual del presupuesto."""
    status = await alert_service.get_budget_status(
        tenant_id=current_user.tenant_id,
        budget_id=budget_id
    )
    return BudgetStatusResponse(**status)


@router.get("/budgets/{budget_id}/alerts", response_model=List[AlertResponse])
async def get_budget_alerts(
    budget_id: str = Path(...),
    status: Optional[str] = Query(None, enum=["active", "acknowledged", "resolved"]),
    level: Optional[str] = Query(None, enum=["warning", "critical", "exceeded"]),
    alert_service: BudgetAlertService = Depends(),
    current_user = Depends(get_current_user)
):
    """Lista alertas de un presupuesto."""
    alerts = await alert_service.get_alerts(
        tenant_id=current_user.tenant_id,
        budget_id=budget_id,
        status=status,
        level=level
    )
    return [AlertResponse(**a.dict()) for a in alerts]


@router.post("/budgets/{budget_id}/alerts/{alert_id}/acknowledge")
@require_permissions(["budget_alerts:acknowledge"])
async def acknowledge_alert(
    budget_id: str = Path(...),
    alert_id: str = Path(...),
    request: AlertAcknowledgeRequest = None,
    alert_service: BudgetAlertService = Depends(),
    current_user = Depends(get_current_user)
):
    """Reconoce una alerta."""
    await alert_service.acknowledge_alert(
        alert_id=alert_id,
        user_id=current_user.id,
        notes=request.notes if request else None
    )
    return {"status": "acknowledged"}


@router.get("/budgets/{budget_id}/config", response_model=AlertConfigResponse)
async def get_alert_config(
    budget_id: str = Path(...),
    alert_service: BudgetAlertService = Depends(),
    current_user = Depends(get_current_user)
):
    """Obtiene configuración de alertas del presupuesto."""
    config = await alert_service.get_config(
        budget_id=budget_id
    )
    return AlertConfigResponse(**config.dict())


@router.put("/budgets/{budget_id}/config", response_model=AlertConfigResponse)
@require_permissions(["budget_alerts:configure"])
async def update_alert_config(
    budget_id: str = Path(...),
    request: AlertConfigRequest = None,
    alert_service: BudgetAlertService = Depends(),
    current_user = Depends(get_current_user)
):
    """Actualiza configuración de alertas."""
    config = await alert_service.update_config(
        budget_id=budget_id,
        **request.dict()
    )
    return AlertConfigResponse(**config.dict())


@router.post("/validate", response_model=ValidationResponse)
async def validate_transaction(
    request: ValidationRequest,
    validation_service: BudgetValidationService = Depends(),
    current_user = Depends(get_current_user)
):
    """
    Valida una transacción contra el presupuesto.

    Retorna si la transacción es válida y qué acción tomar.
    """
    result = await validation_service.validate_transaction(
        tenant_id=current_user.tenant_id,
        document_type=request.document_type,
        analytic_account_id=request.analytic_account_id,
        account_ids=request.account_ids,
        amount=request.amount,
        date=request.date,
        user_id=current_user.id
    )
    return ValidationResponse(**result.to_dict())


# =====================================================
# DASHBOARD
# =====================================================

class DashboardSummary(BaseModel):
    """Resumen para dashboard."""
    total_budgets: int
    healthy: int
    at_risk: int
    exceeded: int
    total_active_alerts: int
    alerts_by_level: dict
    budgets_at_risk: List[BudgetStatusResponse]


@router.get("/dashboard", response_model=DashboardSummary)
async def get_dashboard(
    alert_service: BudgetAlertService = Depends(),
    current_user = Depends(get_current_user)
):
    """Obtiene resumen para dashboard de presupuestos."""
    summary = await alert_service.get_dashboard_summary(
        tenant_id=current_user.tenant_id
    )
    return DashboardSummary(**summary)

4. Reglas de Negocio

4.1 Umbrales y Alertas

Regla Descripción
RN-ALT-001 Umbral warning por defecto: 80% del presupuesto
RN-ALT-002 Umbral critical por defecto: 95% del presupuesto
RN-ALT-003 Umbral exceeded: 100% del presupuesto
RN-ALT-004 Los umbrales deben ser: warning < critical < exceed
RN-ALT-005 Una alerta de nivel superior supersede alertas menores

4.2 Validaciones

Regla Descripción
RN-ALT-006 Validación solo aplica si existe línea de presupuesto activa
RN-ALT-007 Usuarios exentos pueden saltarse validaciones
RN-ALT-008 Montos menores al umbral mínimo no validan
RN-ALT-009 Hard block impide completamente la transacción
RN-ALT-010 Soft block requiere justificación pero permite continuar

4.3 Notificaciones

Regla Descripción
RN-ALT-011 No enviar duplicados del mismo nivel
RN-ALT-012 Recordatorios según frecuencia configurada
RN-ALT-013 Resumen diario solo si hay alertas activas
RN-ALT-014 Escalar alertas sin acción después de 7 días

5. Pruebas

5.1 Casos de Prueba

# tests/unit/budget/test_budget_alert_service.py

import pytest
from decimal import Decimal
from datetime import date, timedelta
from unittest.mock import AsyncMock, MagicMock

from app.modules.budget.domain.services.budget_alert_service import (
    BudgetAlertService,
    BudgetValidationService,
    AlertLevel,
    ExceedAction,
    ValidationResult
)


class TestBudgetAlertService:
    """Tests para BudgetAlertService."""

    @pytest.fixture
    def service(self):
        return BudgetAlertService(
            budget_repository=AsyncMock(),
            alert_repository=AsyncMock(),
            config_repository=AsyncMock(),
            notification_service=AsyncMock(),
            event_bus=AsyncMock()
        )

    async def test_determine_alert_level_warning(self, service):
        """Debe retornar WARNING entre 80-95%."""
        config = MagicMock(
            warning_threshold=Decimal("80"),
            critical_threshold=Decimal("95"),
            exceed_threshold=Decimal("100")
        )

        level = service._determine_alert_level(Decimal("85"), config)
        assert level == AlertLevel.WARNING

    async def test_determine_alert_level_critical(self, service):
        """Debe retornar CRITICAL entre 95-100%."""
        config = MagicMock(
            warning_threshold=Decimal("80"),
            critical_threshold=Decimal("95"),
            exceed_threshold=Decimal("100")
        )

        level = service._determine_alert_level(Decimal("97"), config)
        assert level == AlertLevel.CRITICAL

    async def test_determine_alert_level_exceeded(self, service):
        """Debe retornar EXCEEDED >= 100%."""
        config = MagicMock(
            warning_threshold=Decimal("80"),
            critical_threshold=Decimal("95"),
            exceed_threshold=Decimal("100")
        )

        level = service._determine_alert_level(Decimal("105"), config)
        assert level == AlertLevel.EXCEEDED

    async def test_determine_alert_level_none_below_warning(self, service):
        """Debe retornar None si está por debajo del warning."""
        config = MagicMock(
            warning_threshold=Decimal("80"),
            critical_threshold=Decimal("95"),
            exceed_threshold=Decimal("100")
        )

        level = service._determine_alert_level(Decimal("50"), config)
        assert level is None


class TestBudgetValidationService:
    """Tests para BudgetValidationService."""

    @pytest.fixture
    def service(self):
        return BudgetValidationService(
            budget_repository=AsyncMock(),
            alert_service=AsyncMock(),
            rule_repository=AsyncMock()
        )

    async def test_validation_no_budget_allows(self, service):
        """Debe permitir si no hay presupuesto."""
        service._budget_repo.find_lines_by_criteria.return_value = []

        result = await service.validate_transaction(
            tenant_id="tenant-1",
            document_type="invoice",
            analytic_account_id="analytic-1",
            account_ids=["acc-1"],
            amount=Decimal("1000"),
            date=date.today(),
            user_id="user-1"
        )

        assert result.is_valid is True
        assert result.action == ExceedAction.IGNORE

    async def test_validation_hard_block_exceeds(self, service):
        """Debe bloquear con hard_block si excede."""
        # Setup mock budget line
        mock_line = MagicMock(
            id="line-1",
            planned_amount=Decimal("10000"),
            practical_amount=Decimal("9500"),
            general_budget_id="position-1"
        )
        service._budget_repo.find_lines_by_criteria.return_value = [mock_line]

        # Setup mock rule
        mock_rule = MagicMock(
            exempt_user_ids=[],
            min_amount_threshold=Decimal("0"),
            warning_at_percent=Decimal("80"),
            block_at_percent=Decimal("100"),
            action_type=ExceedAction.HARD_BLOCK
        )
        service._rule_repo.find_applicable_rules.return_value = [mock_rule]

        result = await service.validate_transaction(
            tenant_id="tenant-1",
            document_type="invoice",
            analytic_account_id="analytic-1",
            account_ids=["acc-1"],
            amount=Decimal("1000"),  # Esto llevaría a 105%
            date=date.today(),
            user_id="user-1"
        )

        assert result.is_valid is False
        assert result.action == ExceedAction.HARD_BLOCK

    async def test_validation_warn_near_limit(self, service):
        """Debe advertir si se acerca al límite."""
        mock_line = MagicMock(
            id="line-1",
            planned_amount=Decimal("10000"),
            practical_amount=Decimal("7500"),
            general_budget_id="position-1"
        )
        service._budget_repo.find_lines_by_criteria.return_value = [mock_line]

        mock_rule = MagicMock(
            exempt_user_ids=[],
            min_amount_threshold=Decimal("0"),
            warning_at_percent=Decimal("80"),
            block_at_percent=Decimal("100"),
            action_type=ExceedAction.WARN
        )
        service._rule_repo.find_applicable_rules.return_value = [mock_rule]

        result = await service.validate_transaction(
            tenant_id="tenant-1",
            document_type="invoice",
            analytic_account_id="analytic-1",
            account_ids=["acc-1"],
            amount=Decimal("500"),  # Esto llevaría a 80%
            date=date.today(),
            user_id="user-1"
        )

        assert result.is_valid is True
        assert result.action == ExceedAction.WARN

    async def test_validation_exempt_user_bypasses(self, service):
        """Usuario exento debe poder saltarse validación."""
        mock_line = MagicMock(
            id="line-1",
            planned_amount=Decimal("10000"),
            practical_amount=Decimal("9500"),
            general_budget_id="position-1"
        )
        service._budget_repo.find_lines_by_criteria.return_value = [mock_line]

        # Usuario exento
        mock_rule = MagicMock(
            exempt_user_ids=["user-exempt"],
            min_amount_threshold=Decimal("0"),
            warning_at_percent=Decimal("80"),
            block_at_percent=Decimal("100"),
            action_type=ExceedAction.HARD_BLOCK
        )
        service._rule_repo.find_applicable_rules.return_value = [mock_rule]

        result = await service.validate_transaction(
            tenant_id="tenant-1",
            document_type="invoice",
            analytic_account_id="analytic-1",
            account_ids=["acc-1"],
            amount=Decimal("1000"),
            date=date.today(),
            user_id="user-exempt"  # Usuario exento
        )

        assert result.is_valid is True

6. Métricas y Monitoreo

6.1 Métricas Clave

Métrica Tipo Descripción
budget_alerts_total Counter Total de alertas generadas
budget_alerts_by_level Counter Alertas por nivel (warning/critical/exceeded)
budget_validations_total Counter Total de validaciones ejecutadas
budget_validations_blocked Counter Validaciones bloqueadas
budget_notification_sent Counter Notificaciones enviadas
budget_execution_percentage Gauge Porcentaje de ejecución actual

6.2 Dashboards

# Dashboard panels
panels:
  - title: "Budgets at Risk"
    type: stat
    query: budget_alerts_by_level{level="critical"} + budget_alerts_by_level{level="exceeded"}

  - title: "Alert Response Time"
    type: histogram
    query: budget_alert_acknowledgement_time_seconds

  - title: "Validation Blocks Today"
    type: counter
    query: increase(budget_validations_blocked[24h])

7. Referencias