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