299 lines
12 KiB
Python
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)) |