# 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` ```python # 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 ```sql -- ===================================================== -- 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 ```python # 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", "
"), "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 ```python # 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 ```python # 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 ```python # 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 ```yaml # 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 - [Odoo account_budget](https://www.odoo.com/documentation/17.0/applications/finance/accounting/reporting/budget.html) - [Budget Exceed Notification - Odoo Apps](https://apps.odoo.com/apps/modules/17.0/dev_budget_exceed_notification/) - [Account Budget Alert - Odoo Apps](https://apps.odoo.com/apps/modules/13.0/account_budget_alert_app/)