Improve the frontend 3
This commit is contained in:
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user