Improve the frontend 3

This commit is contained in:
Urtzi Alfaro
2025-10-30 21:08:07 +01:00
parent 36217a2729
commit 63f5c6d512
184 changed files with 21512 additions and 7442 deletions

View File

@@ -1,452 +0,0 @@
# ================================================================
# services/orders/app/services/cache_service.py
# ================================================================
"""
Cache Service - Redis caching for procurement plans and related data
"""
import json
import uuid
from datetime import datetime, date, timedelta
from typing import Optional, Dict, Any, List
import structlog
from pydantic import BaseModel
from shared.redis_utils import get_redis_client
from app.core.config import settings
from app.models.procurement import ProcurementPlan
from app.schemas.procurement_schemas import ProcurementPlanResponse
logger = structlog.get_logger()
class CacheService:
"""Service for managing Redis cache operations"""
def __init__(self):
"""Initialize cache service"""
self._redis_client = None
async def _get_redis(self):
"""Get shared Redis client"""
if self._redis_client is None:
self._redis_client = await get_redis_client()
return self._redis_client
@property
def redis(self):
"""Get Redis client with connection check"""
if self._redis_client is None:
self._connect()
return self._redis_client
def is_available(self) -> bool:
"""Check if Redis is available"""
try:
return self.redis is not None and self.redis.ping()
except Exception:
return False
# ================================================================
# PROCUREMENT PLAN CACHING
# ================================================================
def _get_plan_key(self, tenant_id: uuid.UUID, plan_date: Optional[date] = None, plan_id: Optional[uuid.UUID] = None) -> str:
"""Generate cache key for procurement plan"""
if plan_id:
return f"procurement:plan:id:{tenant_id}:{plan_id}"
elif plan_date:
return f"procurement:plan:date:{tenant_id}:{plan_date.isoformat()}"
else:
return f"procurement:plan:current:{tenant_id}"
def _get_dashboard_key(self, tenant_id: uuid.UUID) -> str:
"""Generate cache key for dashboard data"""
return f"procurement:dashboard:{tenant_id}"
def _get_requirements_key(self, tenant_id: uuid.UUID, plan_id: uuid.UUID) -> str:
"""Generate cache key for plan requirements"""
return f"procurement:requirements:{tenant_id}:{plan_id}"
async def cache_procurement_plan(
self,
plan: ProcurementPlan,
ttl_hours: int = 6
) -> bool:
"""Cache a procurement plan with multiple keys for different access patterns"""
if not self.is_available():
logger.warning("Redis not available, skipping cache")
return False
try:
# Convert plan to cacheable format
plan_data = self._serialize_plan(plan)
ttl_seconds = ttl_hours * 3600
# Cache by plan ID
id_key = self._get_plan_key(plan.tenant_id, plan_id=plan.id)
self.redis.setex(id_key, ttl_seconds, plan_data)
# Cache by plan date
date_key = self._get_plan_key(plan.tenant_id, plan_date=plan.plan_date)
self.redis.setex(date_key, ttl_seconds, plan_data)
# If this is today's plan, cache as current
if plan.plan_date == date.today():
current_key = self._get_plan_key(plan.tenant_id)
self.redis.setex(current_key, ttl_seconds, plan_data)
# Cache requirements separately for faster access
if plan.requirements:
requirements_data = self._serialize_requirements(plan.requirements)
req_key = self._get_requirements_key(plan.tenant_id, plan.id)
self.redis.setex(req_key, ttl_seconds, requirements_data)
# Update plan list cache
await self._update_plan_list_cache(plan.tenant_id, plan)
logger.info("Procurement plan cached", plan_id=plan.id, tenant_id=plan.tenant_id)
return True
except Exception as e:
logger.error("Error caching procurement plan", error=str(e), plan_id=plan.id)
return False
async def get_cached_plan(
self,
tenant_id: uuid.UUID,
plan_date: Optional[date] = None,
plan_id: Optional[uuid.UUID] = None
) -> Optional[Dict[str, Any]]:
"""Get cached procurement plan"""
if not self.is_available():
return None
try:
key = self._get_plan_key(tenant_id, plan_date, plan_id)
cached_data = self.redis.get(key)
if cached_data:
plan_data = json.loads(cached_data)
logger.debug("Procurement plan retrieved from cache",
tenant_id=tenant_id, key=key)
return plan_data
return None
except Exception as e:
logger.error("Error retrieving cached plan", error=str(e))
return None
async def get_cached_requirements(
self,
tenant_id: uuid.UUID,
plan_id: uuid.UUID
) -> Optional[List[Dict[str, Any]]]:
"""Get cached plan requirements"""
if not self.is_available():
return None
try:
key = self._get_requirements_key(tenant_id, plan_id)
cached_data = self.redis.get(key)
if cached_data:
requirements_data = json.loads(cached_data)
logger.debug("Requirements retrieved from cache",
tenant_id=tenant_id, plan_id=plan_id)
return requirements_data
return None
except Exception as e:
logger.error("Error retrieving cached requirements", error=str(e))
return None
async def cache_dashboard_data(
self,
tenant_id: uuid.UUID,
dashboard_data: Dict[str, Any],
ttl_hours: int = 1
) -> bool:
"""Cache dashboard data with shorter TTL"""
if not self.is_available():
return False
try:
key = self._get_dashboard_key(tenant_id)
data_json = json.dumps(dashboard_data, cls=DateTimeEncoder)
ttl_seconds = ttl_hours * 3600
self.redis.setex(key, ttl_seconds, data_json)
logger.debug("Dashboard data cached", tenant_id=tenant_id)
return True
except Exception as e:
logger.error("Error caching dashboard data", error=str(e))
return False
async def get_cached_dashboard_data(self, tenant_id: uuid.UUID) -> Optional[Dict[str, Any]]:
"""Get cached dashboard data"""
if not self.is_available():
return None
try:
key = self._get_dashboard_key(tenant_id)
cached_data = self.redis.get(key)
if cached_data:
return json.loads(cached_data)
return None
except Exception as e:
logger.error("Error retrieving cached dashboard data", error=str(e))
return None
async def invalidate_plan_cache(
self,
tenant_id: uuid.UUID,
plan_id: Optional[uuid.UUID] = None,
plan_date: Optional[date] = None
) -> bool:
"""Invalidate cached procurement plan data"""
if not self.is_available():
return False
try:
keys_to_delete = []
if plan_id:
# Delete specific plan cache
keys_to_delete.append(self._get_plan_key(tenant_id, plan_id=plan_id))
keys_to_delete.append(self._get_requirements_key(tenant_id, plan_id))
if plan_date:
keys_to_delete.append(self._get_plan_key(tenant_id, plan_date=plan_date))
# Always invalidate current plan cache and dashboard
keys_to_delete.extend([
self._get_plan_key(tenant_id),
self._get_dashboard_key(tenant_id)
])
# Delete plan list cache
list_key = f"procurement:plans:list:{tenant_id}:*"
list_keys = self.redis.keys(list_key)
keys_to_delete.extend(list_keys)
if keys_to_delete:
self.redis.delete(*keys_to_delete)
logger.info("Plan cache invalidated",
tenant_id=tenant_id, keys_count=len(keys_to_delete))
return True
except Exception as e:
logger.error("Error invalidating plan cache", error=str(e))
return False
# ================================================================
# LIST CACHING
# ================================================================
async def _update_plan_list_cache(self, tenant_id: uuid.UUID, plan: ProcurementPlan) -> None:
"""Update cached plan lists"""
try:
# Add plan to various lists
list_keys = [
f"procurement:plans:list:{tenant_id}:all",
f"procurement:plans:list:{tenant_id}:status:{plan.status}",
f"procurement:plans:list:{tenant_id}:month:{plan.plan_date.strftime('%Y-%m')}"
]
plan_summary = {
"id": str(plan.id),
"plan_number": plan.plan_number,
"plan_date": plan.plan_date.isoformat(),
"status": plan.status,
"total_requirements": plan.total_requirements,
"total_estimated_cost": float(plan.total_estimated_cost),
"created_at": plan.created_at.isoformat()
}
for key in list_keys:
# Use sorted sets for automatic ordering by date
score = plan.plan_date.toordinal() # Use ordinal date as score
self.redis.zadd(key, {json.dumps(plan_summary): score})
self.redis.expire(key, 3600) # 1 hour TTL
except Exception as e:
logger.warning("Error updating plan list cache", error=str(e))
# ================================================================
# PERFORMANCE METRICS CACHING
# ================================================================
async def cache_performance_metrics(
self,
tenant_id: uuid.UUID,
metrics: Dict[str, Any],
ttl_hours: int = 24
) -> bool:
"""Cache performance metrics"""
if not self.is_available():
return False
try:
key = f"procurement:metrics:{tenant_id}"
data_json = json.dumps(metrics, cls=DateTimeEncoder)
ttl_seconds = ttl_hours * 3600
self.redis.setex(key, ttl_seconds, data_json)
return True
except Exception as e:
logger.error("Error caching performance metrics", error=str(e))
return False
async def get_cached_metrics(self, tenant_id: uuid.UUID) -> Optional[Dict[str, Any]]:
"""Get cached performance metrics"""
if not self.is_available():
return None
try:
key = f"procurement:metrics:{tenant_id}"
cached_data = self.redis.get(key)
if cached_data:
return json.loads(cached_data)
return None
except Exception as e:
logger.error("Error retrieving cached metrics", error=str(e))
return None
# ================================================================
# UTILITY METHODS
# ================================================================
def _serialize_plan(self, plan: ProcurementPlan) -> str:
"""Serialize procurement plan for caching"""
try:
# Convert to dict, handling special types
plan_dict = {
"id": str(plan.id),
"tenant_id": str(plan.tenant_id),
"plan_number": plan.plan_number,
"plan_date": plan.plan_date.isoformat(),
"plan_period_start": plan.plan_period_start.isoformat(),
"plan_period_end": plan.plan_period_end.isoformat(),
"status": plan.status,
"plan_type": plan.plan_type,
"priority": plan.priority,
"total_requirements": plan.total_requirements,
"total_estimated_cost": float(plan.total_estimated_cost),
"total_approved_cost": float(plan.total_approved_cost),
"safety_stock_buffer": float(plan.safety_stock_buffer),
"supply_risk_level": plan.supply_risk_level,
"created_at": plan.created_at.isoformat(),
"updated_at": plan.updated_at.isoformat(),
# Add requirements count for quick reference
"requirements_count": len(plan.requirements) if plan.requirements else 0
}
return json.dumps(plan_dict)
except Exception as e:
logger.error("Error serializing plan", error=str(e))
raise
def _serialize_requirements(self, requirements: List) -> str:
"""Serialize requirements for caching"""
try:
requirements_data = []
for req in requirements:
req_dict = {
"id": str(req.id),
"requirement_number": req.requirement_number,
"product_id": str(req.product_id),
"product_name": req.product_name,
"status": req.status,
"priority": req.priority,
"required_quantity": float(req.required_quantity),
"net_requirement": float(req.net_requirement),
"estimated_total_cost": float(req.estimated_total_cost or 0),
"required_by_date": req.required_by_date.isoformat(),
"suggested_order_date": req.suggested_order_date.isoformat()
}
requirements_data.append(req_dict)
return json.dumps(requirements_data)
except Exception as e:
logger.error("Error serializing requirements", error=str(e))
raise
async def clear_tenant_cache(self, tenant_id: uuid.UUID) -> bool:
"""Clear all cached data for a tenant"""
if not self.is_available():
return False
try:
pattern = f"*:{tenant_id}*"
keys = self.redis.keys(pattern)
if keys:
self.redis.delete(*keys)
logger.info("Tenant cache cleared", tenant_id=tenant_id, keys_count=len(keys))
return True
except Exception as e:
logger.error("Error clearing tenant cache", error=str(e))
return False
def get_cache_stats(self) -> Dict[str, Any]:
"""Get Redis cache statistics"""
if not self.is_available():
return {"available": False}
try:
info = self.redis.info()
return {
"available": True,
"used_memory": info.get("used_memory_human"),
"connected_clients": info.get("connected_clients"),
"total_connections_received": info.get("total_connections_received"),
"keyspace_hits": info.get("keyspace_hits", 0),
"keyspace_misses": info.get("keyspace_misses", 0),
"hit_rate": self._calculate_hit_rate(
info.get("keyspace_hits", 0),
info.get("keyspace_misses", 0)
)
}
except Exception as e:
logger.error("Error getting cache stats", error=str(e))
return {"available": False, "error": str(e)}
def _calculate_hit_rate(self, hits: int, misses: int) -> float:
"""Calculate cache hit rate percentage"""
total = hits + misses
return (hits / total * 100) if total > 0 else 0.0
class DateTimeEncoder(json.JSONEncoder):
"""JSON encoder that handles datetime objects"""
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
return super().default(obj)
# Global cache service instance
_cache_service = None
def get_cache_service() -> CacheService:
"""Get the global cache service instance"""
global _cache_service
if _cache_service is None:
_cache_service = CacheService()
return _cache_service

