Files
bakery-ia/services/orders/app/services/procurement_scheduler_service.py
2025-09-22 16:10:08 +02:00

299 lines
12 KiB
Python

# 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 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
)
# 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"""
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
# Process each tenant
processed_tenants = 0
failed_tenants = 0
for tenant_id in active_tenants:
try:
logger.info("Processing tenant procurement", tenant_id=str(tenant_id))
await self.process_tenant_procurement(tenant_id)
processed_tenants += 1
logger.info("✅ Successfully processed tenant", tenant_id=str(tenant_id))
except Exception as e:
failed_tenants += 1
logger.error("❌ Error processing tenant procurement",
tenant_id=str(tenant_id),
error=str(e))
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 get_active_tenants(self) -> List[UUID]:
"""Get active tenants from tenant service or base implementation"""
# Only use tenant service, no fallbacks
try:
return await super().get_active_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:
# Send notification about new plan
await self.send_procurement_notification(
tenant_id, result.plan, "plan_created"
)
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)
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 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))