Files
bakery-ia/services/orders/app/services/approval_rules_service.py
2025-10-29 06:58:05 +01:00

233 lines
9.1 KiB
Python

# services/orders/app/services/approval_rules_service.py
"""
Approval Rules Service - Smart auto-approval logic for purchase orders
Evaluates POs against configurable business rules to determine if auto-approval is appropriate
"""
from typing import Dict, List, Any, Optional, Tuple
from decimal import Decimal
from uuid import UUID
import structlog
from shared.config.base import BaseServiceSettings
from shared.utils.tenant_settings_client import TenantSettingsClient
logger = structlog.get_logger()
class ApprovalRulesService:
"""
Service for evaluating purchase orders against approval rules
Implements smart auto-approval logic based on multiple criteria
Uses tenant-specific settings from the database instead of system-level config
"""
def __init__(self, config: BaseServiceSettings, tenant_id: UUID):
self.config = config
self.tenant_id = tenant_id
# Initialize tenant settings client
tenant_service_url = getattr(config, 'TENANT_SERVICE_URL', 'http://tenant-service:8000')
self.tenant_settings_client = TenantSettingsClient(tenant_service_url=tenant_service_url)
async def evaluate_po_for_auto_approval(
self,
po_data: Dict[str, Any],
supplier_data: Optional[Dict[str, Any]] = None,
requirements_data: Optional[List[Dict[str, Any]]] = None
) -> Tuple[bool, List[str]]:
"""
Evaluate if a PO should be auto-approved using tenant-specific settings
Returns:
Tuple of (should_auto_approve, reasons)
"""
# Fetch tenant-specific procurement settings
try:
tenant_settings = await self.tenant_settings_client.get_procurement_settings(self.tenant_id)
except Exception as e:
logger.error("Failed to fetch tenant settings, using safe defaults",
tenant_id=str(self.tenant_id),
error=str(e))
# Use safe defaults if settings unavailable
tenant_settings = {
'auto_approve_enabled': False,
'auto_approve_threshold_eur': 500.0,
'auto_approve_min_supplier_score': 0.80,
'require_approval_new_suppliers': True,
'require_approval_critical_items': True
}
# Check if auto-approval is enabled for this tenant
if not tenant_settings.get('auto_approve_enabled', True):
return False, ["Auto-approval is disabled in tenant settings"]
reasons = []
should_approve = True
# Rule 1: Amount threshold check
total_amount = self._calculate_po_total(po_data)
threshold = Decimal(str(tenant_settings.get('auto_approve_threshold_eur', 500.0)))
if total_amount > threshold:
should_approve = False
reasons.append(
f"PO amount €{total_amount:.2f} exceeds threshold €{threshold:.2f}"
)
else:
reasons.append(
f"PO amount €{total_amount:.2f} within threshold €{threshold:.2f}"
)
# Rule 2: Supplier trust score check
min_supplier_score = tenant_settings.get('auto_approve_min_supplier_score', 0.80)
if supplier_data:
supplier_score = supplier_data.get('trust_score', 0.0)
is_preferred = supplier_data.get('is_preferred_supplier', False)
auto_approve_enabled = supplier_data.get('auto_approve_enabled', False)
if supplier_score < min_supplier_score:
should_approve = False
reasons.append(
f"Supplier trust score {supplier_score:.2f} below minimum {min_supplier_score:.2f}"
)
else:
reasons.append(f"Supplier trust score {supplier_score:.2f} meets minimum requirements")
if not is_preferred:
should_approve = False
reasons.append("Supplier is not marked as preferred")
else:
reasons.append("Supplier is a preferred supplier")
if not auto_approve_enabled:
should_approve = False
reasons.append("Auto-approve is disabled for this supplier")
else:
reasons.append("Auto-approve is enabled for this supplier")
elif supplier_data is None:
should_approve = False
reasons.append("No supplier data available")
# Rule 3: New supplier check
require_approval_new_suppliers = tenant_settings.get('require_approval_new_suppliers', True)
if supplier_data and require_approval_new_suppliers:
total_pos = supplier_data.get('total_pos_count', 0)
if total_pos < 5:
should_approve = False
reasons.append(f"New supplier with only {total_pos} previous orders (minimum 5 required)")
else:
reasons.append(f"Established supplier with {total_pos} previous orders")
# Rule 4: Critical/urgent items check
require_approval_critical_items = tenant_settings.get('require_approval_critical_items', True)
if requirements_data and require_approval_critical_items:
critical_count = sum(
1 for req in requirements_data
if req.get('priority') in ['critical', 'urgent', 'CRITICAL', 'URGENT']
)
if critical_count > 0:
should_approve = False
reasons.append(f"Contains {critical_count} critical/urgent items requiring manual review")
else:
reasons.append("No critical/urgent items detected")
# Rule 5: Historical approval rate check
if supplier_data:
total_pos = supplier_data.get('total_pos_count', 0)
approved_pos = supplier_data.get('approved_pos_count', 0)
if total_pos > 0:
approval_rate = approved_pos / total_pos
if approval_rate < 0.95:
should_approve = False
reasons.append(
f"Historical approval rate {approval_rate:.1%} below 95% threshold"
)
else:
reasons.append(f"High historical approval rate {approval_rate:.1%}")
# Rule 6: PO priority check
priority = po_data.get('priority', 'normal')
if priority in ['urgent', 'critical', 'URGENT', 'CRITICAL']:
should_approve = False
reasons.append(f"PO priority is '{priority}' - requires manual review")
logger.info(
"PO auto-approval evaluation completed",
should_auto_approve=should_approve,
total_amount=float(total_amount),
supplier_id=supplier_data.get('id') if supplier_data else None,
reasons_count=len(reasons),
po_data=po_data.get('id') if isinstance(po_data, dict) else str(po_data)
)
return should_approve, reasons
def _calculate_po_total(self, po_data: Dict[str, Any]) -> Decimal:
"""Calculate total PO amount including tax and shipping"""
subtotal = Decimal(str(po_data.get('subtotal', 0)))
tax = Decimal(str(po_data.get('tax_amount', 0)))
shipping = Decimal(str(po_data.get('shipping_cost', 0)))
discount = Decimal(str(po_data.get('discount_amount', 0)))
total = subtotal + tax + shipping - discount
return total
def get_approval_summary(
self,
should_approve: bool,
reasons: List[str]
) -> Dict[str, Any]:
"""
Generate a human-readable approval summary
Returns:
Dict with summary data for UI display
"""
return {
"auto_approved": should_approve,
"decision": "APPROVED" if should_approve else "REQUIRES_MANUAL_APPROVAL",
"reasons": reasons,
"reason_count": len(reasons),
"summary": self._format_summary(should_approve, reasons)
}
def _format_summary(self, should_approve: bool, reasons: List[str]) -> str:
"""Format approval decision summary"""
if should_approve:
return f"Auto-approved: {', '.join(reasons[:2])}"
else:
failing_reasons = [r for r in reasons if any(
keyword in r.lower()
for keyword in ['exceeds', 'below', 'not', 'disabled', 'new', 'critical']
)]
if failing_reasons:
return f"Manual approval required: {failing_reasons[0]}"
return "Manual approval required"
def validate_approval_override(
self,
override_reason: str,
user_role: str
) -> Tuple[bool, Optional[str]]:
"""
Validate if a user can override auto-approval decision
Returns:
Tuple of (is_valid, error_message)
"""
# Only admin/owner can override
if user_role not in ['admin', 'owner']:
return False, "Insufficient permissions to override approval rules"
# Require a reason
if not override_reason or len(override_reason.strip()) < 10:
return False, "Override reason must be at least 10 characters"
return True, None