View File

@@ -1,490 +0,0 @@
# services/orders/app/services/procurement_scheduler_service.py
"""
Procurement Scheduler Service - Daily procurement planning automation
"""
import asyncio
from datetime import datetime, timedelta
from typing import List, Dict, Any
from uuid import UUID
import structlog
from apscheduler.triggers.cron import CronTrigger
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
from shared.database.base import create_database_manager
from app.services.procurement_service import ProcurementService
logger = structlog.get_logger()
class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
"""
Procurement scheduler service for automated daily procurement planning
Extends BaseAlertService to use proven scheduling infrastructure
"""
def __init__(self, config):
super().__init__(config)
self.procurement_service = None
async def start(self):
"""Initialize scheduler and procurement service"""
# Initialize base alert service
await super().start()
# Initialize procurement service instance for reuse
from app.core.database import AsyncSessionLocal
self.db_session_factory = AsyncSessionLocal
logger.info("Procurement scheduler service started", service=self.config.SERVICE_NAME)
def setup_scheduled_checks(self):
"""Configure daily procurement planning jobs"""
# Daily procurement planning at 6:00 AM
self.scheduler.add_job(
func=self.run_daily_procurement_planning,
trigger=CronTrigger(hour=6, minute=0),
id="daily_procurement_planning",
name="Daily Procurement Planning",
misfire_grace_time=300,
coalesce=True,
max_instances=1
)
# Stale plan cleanup at 6:30 AM (Bug #3 FIX, Edge Cases #1 & #2)
self.scheduler.add_job(
func=self.run_stale_plan_cleanup,
trigger=CronTrigger(hour=6, minute=30),
id="stale_plan_cleanup",
name="Stale Plan Cleanup & Reminders",
misfire_grace_time=300,
coalesce=True,
max_instances=1
)
# Also add a test job that runs every 30 minutes for development/testing
# This will be disabled in production via environment variable
if getattr(self.config, 'DEBUG', False) or getattr(self.config, 'PROCUREMENT_TEST_MODE', False):
self.scheduler.add_job(
func=self.run_daily_procurement_planning,
trigger=CronTrigger(minute='*/30'), # Every 30 minutes
id="test_procurement_planning",
name="Test Procurement Planning (30min)",
misfire_grace_time=300,
coalesce=True,
max_instances=1
)
logger.info("⚡ Test procurement planning job added (every 30 minutes)")
# Weekly procurement optimization at 7:00 AM on Mondays
self.scheduler.add_job(
func=self.run_weekly_optimization,
trigger=CronTrigger(day_of_week=0, hour=7, minute=0),
id="weekly_procurement_optimization",
name="Weekly Procurement Optimization",
misfire_grace_time=600,
coalesce=True,
max_instances=1
)
logger.info("📅 Procurement scheduled jobs configured",
jobs_count=len(self.scheduler.get_jobs()))
async def run_daily_procurement_planning(self):
"""
Execute daily procurement planning for all active tenants
Edge Case #6: Uses parallel processing with per-tenant timeouts
"""
if not self.is_leader:
logger.debug("Skipping procurement planning - not leader")
return
try:
self._checks_performed += 1
logger.info("🔄 Starting daily procurement planning execution",
timestamp=datetime.now().isoformat())
# Get active tenants from tenant service
active_tenants = await self.get_active_tenants()
if not active_tenants:
logger.info("No active tenants found for procurement planning")
return
# Edge Case #6: Process tenants in parallel with individual error handling
logger.info(f"Processing {len(active_tenants)} tenants in parallel")
# Create tasks with timeout for each tenant
tasks = [
self._process_tenant_with_timeout(tenant_id, timeout_seconds=120)
for tenant_id in active_tenants
]
# Execute all tasks in parallel
results = await asyncio.gather(*tasks, return_exceptions=True)
# Count successes and failures
processed_tenants = sum(1 for r in results if r is True)
failed_tenants = sum(1 for r in results if isinstance(r, Exception) or r is False)
logger.info("🎯 Daily procurement planning completed",
total_tenants=len(active_tenants),
processed_tenants=processed_tenants,
failed_tenants=failed_tenants)
except Exception as e:
self._errors_count += 1
logger.error("💥 Daily procurement planning failed completely", error=str(e))
async def _process_tenant_with_timeout(self, tenant_id: UUID, timeout_seconds: int = 120) -> bool:
"""
Process tenant procurement with timeout (Edge Case #6)
Returns True on success, False or raises exception on failure
"""
try:
await asyncio.wait_for(
self.process_tenant_procurement(tenant_id),
timeout=timeout_seconds
)
logger.info("✅ Successfully processed tenant", tenant_id=str(tenant_id))
return True
except asyncio.TimeoutError:
logger.error("⏱️ Tenant processing timed out",
tenant_id=str(tenant_id),
timeout=timeout_seconds)
return False
except Exception as e:
logger.error("❌ Error processing tenant procurement",
tenant_id=str(tenant_id),
error=str(e))
raise
async def run_stale_plan_cleanup(self):
"""
Clean up stale plans, send reminders and escalations
Bug #3 FIX, Edge Cases #1 & #2
"""
if not self.is_leader:
logger.debug("Skipping stale plan cleanup - not leader")
return
try:
logger.info("🧹 Starting stale plan cleanup")
active_tenants = await self.get_active_tenants()
if not active_tenants:
logger.info("No active tenants found for cleanup")
return
total_archived = 0
total_cancelled = 0
total_escalated = 0
# Process each tenant's stale plans
for tenant_id in active_tenants:
try:
async with self.db_session_factory() as session:
procurement_service = ProcurementService(session, self.config)
stats = await procurement_service.cleanup_stale_plans(tenant_id)
total_archived += stats.get('archived', 0)
total_cancelled += stats.get('cancelled', 0)
total_escalated += stats.get('escalated', 0)
except Exception as e:
logger.error("Error cleaning up tenant plans",
tenant_id=str(tenant_id),
error=str(e))
logger.info("✅ Stale plan cleanup completed",
archived=total_archived,
cancelled=total_cancelled,
escalated=total_escalated)
except Exception as e:
self._errors_count += 1
logger.error("💥 Stale plan cleanup failed", error=str(e))
async def get_active_tenants(self) -> List[UUID]:
"""Get active tenants from tenant service, excluding demo tenants"""
try:
all_tenants = await super().get_active_tenants()
# Filter out demo tenants
from services.tenant.app.models.tenants import Tenant
from sqlalchemy import select
import os
tenant_db_url = os.getenv("TENANT_DATABASE_URL")
if not tenant_db_url:
logger.warning("TENANT_DATABASE_URL not set, returning all tenants")
return all_tenants
tenant_db = create_database_manager(tenant_db_url, "tenant-filter")
non_demo_tenants = []
async with tenant_db.get_session() as session:
for tenant_id in all_tenants:
result = await session.execute(
select(Tenant).where(Tenant.id == tenant_id)
)
tenant = result.scalars().first()
# Only include non-demo tenants
if tenant and not tenant.is_demo:
non_demo_tenants.append(tenant_id)
elif tenant and tenant.is_demo:
logger.debug("Excluding demo tenant from procurement scheduler",
tenant_id=str(tenant_id))
logger.info("Filtered demo tenants from procurement scheduling",
total_tenants=len(all_tenants),
non_demo_tenants=len(non_demo_tenants),
demo_tenants_filtered=len(all_tenants) - len(non_demo_tenants))
return non_demo_tenants
except Exception as e:
logger.error("Could not fetch tenants from base service", error=str(e))
return []
async def process_tenant_procurement(self, tenant_id: UUID):
"""Process procurement planning for a specific tenant"""
try:
# Use default configuration since tenants table is not in orders DB
planning_days = 7 # Default planning horizon
# Calculate planning date (tomorrow by default)
planning_date = datetime.now().date() + timedelta(days=1)
logger.info("Processing procurement for tenant",
tenant_id=str(tenant_id),
planning_date=str(planning_date),
planning_days=planning_days)
# Create procurement service instance and generate plan
from app.core.database import AsyncSessionLocal
from app.schemas.procurement_schemas import GeneratePlanRequest
from decimal import Decimal
async with AsyncSessionLocal() as session:
procurement_service = ProcurementService(session, self.config)
# Check if plan already exists for this date
existing_plan = await procurement_service.get_plan_by_date(
tenant_id, planning_date
)
if existing_plan:
logger.info("📋 Procurement plan already exists, skipping",
tenant_id=str(tenant_id),
plan_date=str(planning_date),
plan_id=str(existing_plan.id))
return
# Generate procurement plan
request = GeneratePlanRequest(
plan_date=planning_date,
planning_horizon_days=planning_days,
include_safety_stock=True,
safety_stock_percentage=Decimal('20.0'),
force_regenerate=False
)
logger.info("📊 Generating procurement plan",
tenant_id=str(tenant_id),
request_params=str(request.model_dump()))
result = await procurement_service.generate_procurement_plan(tenant_id, request)
if result.success and result.plan:
logger.info("🎉 Procurement plan created successfully",
tenant_id=str(tenant_id),
plan_id=str(result.plan.id),
plan_date=str(planning_date),
total_requirements=result.plan.total_requirements)
# Auto-create POs from the plan (NEW FEATURE)
if self.config.AUTO_CREATE_POS_FROM_PLAN:
await self._auto_create_purchase_orders_from_plan(
procurement_service,
tenant_id,
result.plan.id
)
# Send notification about new plan
await self.send_procurement_notification(
tenant_id, result.plan, "plan_created"
)
else:
logger.warning("⚠️ Failed to generate procurement plan",
tenant_id=str(tenant_id),
errors=result.errors,
warnings=result.warnings)
except Exception as e:
logger.error("💥 Error processing tenant procurement",
tenant_id=str(tenant_id),
error=str(e))
raise
async def run_weekly_optimization(self):
"""Run weekly procurement optimization"""
if not self.is_leader:
logger.debug("Skipping weekly optimization - not leader")
return
try:
self._checks_performed += 1
logger.info("Starting weekly procurement optimization")
active_tenants = await self.get_active_tenants()
for tenant_id in active_tenants:
try:
await self.optimize_tenant_procurement(tenant_id)
except Exception as e:
logger.error("Error in weekly optimization",
tenant_id=str(tenant_id),
error=str(e))
logger.info("Weekly procurement optimization completed")
except Exception as e:
self._errors_count += 1
logger.error("Weekly procurement optimization failed", error=str(e))
async def optimize_tenant_procurement(self, tenant_id: UUID):
"""Optimize procurement planning for a tenant"""
# Get plans from the last week
end_date = datetime.now().date()
start_date = end_date - timedelta(days=7)
# For now, just log the optimization - full implementation would analyze patterns
logger.info("Processing weekly optimization",
tenant_id=str(tenant_id),
period=f"{start_date} to {end_date}")
# Simple recommendation: if no plans exist, suggest creating one
recommendations = [{
"type": "weekly_review",
"severity": "low",
"title": "Revisión Semanal de Compras",
"message": "Es momento de revisar y optimizar tu planificación de compras semanal.",
"metadata": {
"tenant_id": str(tenant_id),
"week_period": f"{start_date} to {end_date}"
}
}]
for recommendation in recommendations:
await self.publish_item(
tenant_id, recommendation, item_type='recommendation'
)
async def send_procurement_notification(self, tenant_id: UUID,
plan, notification_type: str):
"""Send procurement-related notifications"""
try:
if notification_type == "plan_created":
alert_data = {
"type": "procurement_plan_created",
"severity": "low",
"title": "Plan de Compras Creado",
"message": f"Nuevo plan de compras generado para {plan.plan_date if plan else 'fecha desconocida'}",
"metadata": {
"tenant_id": str(tenant_id),
"plan_id": str(plan.id) if plan else "unknown",
"plan_date": str(plan.plan_date) if plan else "unknown",
"auto_generated": getattr(plan, 'auto_generated', True)
}
}
await self.publish_item(tenant_id, alert_data, item_type='alert')
except Exception as e:
logger.error("Error sending procurement notification",
tenant_id=str(tenant_id),
notification_type=notification_type,
error=str(e))
async def _auto_create_purchase_orders_from_plan(
self,
procurement_service,
tenant_id: UUID,
plan_id: UUID
):
"""
Automatically create purchase orders from procurement plan
Integrates with auto-approval rules
"""
try:
logger.info("🛒 Auto-creating purchase orders from plan",
tenant_id=str(tenant_id),
plan_id=str(plan_id))
# Create POs with auto-approval evaluation enabled
po_result = await procurement_service.create_purchase_orders_from_plan(
tenant_id=tenant_id,
plan_id=plan_id,
auto_approve=True # Enable auto-approval evaluation
)
if po_result.get("success"):
total_created = po_result.get("total_created", 0)
auto_approved = po_result.get("total_auto_approved", 0)
pending_approval = po_result.get("total_pending_approval", 0)
logger.info("✅ Purchase orders created from plan",
tenant_id=str(tenant_id),
plan_id=str(plan_id),
total_created=total_created,
auto_approved=auto_approved,
pending_approval=pending_approval)
# Send notifications
from app.services.procurement_notification_service import ProcurementNotificationService
notification_service = ProcurementNotificationService(self.config)
# Notify about pending approvals
if pending_approval > 0:
await notification_service.send_pos_pending_approval_alert(
tenant_id=tenant_id,
pos_data=po_result.get("pending_approval_pos", [])
)
# Log auto-approved POs for summary
if auto_approved > 0:
logger.info("🤖 Auto-approved POs",
tenant_id=str(tenant_id),
count=auto_approved,
pos=po_result.get("auto_approved_pos", []))
else:
logger.error("❌ Failed to create purchase orders from plan",
tenant_id=str(tenant_id),
plan_id=str(plan_id),
error=po_result.get("error"))
except Exception as e:
logger.error("💥 Error auto-creating purchase orders",
tenant_id=str(tenant_id),
plan_id=str(plan_id),
error=str(e))
async def test_procurement_generation(self):
"""Test method to manually trigger procurement planning"""
# Get the first available tenant for testing
active_tenants = await self.get_active_tenants()
if not active_tenants:
logger.error("No active tenants found for testing procurement generation")
return
test_tenant_id = active_tenants[0]
logger.info("Testing procurement plan generation", tenant_id=str(test_tenant_id))
try:
await self.process_tenant_procurement(test_tenant_id)
logger.info("Test procurement generation completed successfully")
except Exception as e:
logger.error("Test procurement generation failed", error=str(e), tenant_id=str(test_tenant_id))

File diff suppressed because it is too large Load Diff