Imporve the i18 and frontend UI pages

This commit is contained in:
Urtzi Alfaro
2025-09-22 16:10:08 +02:00
parent ee36c45d25
commit 8d54202e91
32 changed files with 875 additions and 434 deletions

View File

@@ -8,8 +8,11 @@ Procurement API Endpoints - RESTful APIs for procurement planning
import uuid
from datetime import date
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
from app.core.database import get_db
from app.core.config import settings
@@ -131,12 +134,12 @@ async def get_procurement_plan_by_date(
@monitor_performance("list_procurement_plans")
async def list_procurement_plans(
tenant_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by plan status"),
plan_status: Optional[str] = Query(None, description="Filter by plan status"),
start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"),
limit: int = Query(50, ge=1, le=100, description="Number of plans to return"),
offset: int = Query(0, ge=0, description="Number of plans to skip"),
tenant_access: TenantAccess = Depends(get_current_tenant),
# tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
@@ -147,8 +150,8 @@ async def list_procurement_plans(
try:
# Get plans from repository directly for listing
plans = await procurement_service.plan_repo.list_plans(
tenant_access.tenant_id,
status=status,
tenant_id,
status=plan_status,
start_date=start_date,
end_date=end_date,
limit=limit,
@@ -156,7 +159,14 @@ async def list_procurement_plans(
)
# Convert to response models
plan_responses = [ProcurementPlanResponse.model_validate(plan) for plan in plans]
plan_responses = []
for plan in plans:
try:
plan_response = ProcurementPlanResponse.model_validate(plan)
plan_responses.append(plan_response)
except Exception as validation_error:
logger.error(f"Error validating plan {plan.id}: {validation_error}")
raise
# For simplicity, we'll use the returned count as total
# In a production system, you'd want a separate count query
@@ -404,24 +414,33 @@ async def get_critical_requirements(
@monitor_performance("trigger_daily_scheduler")
async def trigger_daily_scheduler(
tenant_id: uuid.UUID,
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
request: Request
):
"""
Manually trigger the daily scheduler for the current tenant
This endpoint is primarily for testing and maintenance purposes.
Note: Authentication temporarily disabled for development testing.
"""
try:
# Process daily plan for current tenant only
await procurement_service._process_daily_plan_for_tenant(tenant_access.tenant_id)
return {
"success": True,
"message": "Daily scheduler executed successfully",
"tenant_id": str(tenant_access.tenant_id)
}
# Get the scheduler service from app state and call process_tenant_procurement
if hasattr(request.app.state, 'scheduler_service'):
scheduler_service = request.app.state.scheduler_service
await scheduler_service.process_tenant_procurement(tenant_id)
return {
"success": True,
"message": "Daily scheduler executed successfully for tenant",
"tenant_id": str(tenant_id)
}
else:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scheduler service is not available"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -429,6 +448,7 @@ async def trigger_daily_scheduler(
)
@router.get("/procurement/health")
async def procurement_health_check():
"""

View File

@@ -68,5 +68,6 @@ class OrdersSettings(BaseServiceSettings):
SALES_SERVICE_URL: str = os.getenv("SALES_SERVICE_URL", "http://sales-service:8000")
# Global settings instance
settings = OrdersSettings()

View File

@@ -228,7 +228,7 @@ class ProcurementRequirementBase(BaseModel):
product_name: str = Field(..., min_length=1, max_length=200)
product_sku: Optional[str] = Field(None, max_length=100)
product_category: Optional[str] = Field(None, max_length=100)
product_type: str = Field(default="ingredient") # TODO: Create ProductType enum if needed
product_type: str = Field(default="ingredient")
required_quantity: Decimal = Field(..., gt=0)
unit_of_measure: str = Field(..., min_length=1, max_length=50)
safety_stock_quantity: Decimal = Field(default=Decimal("0.000"), ge=0)

View File

@@ -143,11 +143,11 @@ class ProcurementPlanBase(ProcurementBase):
plan_period_end: date
planning_horizon_days: int = Field(default=14, gt=0)
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal)$")
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal|urgent)$")
priority: str = Field(default="normal", pattern="^(high|normal|low)$")
business_model: Optional[str] = Field(None, pattern="^(individual_bakery|central_bakery)$")
procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed)$")
procurement_strategy: str = Field(default="just_in_time", pattern="^(just_in_time|bulk|mixed|bulk_order)$")
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
supply_risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$")

View File

@@ -30,7 +30,11 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
"""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):
@@ -45,112 +49,153 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
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",
id="weekly_procurement_optimization",
name="Weekly Procurement Optimization",
misfire_grace_time=600,
coalesce=True,
max_instances=1
)
logger.info("Procurement scheduled jobs configured")
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")
# Get active tenants
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:
logger.error("Error processing tenant procurement",
failed_tenants += 1
logger.error("❌ Error processing tenant procurement",
tenant_id=str(tenant_id),
error=str(e))
logger.info("Daily procurement planning completed",
logger.info("🎯 Daily procurement planning completed",
total_tenants=len(active_tenants),
processed_tenants=processed_tenants)
processed_tenants=processed_tenants,
failed_tenants=failed_tenants)
except Exception as e:
self._errors_count += 1
logger.error("Daily procurement planning failed", error=str(e))
logger.error("💥 Daily procurement planning failed completely", error=str(e))
async def get_active_tenants(self) -> List[UUID]:
"""Override to return test tenant since tenants table is not in orders DB"""
# For testing, return the known test tenant
return [UUID('c464fb3e-7af2-46e6-9e43-85318f34199a')]
"""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.debug("Procurement plan already exists",
logger.info("📋 Procurement plan already exists, skipping",
tenant_id=str(tenant_id),
plan_date=str(planning_date))
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')
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)
plan = result.plan if result.success else None
if plan:
# Send notification about new plan
await self.send_procurement_notification(
tenant_id, plan, "plan_created"
)
logger.info("Procurement plan created successfully",
tenant_id=str(tenant_id),
plan_id=str(plan.id),
plan_date=str(planning_date))
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",
logger.error("💥 Error processing tenant procurement",
tenant_id=str(tenant_id),
error=str(e))
raise
@@ -237,10 +282,16 @@ class ProcurementSchedulerService(BaseAlertService, AlertServiceMixin):
error=str(e))
async def test_procurement_generation(self):
"""Test method to manually trigger procurement planning for testing"""
test_tenant_id = UUID('c464fb3e-7af2-46e6-9e43-85318f34199a')
"""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")

View File

@@ -164,13 +164,22 @@ class ProcurementService:
if requirements_data:
await self.requirement_repo.create_requirements_batch(requirements_data)
# Update plan with correct total_requirements count
await self.plan_repo.update_plan(
plan.id,
tenant_id,
{"total_requirements": len(requirements_data)}
# Calculate total costs from requirements
total_estimated_cost = sum(
req_data.get('estimated_total_cost', Decimal('0'))
for req_data in requirements_data
)
# Update plan with correct totals
plan_updates = {
"total_requirements": len(requirements_data),
"total_estimated_cost": total_estimated_cost,
"total_approved_cost": Decimal('0'), # Will be updated during approval
"cost_variance": Decimal('0') - total_estimated_cost # Initial variance
}
await self.plan_repo.update_plan(plan.id, tenant_id, plan_updates)
await self.db.commit()
@@ -213,6 +222,13 @@ class ProcurementService:
if status == "approved":
updates["approved_at"] = datetime.utcnow()
updates["approved_by"] = updated_by
# When approving, set approved cost equal to estimated cost
# (In real system, this might be different based on actual approvals)
plan = await self.plan_repo.get_plan_by_id(plan_id, tenant_id)
if plan and plan.total_estimated_cost:
updates["total_approved_cost"] = plan.total_estimated_cost
updates["cost_variance"] = Decimal('0') # No variance initially
elif status == "in_execution":
updates["execution_started_at"] = datetime.utcnow()
elif status in ["completed", "cancelled"]:
@@ -497,25 +513,168 @@ class ProcurementService:
async def _get_procurement_summary(self, tenant_id: uuid.UUID) -> ProcurementSummary:
"""Get procurement summary for dashboard"""
# Implement summary calculation
return ProcurementSummary(
total_plans=0,
active_plans=0,
total_requirements=0,
pending_requirements=0,
critical_requirements=0,
total_estimated_cost=Decimal('0'),
total_approved_cost=Decimal('0'),
cost_variance=Decimal('0')
)
try:
# Get all plans for the tenant
all_plans = await self.plan_repo.list_plans(tenant_id, limit=1000)
# Debug logging
logger.info(f"Found {len(all_plans)} plans for tenant {tenant_id}")
for plan in all_plans[:3]: # Log first 3 plans for debugging
logger.info(f"Plan {plan.plan_number}: status={plan.status}, requirements={plan.total_requirements}, cost={plan.total_estimated_cost}")
# Calculate total and active plans
total_plans = len(all_plans)
active_statuses = ['draft', 'pending_approval', 'approved', 'in_execution']
active_plans = len([p for p in all_plans if p.status in active_statuses])
# Get all requirements for analysis
pending_requirements = []
critical_requirements = []
try:
pending_requirements = await self.requirement_repo.get_pending_requirements(tenant_id)
logger.info(f"Found {len(pending_requirements)} pending requirements")
except Exception as req_err:
logger.warning(f"Error getting pending requirements: {req_err}")
try:
critical_requirements = await self.requirement_repo.get_critical_requirements(tenant_id)
logger.info(f"Found {len(critical_requirements)} critical requirements")
except Exception as crit_err:
logger.warning(f"Error getting critical requirements: {crit_err}")
# Calculate total requirements across all plans
total_requirements = 0
total_estimated_cost = Decimal('0')
total_approved_cost = Decimal('0')
plans_to_fix = [] # Track plans that need recalculation
for plan in all_plans:
plan_reqs = plan.total_requirements or 0
plan_est_cost = plan.total_estimated_cost or Decimal('0')
plan_app_cost = plan.total_approved_cost or Decimal('0')
# If plan has requirements but zero costs, it needs recalculation
if plan_reqs > 0 and plan_est_cost == Decimal('0'):
plans_to_fix.append(plan.id)
logger.info(f"Plan {plan.plan_number} needs cost recalculation")
total_requirements += plan_reqs
total_estimated_cost += plan_est_cost
total_approved_cost += plan_app_cost
# Fix plans with missing totals (do this in background to avoid blocking dashboard)
if plans_to_fix:
logger.info(f"Found {len(plans_to_fix)} plans that need cost recalculation")
# For now, just log. In production, you might want to queue this for background processing
# Calculate cost variance
cost_variance = total_approved_cost - total_estimated_cost
logger.info(f"Summary totals: plans={total_plans}, active={active_plans}, requirements={total_requirements}, est_cost={total_estimated_cost}")
return ProcurementSummary(
total_plans=total_plans,
active_plans=active_plans,
total_requirements=total_requirements,
pending_requirements=len(pending_requirements),
critical_requirements=len(critical_requirements),
total_estimated_cost=total_estimated_cost,
total_approved_cost=total_approved_cost,
cost_variance=cost_variance
)
except Exception as e:
logger.error("Error calculating procurement summary", error=str(e), tenant_id=tenant_id)
# Return empty summary on error
return ProcurementSummary(
total_plans=0,
active_plans=0,
total_requirements=0,
pending_requirements=0,
critical_requirements=0,
total_estimated_cost=Decimal('0'),
total_approved_cost=Decimal('0'),
cost_variance=Decimal('0')
)
async def _get_upcoming_deliveries(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]:
"""Get upcoming deliveries"""
return []
try:
# Get requirements with expected delivery dates in the next 7 days
today = date.today()
upcoming_date = today + timedelta(days=7)
# Get all pending requirements that have expected delivery dates
pending_requirements = await self.requirement_repo.get_pending_requirements(tenant_id)
upcoming_deliveries = []
for req in pending_requirements:
if (req.expected_delivery_date and
today <= req.expected_delivery_date <= upcoming_date and
req.delivery_status in ['pending', 'in_transit']):
upcoming_deliveries.append({
"id": str(req.id),
"requirement_number": req.requirement_number,
"product_name": req.product_name,
"supplier_name": req.supplier_name or "Sin proveedor",
"expected_delivery_date": req.expected_delivery_date.isoformat(),
"ordered_quantity": float(req.ordered_quantity or 0),
"unit_of_measure": req.unit_of_measure,
"delivery_status": req.delivery_status,
"days_until_delivery": (req.expected_delivery_date - today).days
})
# Sort by delivery date
upcoming_deliveries.sort(key=lambda x: x["expected_delivery_date"])
return upcoming_deliveries[:10] # Return top 10 upcoming deliveries
except Exception as e:
logger.error("Error getting upcoming deliveries", error=str(e), tenant_id=tenant_id)
return []
async def _get_overdue_requirements(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]:
"""Get overdue requirements"""
return []
try:
today = date.today()
# Get all pending requirements
pending_requirements = await self.requirement_repo.get_pending_requirements(tenant_id)
overdue_requirements = []
for req in pending_requirements:
# Check if requirement is overdue based on required_by_date
if (req.required_by_date and req.required_by_date < today and
req.status in ['pending', 'approved']):
days_overdue = (today - req.required_by_date).days
overdue_requirements.append({
"id": str(req.id),
"requirement_number": req.requirement_number,
"product_name": req.product_name,
"supplier_name": req.supplier_name or "Sin proveedor",
"required_by_date": req.required_by_date.isoformat(),
"required_quantity": float(req.required_quantity),
"unit_of_measure": req.unit_of_measure,
"status": req.status,
"priority": req.priority,
"days_overdue": days_overdue,
"estimated_total_cost": float(req.estimated_total_cost or 0)
})
# Sort by days overdue (most overdue first)
overdue_requirements.sort(key=lambda x: x["days_overdue"], reverse=True)
return overdue_requirements[:10] # Return top 10 overdue requirements
except Exception as e:
logger.error("Error getting overdue requirements", error=str(e), tenant_id=tenant_id)
return []
async def _get_low_stock_alerts(self, tenant_id: uuid.UUID) -> List[Dict[str, Any]]:
"""Get low stock alerts from inventory service"""
@@ -527,4 +686,63 @@ class ProcurementService:
async def _get_performance_metrics(self, tenant_id: uuid.UUID) -> Dict[str, Any]:
"""Get performance metrics"""
return {}
try:
# Get completed and active plans for metrics calculation
all_plans = await self.plan_repo.list_plans(tenant_id, limit=1000)
completed_plans = [p for p in all_plans if p.status == 'completed']
if not completed_plans:
return {
"average_fulfillment_rate": 0.0,
"average_on_time_delivery": 0.0,
"cost_accuracy": 0.0,
"plan_completion_rate": 0.0,
"supplier_performance": 0.0
}
# Calculate fulfillment rate
total_fulfillment = sum(float(p.fulfillment_rate or 0) for p in completed_plans)
avg_fulfillment = total_fulfillment / len(completed_plans) if completed_plans else 0.0
# Calculate on-time delivery rate
total_on_time = sum(float(p.on_time_delivery_rate or 0) for p in completed_plans)
avg_on_time = total_on_time / len(completed_plans) if completed_plans else 0.0
# Calculate cost accuracy (how close approved costs were to estimated)
cost_accuracy_sum = 0.0
cost_plans_count = 0
for plan in completed_plans:
if plan.total_estimated_cost and plan.total_approved_cost and plan.total_estimated_cost > 0:
accuracy = min(100.0, (float(plan.total_approved_cost) / float(plan.total_estimated_cost)) * 100)
cost_accuracy_sum += accuracy
cost_plans_count += 1
avg_cost_accuracy = cost_accuracy_sum / cost_plans_count if cost_plans_count > 0 else 0.0
# Calculate plan completion rate
total_plans = len(all_plans)
completion_rate = (len(completed_plans) / total_plans * 100) if total_plans > 0 else 0.0
# Calculate supplier performance (average quality score)
quality_scores = [float(p.quality_score or 0) for p in completed_plans if p.quality_score]
avg_supplier_performance = sum(quality_scores) / len(quality_scores) if quality_scores else 0.0
return {
"average_fulfillment_rate": round(avg_fulfillment, 2),
"average_on_time_delivery": round(avg_on_time, 2),
"cost_accuracy": round(avg_cost_accuracy, 2),
"plan_completion_rate": round(completion_rate, 2),
"supplier_performance": round(avg_supplier_performance, 2),
"total_plans_analyzed": len(completed_plans),
"active_plans": len([p for p in all_plans if p.status in ['draft', 'pending_approval', 'approved', 'in_execution']])
}
except Exception as e:
logger.error("Error calculating performance metrics", error=str(e), tenant_id=tenant_id)
return {
"average_fulfillment_rate": 0.0,
"average_on_time_delivery": 0.0,
"cost_accuracy": 0.0,
"plan_completion_rate": 0.0,
"supplier_performance": 0.0
}

View File

@@ -1,290 +0,0 @@
#!/usr/bin/env python3
"""
Script to populate the database with test data for orders and customers
"""
import os
import sys
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
import asyncio
import random
# Add the parent directory to the path to import our modules
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_session
from app.models.customer import Customer, CustomerContact
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
# Test tenant ID - in a real environment this would be provided
TEST_TENANT_ID = "946206b3-7446-436b-b29d-f265b28d9ff5"
# Sample customer data
SAMPLE_CUSTOMERS = [
{
"name": "María García López",
"customer_type": "individual",
"email": "maria.garcia@email.com",
"phone": "+34 612 345 678",
"city": "Madrid",
"country": "España",
"customer_segment": "vip",
"is_active": True
},
{
"name": "Panadería San Juan",
"business_name": "Panadería San Juan S.L.",
"customer_type": "business",
"email": "pedidos@panaderiasjuan.com",
"phone": "+34 687 654 321",
"city": "Barcelona",
"country": "España",
"customer_segment": "wholesale",
"is_active": True
},
{
"name": "Carlos Rodríguez Martín",
"customer_type": "individual",
"email": "carlos.rodriguez@email.com",
"phone": "+34 698 765 432",
"city": "Valencia",
"country": "España",
"customer_segment": "regular",
"is_active": True
},
{
"name": "Ana Fernández Ruiz",
"customer_type": "individual",
"email": "ana.fernandez@email.com",
"phone": "+34 634 567 890",
"city": "Sevilla",
"country": "España",
"customer_segment": "regular",
"is_active": True
},
{
"name": "Café Central",
"business_name": "Café Central Madrid S.L.",
"customer_type": "business",
"email": "compras@cafecentral.es",
"phone": "+34 623 456 789",
"city": "Madrid",
"country": "España",
"customer_segment": "wholesale",
"is_active": True
},
{
"name": "Laura Martínez Silva",
"customer_type": "individual",
"email": "laura.martinez@email.com",
"phone": "+34 645 789 012",
"city": "Bilbao",
"country": "España",
"customer_segment": "regular",
"is_active": False # Inactive customer for testing
}
]
# Sample products (in a real system these would come from a products service)
SAMPLE_PRODUCTS = [
{"id": str(uuid.uuid4()), "name": "Pan Integral Artesano", "price": Decimal("2.50"), "category": "Panadería"},
{"id": str(uuid.uuid4()), "name": "Croissant de Mantequilla", "price": Decimal("1.80"), "category": "Bollería"},
{"id": str(uuid.uuid4()), "name": "Tarta de Santiago", "price": Decimal("18.90"), "category": "Repostería"},
{"id": str(uuid.uuid4()), "name": "Magdalenas de Limón", "price": Decimal("0.90"), "category": "Bollería"},
{"id": str(uuid.uuid4()), "name": "Empanada de Atún", "price": Decimal("3.50"), "category": "Salado"},
{"id": str(uuid.uuid4()), "name": "Brownie de Chocolate", "price": Decimal("3.20"), "category": "Repostería"},
{"id": str(uuid.uuid4()), "name": "Baguette Francesa", "price": Decimal("2.80"), "category": "Panadería"},
{"id": str(uuid.uuid4()), "name": "Palmera de Chocolate", "price": Decimal("2.40"), "category": "Bollería"},
]
async def create_customers(session: AsyncSession) -> list[Customer]:
"""Create sample customers"""
customers = []
for i, customer_data in enumerate(SAMPLE_CUSTOMERS):
customer = Customer(
tenant_id=TEST_TENANT_ID,
customer_code=f"CUST-{i+1:04d}",
name=customer_data["name"],
business_name=customer_data.get("business_name"),
customer_type=customer_data["customer_type"],
email=customer_data["email"],
phone=customer_data["phone"],
city=customer_data["city"],
country=customer_data["country"],
is_active=customer_data["is_active"],
preferred_delivery_method="delivery" if random.choice([True, False]) else "pickup",
payment_terms=random.choice(["immediate", "net_30"]),
customer_segment=customer_data["customer_segment"],
priority_level=random.choice(["normal", "high"]) if customer_data["customer_segment"] == "vip" else "normal",
discount_percentage=Decimal("5.0") if customer_data["customer_segment"] == "vip" else
Decimal("10.0") if customer_data["customer_segment"] == "wholesale" else Decimal("0.0"),
total_orders=random.randint(5, 50),
total_spent=Decimal(str(random.randint(100, 5000))),
average_order_value=Decimal(str(random.randint(15, 150))),
last_order_date=datetime.now() - timedelta(days=random.randint(1, 30))
)
session.add(customer)
customers.append(customer)
await session.commit()
return customers
async def create_orders(session: AsyncSession, customers: list[Customer]):
"""Create sample orders in different statuses"""
order_statuses = [
"pending", "confirmed", "in_production", "ready",
"out_for_delivery", "delivered", "cancelled"
]
order_types = ["standard", "rush", "recurring", "special"]
priorities = ["low", "normal", "high"]
delivery_methods = ["delivery", "pickup"]
payment_statuses = ["pending", "partial", "paid", "failed"]
for i in range(25): # Create 25 sample orders
customer = random.choice(customers)
order_status = random.choice(order_statuses)
# Create order date in the last 30 days
order_date = datetime.now() - timedelta(days=random.randint(0, 30))
# Create delivery date (1-7 days after order date)
delivery_date = order_date + timedelta(days=random.randint(1, 7))
order = CustomerOrder(
tenant_id=TEST_TENANT_ID,
order_number=f"ORD-{datetime.now().year}-{i+1:04d}",
customer_id=customer.id,
status=order_status,
order_type=random.choice(order_types),
priority=random.choice(priorities),
order_date=order_date,
requested_delivery_date=delivery_date,
confirmed_delivery_date=delivery_date if order_status not in ["pending", "cancelled"] else None,
actual_delivery_date=delivery_date if order_status == "delivered" else None,
delivery_method=random.choice(delivery_methods),
delivery_instructions=random.choice([
None, "Dejar en recepción", "Llamar al timbre", "Cuidado con el escalón"
]),
discount_percentage=customer.discount_percentage,
payment_status=random.choice(payment_statuses) if order_status != "cancelled" else "failed",
payment_method=random.choice(["cash", "card", "bank_transfer"]),
payment_terms=customer.payment_terms,
special_instructions=random.choice([
None, "Sin gluten", "Decoración especial", "Entrega temprano", "Cliente VIP"
]),
order_source=random.choice(["manual", "online", "phone"]),
sales_channel=random.choice(["direct", "wholesale"]),
customer_notified_confirmed=order_status not in ["pending", "cancelled"],
customer_notified_ready=order_status in ["ready", "out_for_delivery", "delivered"],
customer_notified_delivered=order_status == "delivered",
quality_score=Decimal(str(random.randint(70, 100) / 10)) if order_status == "delivered" else None,
customer_rating=random.randint(3, 5) if order_status == "delivered" else None
)
session.add(order)
await session.flush() # Flush to get the order ID
# Create order items
num_items = random.randint(1, 5)
subtotal = Decimal("0.00")
for _ in range(num_items):
product = random.choice(SAMPLE_PRODUCTS)
quantity = random.randint(1, 10)
unit_price = product["price"]
line_total = unit_price * quantity
order_item = OrderItem(
order_id=order.id,
product_id=product["id"],
product_name=product["name"],
product_category=product["category"],
quantity=quantity,
unit_of_measure="unidad",
unit_price=unit_price,
line_discount=Decimal("0.00"),
line_total=line_total,
status=order_status if order_status != "cancelled" else "cancelled"
)
session.add(order_item)
subtotal += line_total
# Calculate financial totals
discount_amount = subtotal * (order.discount_percentage / 100)
tax_amount = (subtotal - discount_amount) * Decimal("0.21") # 21% VAT
delivery_fee = Decimal("3.50") if order.delivery_method == "delivery" and subtotal < 25 else Decimal("0.00")
total_amount = subtotal - discount_amount + tax_amount + delivery_fee
# Update order with calculated totals
order.subtotal = subtotal
order.discount_amount = discount_amount
order.tax_amount = tax_amount
order.delivery_fee = delivery_fee
order.total_amount = total_amount
# Create status history
status_history = OrderStatusHistory(
order_id=order.id,
from_status=None,
to_status=order_status,
event_type="status_change",
event_description=f"Order created with status: {order_status}",
change_source="system",
changed_at=order_date,
customer_notified=order_status != "pending"
)
session.add(status_history)
# Add additional status changes for non-pending orders
if order_status != "pending":
current_date = order_date
for status in ["confirmed", "in_production", "ready"]:
if order_statuses.index(status) <= order_statuses.index(order_status):
current_date += timedelta(hours=random.randint(2, 12))
status_change = OrderStatusHistory(
order_id=order.id,
from_status="pending" if status == "confirmed" else None,
to_status=status,
event_type="status_change",
event_description=f"Order status changed to: {status}",
change_source="manual",
changed_at=current_date,
customer_notified=True
)
session.add(status_change)
await session.commit()
async def main():
"""Main function to seed the database"""
print("🌱 Starting database seeding...")
async for session in get_session():
try:
print("📋 Creating customers...")
customers = await create_customers(session)
print(f"✅ Created {len(customers)} customers")
print("📦 Creating orders...")
await create_orders(session, customers)
print("✅ Created orders with different statuses")
print("🎉 Database seeding completed successfully!")
except Exception as e:
print(f"❌ Error during seeding: {e}")
await session.rollback()
raise
finally:
await session.close()
if __name__ == "__main__":
asyncio.run(main())