Improve the frontend 3
This commit is contained in:
@@ -296,15 +296,16 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
|
||||
'type': 'quality_control_failure',
|
||||
'severity': severity,
|
||||
'title': f'❌ Fallo Control Calidad: {issue["product_name"]}',
|
||||
'message': f'Lote {issue["batch_number"]} falló en {issue["test_type"]}. Valor: {issue["result_value"]} (rango: {issue["min_acceptable"]}-{issue["max_acceptable"]})',
|
||||
'message': f'Lote {issue["batch_number"]} falló en {issue["check_type"]}. Puntuación: {issue["quality_score"]}/10. Defectos: {issue["defect_count"]}',
|
||||
'actions': ['Revisar lote', 'Repetir prueba', 'Ajustar proceso', 'Documentar causa'],
|
||||
'metadata': {
|
||||
'quality_check_id': str(issue['id']),
|
||||
'batch_id': str(issue['batch_id']),
|
||||
'test_type': issue['test_type'],
|
||||
'result_value': float(issue['result_value']),
|
||||
'min_acceptable': float(issue['min_acceptable']),
|
||||
'max_acceptable': float(issue['max_acceptable']),
|
||||
'check_type': issue['check_type'],
|
||||
'quality_score': float(issue['quality_score']),
|
||||
'within_tolerance': issue['within_tolerance'],
|
||||
'defect_count': int(issue['defect_count']),
|
||||
'process_stage': issue.get('process_stage'),
|
||||
'qc_severity': qc_severity,
|
||||
'total_failures': total_failures
|
||||
}
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
# services/production/app/services/production_scheduler_service.py
|
||||
"""
|
||||
Production Scheduler Service - Daily production planning automation
|
||||
|
||||
Automatically generates daily production schedules for all active tenants based on:
|
||||
- Demand forecasts from Orders Service
|
||||
- Current inventory levels
|
||||
- Production capacity
|
||||
- Recipe requirements
|
||||
|
||||
Runs daily at 5:30 AM (before procurement @ 6:00 AM) to ensure production
|
||||
plans are ready for the day ahead.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import List, Dict, Any, Optional
|
||||
from uuid import UUID
|
||||
from decimal import Decimal
|
||||
import structlog
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from shared.alerts.base_service import BaseAlertService, AlertServiceMixin
|
||||
from shared.database.base import create_database_manager
|
||||
from app.services.production_service import ProductionService
|
||||
from app.schemas.production import ProductionScheduleCreate, ProductionBatchCreate
|
||||
from app.models.production import ProductionStatus, ProductionPriority
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class ProductionSchedulerService(BaseAlertService, AlertServiceMixin):
|
||||
"""
|
||||
Production scheduler service for automated daily production planning
|
||||
Extends BaseAlertService to use proven scheduling infrastructure
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
super().__init__(config)
|
||||
self.production_service = None
|
||||
|
||||
async def start(self):
|
||||
"""Initialize scheduler and production service"""
|
||||
await super().start()
|
||||
|
||||
# Store database manager for session creation
|
||||
from app.core.database import database_manager
|
||||
self.db_manager = database_manager
|
||||
|
||||
logger.info("Production scheduler service started", service=self.config.SERVICE_NAME)
|
||||
|
||||
def setup_scheduled_checks(self):
|
||||
"""Configure daily production planning jobs"""
|
||||
|
||||
# Daily production planning at 5:30 AM (before procurement)
|
||||
# This ensures production plans are ready before procurement plans
|
||||
self.scheduler.add_job(
|
||||
func=self.run_daily_production_planning,
|
||||
trigger=CronTrigger(hour=5, minute=30),
|
||||
id="daily_production_planning",
|
||||
name="Daily Production Planning",
|
||||
misfire_grace_time=300, # 5 minutes grace period
|
||||
coalesce=True, # Combine missed runs
|
||||
max_instances=1 # Only one instance at a time
|
||||
)
|
||||
|
||||
# Stale schedule cleanup at 5:50 AM
|
||||
self.scheduler.add_job(
|
||||
func=self.run_stale_schedule_cleanup,
|
||||
trigger=CronTrigger(hour=5, minute=50),
|
||||
id="stale_schedule_cleanup",
|
||||
name="Stale Schedule Cleanup",
|
||||
misfire_grace_time=300,
|
||||
coalesce=True,
|
||||
max_instances=1
|
||||
)
|
||||
|
||||
# Test job for development (every 30 minutes if DEBUG enabled)
|
||||
if getattr(self.config, 'DEBUG', False) or getattr(self.config, 'PRODUCTION_TEST_MODE', False):
|
||||
self.scheduler.add_job(
|
||||
func=self.run_daily_production_planning,
|
||||
trigger=CronTrigger(minute='*/30'),
|
||||
id="test_production_planning",
|
||||
name="Test Production Planning (30min)",
|
||||
misfire_grace_time=300,
|
||||
coalesce=True,
|
||||
max_instances=1
|
||||
)
|
||||
logger.info("⚡ Test production planning job added (every 30 minutes)")
|
||||
|
||||
logger.info("📅 Production scheduled jobs configured",
|
||||
jobs_count=len(self.scheduler.get_jobs()))
|
||||
|
||||
async def run_daily_production_planning(self):
|
||||
"""
|
||||
Execute daily production planning for all active tenants
|
||||
Processes tenants in parallel with individual timeouts
|
||||
"""
|
||||
if not self.is_leader:
|
||||
logger.debug("Skipping production planning - not leader")
|
||||
return
|
||||
|
||||
try:
|
||||
self._checks_performed += 1
|
||||
logger.info("🔄 Starting daily production planning execution",
|
||||
timestamp=datetime.now().isoformat())
|
||||
|
||||
# Get active non-demo tenants
|
||||
active_tenants = await self.get_active_tenants()
|
||||
if not active_tenants:
|
||||
logger.info("No active tenants found for production planning")
|
||||
return
|
||||
|
||||
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=180)
|
||||
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 production 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 production planning failed completely", error=str(e))
|
||||
|
||||
async def _process_tenant_with_timeout(self, tenant_id: UUID, timeout_seconds: int = 180) -> bool:
|
||||
"""
|
||||
Process tenant production planning with timeout
|
||||
Returns True on success, False or raises exception on failure
|
||||
"""
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.process_tenant_production(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 production",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
async def process_tenant_production(self, tenant_id: UUID):
|
||||
"""Process production planning for a specific tenant"""
|
||||
try:
|
||||
# Get tenant timezone for accurate date calculation
|
||||
tenant_tz = await self._get_tenant_timezone(tenant_id)
|
||||
|
||||
# Calculate target date in tenant's timezone
|
||||
target_date = datetime.now(ZoneInfo(tenant_tz)).date()
|
||||
|
||||
logger.info("Processing production for tenant",
|
||||
tenant_id=str(tenant_id),
|
||||
target_date=str(target_date),
|
||||
timezone=tenant_tz)
|
||||
|
||||
# Check if schedule already exists for this date
|
||||
async with self.db_manager.get_session() as session:
|
||||
production_service = ProductionService(self.db_manager, self.config)
|
||||
|
||||
# Check for existing schedule
|
||||
existing_schedule = await self._get_schedule_by_date(
|
||||
session, tenant_id, target_date
|
||||
)
|
||||
|
||||
if existing_schedule:
|
||||
logger.info("📋 Production schedule already exists, skipping",
|
||||
tenant_id=str(tenant_id),
|
||||
schedule_date=str(target_date),
|
||||
schedule_id=str(existing_schedule.get('id')))
|
||||
return
|
||||
|
||||
# Calculate daily requirements
|
||||
requirements = await production_service.calculate_daily_requirements(
|
||||
tenant_id, target_date
|
||||
)
|
||||
|
||||
if not requirements.production_plan:
|
||||
logger.info("No production requirements for date",
|
||||
tenant_id=str(tenant_id),
|
||||
date=str(target_date))
|
||||
return
|
||||
|
||||
# Create production schedule
|
||||
schedule_data = ProductionScheduleCreate(
|
||||
schedule_date=target_date,
|
||||
schedule_name=f"Daily Production - {target_date.strftime('%Y-%m-%d')}",
|
||||
status="draft",
|
||||
notes=f"Auto-generated daily production schedule for {target_date}",
|
||||
total_batches=len(requirements.production_plan),
|
||||
auto_generated=True
|
||||
)
|
||||
|
||||
schedule = await production_service.create_production_schedule(
|
||||
tenant_id, schedule_data
|
||||
)
|
||||
|
||||
# Create production batches from requirements
|
||||
batches_created = 0
|
||||
for item in requirements.production_plan:
|
||||
try:
|
||||
batch_data = await self._create_batch_from_requirement(
|
||||
item, schedule.id, target_date
|
||||
)
|
||||
|
||||
batch = await production_service.create_production_batch(
|
||||
tenant_id, batch_data
|
||||
)
|
||||
batches_created += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error creating batch from requirement",
|
||||
tenant_id=str(tenant_id),
|
||||
product=item.get('product_name'),
|
||||
error=str(e))
|
||||
|
||||
# Send notification about new schedule
|
||||
await self.send_production_schedule_notification(
|
||||
tenant_id, schedule.id, batches_created
|
||||
)
|
||||
|
||||
logger.info("🎉 Production schedule created successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
schedule_id=str(schedule.id),
|
||||
schedule_date=str(target_date),
|
||||
batches_created=batches_created)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("💥 Error processing tenant production",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
async def _get_tenant_timezone(self, tenant_id: UUID) -> str:
|
||||
"""Get tenant's timezone, fallback to UTC if not configured"""
|
||||
try:
|
||||
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, using UTC")
|
||||
return "UTC"
|
||||
|
||||
tenant_db = create_database_manager(tenant_db_url, "tenant-tz-lookup")
|
||||
|
||||
async with tenant_db.get_session() as session:
|
||||
result = await session.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalars().first()
|
||||
|
||||
if tenant and hasattr(tenant, 'timezone') and tenant.timezone:
|
||||
return tenant.timezone
|
||||
|
||||
# Default to Europe/Madrid for Spanish bakeries
|
||||
return "Europe/Madrid"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch tenant timezone, using UTC",
|
||||
tenant_id=str(tenant_id), error=str(e))
|
||||
return "UTC"
|
||||
|
||||
async def _get_schedule_by_date(self, session, tenant_id: UUID, schedule_date: date) -> Optional[Dict]:
|
||||
"""Check if production schedule exists for date"""
|
||||
try:
|
||||
from app.repositories.production_schedule_repository import ProductionScheduleRepository
|
||||
|
||||
schedule_repo = ProductionScheduleRepository(session)
|
||||
schedule = await schedule_repo.get_schedule_by_date(str(tenant_id), schedule_date)
|
||||
|
||||
if schedule:
|
||||
return {"id": schedule.id, "status": schedule.status}
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error checking existing schedule", error=str(e))
|
||||
return None
|
||||
|
||||
async def _create_batch_from_requirement(
|
||||
self,
|
||||
requirement: Dict[str, Any],
|
||||
schedule_id: UUID,
|
||||
target_date: date
|
||||
) -> ProductionBatchCreate:
|
||||
"""Create batch data from production requirement"""
|
||||
|
||||
# Map urgency to priority
|
||||
urgency_to_priority = {
|
||||
"high": ProductionPriority.HIGH,
|
||||
"medium": ProductionPriority.MEDIUM,
|
||||
"low": ProductionPriority.LOW
|
||||
}
|
||||
priority = urgency_to_priority.get(requirement.get('urgency', 'medium'), ProductionPriority.MEDIUM)
|
||||
|
||||
# Calculate planned times (start at 6 AM, estimate 2 hours per batch)
|
||||
planned_start = datetime.combine(target_date, datetime.min.time().replace(hour=6))
|
||||
planned_duration = 120 # 2 hours default
|
||||
|
||||
return ProductionBatchCreate(
|
||||
schedule_id=schedule_id,
|
||||
product_id=UUID(requirement['product_id']),
|
||||
product_name=requirement['product_name'],
|
||||
planned_quantity=Decimal(str(requirement['recommended_production'])),
|
||||
unit_of_measure="units",
|
||||
priority=priority,
|
||||
status=ProductionStatus.PLANNED,
|
||||
planned_start_time=planned_start,
|
||||
planned_duration_minutes=planned_duration,
|
||||
notes=f"Auto-generated from demand forecast. Urgency: {requirement.get('urgency', 'medium')}",
|
||||
auto_generated=True
|
||||
)
|
||||
|
||||
async def run_stale_schedule_cleanup(self):
|
||||
"""
|
||||
Clean up stale production schedules and send reminders
|
||||
"""
|
||||
if not self.is_leader:
|
||||
logger.debug("Skipping stale schedule cleanup - not leader")
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info("🧹 Starting stale schedule 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 schedules
|
||||
for tenant_id in active_tenants:
|
||||
try:
|
||||
stats = await self._cleanup_tenant_schedules(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 schedules",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
logger.info("✅ Stale schedule cleanup completed",
|
||||
archived=total_archived,
|
||||
cancelled=total_cancelled,
|
||||
escalated=total_escalated)
|
||||
|
||||
except Exception as e:
|
||||
self._errors_count += 1
|
||||
logger.error("💥 Stale schedule cleanup failed", error=str(e))
|
||||
|
||||
async def _cleanup_tenant_schedules(self, tenant_id: UUID) -> Dict[str, int]:
|
||||
"""Cleanup stale schedules for a specific tenant"""
|
||||
stats = {"archived": 0, "cancelled": 0, "escalated": 0}
|
||||
|
||||
try:
|
||||
from app.repositories.production_schedule_repository import ProductionScheduleRepository
|
||||
|
||||
async with self.db_manager.get_session() as session:
|
||||
schedule_repo = ProductionScheduleRepository(session)
|
||||
|
||||
today = date.today()
|
||||
|
||||
# Get all schedules for tenant
|
||||
schedules = await schedule_repo.get_all_schedules_for_tenant(tenant_id)
|
||||
|
||||
for schedule in schedules:
|
||||
schedule_age_days = (today - schedule.schedule_date).days
|
||||
|
||||
# Archive completed schedules older than 90 days
|
||||
if schedule.status == "completed" and schedule_age_days > 90:
|
||||
await schedule_repo.archive_schedule(schedule)
|
||||
stats["archived"] += 1
|
||||
|
||||
# Cancel draft schedules older than 7 days
|
||||
elif schedule.status == "draft" and schedule_age_days > 7:
|
||||
await schedule_repo.cancel_schedule(schedule, "Auto-cancelled: stale draft schedule")
|
||||
stats["cancelled"] += 1
|
||||
|
||||
# Escalate overdue schedules
|
||||
elif schedule.schedule_date == today and schedule.status in ['draft', 'pending_approval']:
|
||||
await self._send_schedule_escalation_alert(tenant_id, schedule.id)
|
||||
stats["escalated"] += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error in tenant schedule cleanup",
|
||||
tenant_id=str(tenant_id), error=str(e))
|
||||
|
||||
return stats
|
||||
|
||||
async def send_production_schedule_notification(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
schedule_id: UUID,
|
||||
batches_count: int
|
||||
):
|
||||
"""Send notification about new production schedule"""
|
||||
try:
|
||||
alert_data = {
|
||||
"type": "production_schedule_created",
|
||||
"severity": "low",
|
||||
"title": "Nuevo Plan de Producción Generado",
|
||||
"message": f"Plan de producción diario creado con {batches_count} lotes programados",
|
||||
"metadata": {
|
||||
"tenant_id": str(tenant_id),
|
||||
"schedule_id": str(schedule_id),
|
||||
"batches_count": batches_count,
|
||||
"auto_generated": True
|
||||
}
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, alert_data, item_type='alert')
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending schedule notification",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
|
||||
async def _send_schedule_escalation_alert(self, tenant_id: UUID, schedule_id: UUID):
|
||||
"""Send escalation alert for overdue schedule"""
|
||||
try:
|
||||
alert_data = {
|
||||
"type": "schedule_escalation",
|
||||
"severity": "high",
|
||||
"title": "Plan de Producción Vencido",
|
||||
"message": "Plan de producción para hoy no ha sido procesado - Requiere atención urgente",
|
||||
"metadata": {
|
||||
"tenant_id": str(tenant_id),
|
||||
"schedule_id": str(schedule_id),
|
||||
"escalation_level": "urgent"
|
||||
}
|
||||
}
|
||||
|
||||
await self.publish_item(tenant_id, alert_data, item_type='alert')
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending escalation alert", error=str(e))
|
||||
|
||||
async def test_production_schedule_generation(self):
|
||||
"""Test method to manually trigger production planning"""
|
||||
active_tenants = await self.get_active_tenants()
|
||||
if not active_tenants:
|
||||
logger.error("No active tenants found for testing production schedule generation")
|
||||
return
|
||||
|
||||
test_tenant_id = active_tenants[0]
|
||||
logger.info("Testing production schedule generation", tenant_id=str(test_tenant_id))
|
||||
|
||||
try:
|
||||
await self.process_tenant_production(test_tenant_id)
|
||||
logger.info("Test production schedule generation completed successfully")
|
||||
except Exception as e:
|
||||
logger.error("Test production schedule generation failed",
|
||||
error=str(e), tenant_id=str(test_tenant_id))
|
||||
@@ -1721,4 +1721,162 @@ class ProductionService:
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise
|
||||
raise
|
||||
|
||||
# ================================================================
|
||||
# NEW: ORCHESTRATOR INTEGRATION
|
||||
# ================================================================
|
||||
|
||||
async def generate_production_schedule_from_forecast(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
target_date: date,
|
||||
forecasts: List[Dict[str, Any]],
|
||||
planning_horizon_days: int = 1
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate production schedule from forecast data (called by Orchestrator)
|
||||
|
||||
This method receives forecast data from the Orchestrator and generates
|
||||
a production schedule with production batches.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
target_date: Target production date
|
||||
forecasts: List of forecast data with product_id and predicted_demand
|
||||
planning_horizon_days: Planning horizon (1-7 days)
|
||||
|
||||
Returns:
|
||||
Dict with schedule_id, schedule_number, batches_created, etc.
|
||||
"""
|
||||
try:
|
||||
logger.info("Generating production schedule from forecast",
|
||||
tenant_id=str(tenant_id),
|
||||
target_date=target_date,
|
||||
forecasts_count=len(forecasts))
|
||||
|
||||
async with self.database_manager.get_session() as session:
|
||||
schedule_repo = ProductionScheduleRepository(session)
|
||||
batch_repo = ProductionBatchRepository(session)
|
||||
|
||||
# Generate schedule number
|
||||
schedule_number = await schedule_repo.generate_schedule_number(tenant_id, target_date)
|
||||
|
||||
# Calculate production end date
|
||||
production_end_date = target_date + timedelta(days=planning_horizon_days - 1)
|
||||
|
||||
# Create production schedule
|
||||
schedule_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'schedule_number': schedule_number,
|
||||
'schedule_date': target_date,
|
||||
'production_start_date': target_date,
|
||||
'production_end_date': production_end_date,
|
||||
'status': 'draft',
|
||||
'total_batches': 0,
|
||||
'completed_batches': 0,
|
||||
'created_at': datetime.now(timezone.utc),
|
||||
'updated_at': datetime.now(timezone.utc),
|
||||
}
|
||||
|
||||
schedule = await schedule_repo.create_schedule(schedule_data)
|
||||
|
||||
# Create production batches from forecasts
|
||||
batches_created = 0
|
||||
total_planned_quantity = 0.0
|
||||
warnings = []
|
||||
|
||||
for forecast in forecasts:
|
||||
try:
|
||||
product_id = UUID(forecast['product_id'])
|
||||
predicted_demand = float(forecast['predicted_demand'])
|
||||
|
||||
# Get current stock level from inventory
|
||||
stock_info = await self.inventory_client.get_stock_level(
|
||||
str(tenant_id), str(product_id)
|
||||
)
|
||||
|
||||
current_stock = stock_info.get('current_stock', 0) if stock_info else 0
|
||||
|
||||
# Calculate production quantity needed
|
||||
# Production needed = Predicted demand - Current stock (if positive)
|
||||
production_needed = max(0, predicted_demand - current_stock)
|
||||
|
||||
if production_needed <= 0:
|
||||
logger.info("Skipping product - sufficient stock",
|
||||
product_id=str(product_id),
|
||||
current_stock=current_stock,
|
||||
predicted_demand=predicted_demand)
|
||||
warnings.append(f"Product {product_id}: sufficient stock, no production needed")
|
||||
continue
|
||||
|
||||
# Get recipe for the product (if exists)
|
||||
# Note: In a real scenario, we'd fetch recipe_id from product/inventory
|
||||
# For now, we assume recipe_id = product_id or fetch from a mapping
|
||||
|
||||
# Create production batch
|
||||
batch_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'schedule_id': schedule.id,
|
||||
'recipe_id': product_id, # Assuming recipe_id matches product_id
|
||||
'batch_number': await self._generate_batch_number(session, tenant_id, target_date, batches_created + 1),
|
||||
'status': 'scheduled',
|
||||
'priority': 'normal',
|
||||
'planned_start_time': datetime.combine(target_date, datetime.min.time()),
|
||||
'planned_end_time': datetime.combine(target_date, datetime.max.time()),
|
||||
'planned_quantity': production_needed,
|
||||
'created_at': datetime.now(timezone.utc),
|
||||
'updated_at': datetime.now(timezone.utc),
|
||||
}
|
||||
|
||||
batch = await batch_repo.create_batch(batch_data)
|
||||
|
||||
batches_created += 1
|
||||
total_planned_quantity += production_needed
|
||||
|
||||
logger.info("Production batch created from forecast",
|
||||
batch_id=str(batch.id),
|
||||
product_id=str(product_id),
|
||||
planned_quantity=production_needed)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error creating batch for product {forecast.get('product_id')}: {str(e)}"
|
||||
logger.warning(error_msg, tenant_id=str(tenant_id))
|
||||
warnings.append(error_msg)
|
||||
continue
|
||||
|
||||
# Update schedule with batch counts
|
||||
await schedule_repo.update_schedule(
|
||||
schedule.id,
|
||||
tenant_id,
|
||||
{'total_batches': batches_created}
|
||||
)
|
||||
|
||||
logger.info("Production schedule generated successfully",
|
||||
tenant_id=str(tenant_id),
|
||||
schedule_id=str(schedule.id),
|
||||
batches_created=batches_created)
|
||||
|
||||
return {
|
||||
'schedule_id': schedule.id,
|
||||
'schedule_number': schedule.schedule_number,
|
||||
'batches_created': batches_created,
|
||||
'total_planned_quantity': total_planned_quantity,
|
||||
'warnings': warnings
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error generating production schedule from forecast",
|
||||
error=str(e), tenant_id=str(tenant_id))
|
||||
raise
|
||||
|
||||
async def _generate_batch_number(
|
||||
self,
|
||||
session,
|
||||
tenant_id: UUID,
|
||||
target_date: date,
|
||||
batch_index: int
|
||||
) -> str:
|
||||
"""Generate batch number in format BATCH-YYYYMMDD-NNN"""
|
||||
date_str = target_date.strftime("%Y%m%d")
|
||||
return f"BATCH-{date_str}-{batch_index:03d}"
|
||||
Reference in New Issue
Block a user