Improve the frontend 3

This commit is contained in:
Urtzi Alfaro
2025-10-30 21:08:07 +01:00
parent 36217a2729
commit 63f5c6d512
184 changed files with 21512 additions and 7442 deletions

View File

@@ -1,6 +1,6 @@
"""
Internal Demo Cloning API for Orders Service
Service-to-service endpoint for cloning order and procurement data
Service-to-service endpoint for cloning order and customer data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
@@ -15,7 +15,6 @@ from decimal import Decimal
from app.core.database import get_db
from app.models.order import CustomerOrder, OrderItem
from app.models.procurement import ProcurementPlan, ProcurementRequirement
from app.models.customer import Customer
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
@@ -54,7 +53,6 @@ async def clone_demo_data(
Clones:
- Customers
- Customer orders with line items
- Procurement plans with requirements
- Adjusts dates to recent timeframe
Args:
@@ -96,8 +94,6 @@ async def clone_demo_data(
"customers": 0,
"customer_orders": 0,
"order_line_items": 0,
"procurement_plans": 0,
"procurement_requirements": 0,
"alerts_generated": 0
}
@@ -255,132 +251,6 @@ async def clone_demo_data(
db.add(new_item)
stats["order_line_items"] += 1
# Clone Procurement Plans with Requirements
result = await db.execute(
select(ProcurementPlan).where(ProcurementPlan.tenant_id == base_uuid)
)
base_plans = result.scalars().all()
logger.info(
"Found procurement plans to clone",
count=len(base_plans),
base_tenant=str(base_uuid)
)
# Calculate date offset for procurement
if base_plans:
max_plan_date = max(plan.plan_date for plan in base_plans)
today_date = date.today()
days_diff = (today_date - max_plan_date).days
plan_date_offset = timedelta(days=days_diff)
else:
plan_date_offset = timedelta(days=0)
plan_id_map = {}
for plan in base_plans:
new_plan_id = uuid.uuid4()
plan_id_map[plan.id] = new_plan_id
new_plan = ProcurementPlan(
id=new_plan_id,
tenant_id=virtual_uuid,
plan_number=f"PROC-{uuid.uuid4().hex[:8].upper()}",
plan_date=plan.plan_date + plan_date_offset if plan.plan_date else None,
plan_period_start=plan.plan_period_start + plan_date_offset if plan.plan_period_start else None,
plan_period_end=plan.plan_period_end + plan_date_offset if plan.plan_period_end else None,
planning_horizon_days=plan.planning_horizon_days,
status=plan.status,
plan_type=plan.plan_type,
priority=plan.priority,
business_model=plan.business_model,
procurement_strategy=plan.procurement_strategy,
total_requirements=plan.total_requirements,
total_estimated_cost=plan.total_estimated_cost,
total_approved_cost=plan.total_approved_cost,
cost_variance=plan.cost_variance,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(new_plan)
stats["procurement_plans"] += 1
# Clone Procurement Requirements
for old_plan_id, new_plan_id in plan_id_map.items():
result = await db.execute(
select(ProcurementRequirement).where(ProcurementRequirement.plan_id == old_plan_id)
)
requirements = result.scalars().all()
for req in requirements:
new_req = ProcurementRequirement(
id=uuid.uuid4(),
plan_id=new_plan_id,
requirement_number=req.requirement_number,
product_id=req.product_id,
product_name=req.product_name,
product_sku=req.product_sku,
product_category=req.product_category,
product_type=req.product_type,
required_quantity=req.required_quantity,
unit_of_measure=req.unit_of_measure,
safety_stock_quantity=req.safety_stock_quantity,
total_quantity_needed=req.total_quantity_needed,
current_stock_level=req.current_stock_level,
reserved_stock=req.reserved_stock,
available_stock=req.available_stock,
net_requirement=req.net_requirement,
order_demand=req.order_demand,
production_demand=req.production_demand,
forecast_demand=req.forecast_demand,
buffer_demand=req.buffer_demand,
preferred_supplier_id=req.preferred_supplier_id,
backup_supplier_id=req.backup_supplier_id,
supplier_name=req.supplier_name,
supplier_lead_time_days=req.supplier_lead_time_days,
minimum_order_quantity=req.minimum_order_quantity,
estimated_unit_cost=req.estimated_unit_cost,
estimated_total_cost=req.estimated_total_cost,
last_purchase_cost=req.last_purchase_cost,
cost_variance=req.cost_variance,
required_by_date=req.required_by_date + plan_date_offset if req.required_by_date else None,
lead_time_buffer_days=req.lead_time_buffer_days,
suggested_order_date=req.suggested_order_date + plan_date_offset if req.suggested_order_date else None,
latest_order_date=req.latest_order_date + plan_date_offset if req.latest_order_date else None,
quality_specifications=req.quality_specifications,
special_requirements=req.special_requirements,
storage_requirements=req.storage_requirements,
shelf_life_days=req.shelf_life_days,
status=req.status,
priority=req.priority,
risk_level=req.risk_level,
purchase_order_id=req.purchase_order_id,
purchase_order_number=req.purchase_order_number,
ordered_quantity=req.ordered_quantity,
ordered_at=req.ordered_at,
expected_delivery_date=req.expected_delivery_date + plan_date_offset if req.expected_delivery_date else None,
actual_delivery_date=req.actual_delivery_date + plan_date_offset if req.actual_delivery_date else None,
received_quantity=req.received_quantity,
delivery_status=req.delivery_status,
fulfillment_rate=req.fulfillment_rate,
on_time_delivery=req.on_time_delivery,
quality_rating=req.quality_rating,
source_orders=req.source_orders,
source_production_batches=req.source_production_batches,
demand_analysis=req.demand_analysis,
approved_quantity=req.approved_quantity,
approved_cost=req.approved_cost,
approved_at=req.approved_at,
approved_by=req.approved_by,
procurement_notes=req.procurement_notes,
supplier_communication=req.supplier_communication,
requirement_metadata=req.requirement_metadata,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc)
)
db.add(new_req)
stats["procurement_requirements"] += 1
# Commit cloned data
await db.commit()
@@ -389,7 +259,7 @@ async def clone_demo_data(
# This eliminates duplicate alerts and provides a more realistic demo experience.
stats["alerts_generated"] = 0
total_records = stats["customers"] + stats["customer_orders"] + stats["order_line_items"] + stats["procurement_plans"] + stats["procurement_requirements"]
total_records = stats["customers"] + stats["customer_orders"] + stats["order_line_items"]
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
@@ -462,13 +332,10 @@ async def delete_demo_data(
order_count = await db.scalar(select(func.count(CustomerOrder.id)).where(CustomerOrder.tenant_id == virtual_uuid))
item_count = await db.scalar(select(func.count(OrderItem.id)).where(OrderItem.tenant_id == virtual_uuid))
customer_count = await db.scalar(select(func.count(Customer.id)).where(Customer.tenant_id == virtual_uuid))
procurement_count = await db.scalar(select(func.count(ProcurementPlan.id)).where(ProcurementPlan.tenant_id == virtual_uuid))
# Delete in order
await db.execute(delete(OrderItem).where(OrderItem.tenant_id == virtual_uuid))
await db.execute(delete(CustomerOrder).where(CustomerOrder.tenant_id == virtual_uuid))
await db.execute(delete(ProcurementRequirement).where(ProcurementRequirement.tenant_id == virtual_uuid))
await db.execute(delete(ProcurementPlan).where(ProcurementPlan.tenant_id == virtual_uuid))
await db.execute(delete(Customer).where(Customer.tenant_id == virtual_uuid))
await db.commit()
@@ -483,8 +350,7 @@ async def delete_demo_data(
"orders": order_count,
"items": item_count,
"customers": customer_count,
"procurement": procurement_count,
"total": order_count + item_count + customer_count + procurement_count
"total": order_count + item_count + customer_count
},
"duration_ms": duration_ms
}

View File

@@ -1,850 +0,0 @@
# ================================================================
# services/orders/app/api/procurement_operations.py
# ================================================================
"""
Procurement Operations API Endpoints - BUSINESS logic for procurement planning
RESTful APIs for procurement planning, approval workflows, and PO management
"""
import uuid
from datetime import date
from typing import List, Optional
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
from app.services.procurement_service import ProcurementService
from app.schemas.procurement_schemas import (
ProcurementPlanResponse, GeneratePlanRequest, GeneratePlanResponse,
DashboardData, PaginatedProcurementPlans
)
from shared.auth.decorators import require_authentication, get_current_user_dep
from fastapi import Depends, Request
from typing import Dict, Any
import uuid
from shared.clients.inventory_client import InventoryServiceClient
from shared.clients.forecast_client import ForecastServiceClient
from shared.config.base import BaseServiceSettings
from shared.monitoring.decorators import monitor_performance
from shared.routing import RouteBuilder
from shared.auth.access_control import (
require_user_role,
admin_role_required,
owner_role_required,
require_subscription_tier,
analytics_tier_required,
enterprise_tier_required
)
# Create route builder for consistent URL structure
route_builder = RouteBuilder('orders')
# Create router
router = APIRouter(tags=["Procurement Planning"])
# Create service settings
service_settings = BaseServiceSettings()
# Simple TenantAccess class for compatibility
class TenantAccess:
def __init__(self, tenant_id: uuid.UUID, user_id: str):
self.tenant_id = tenant_id
self.user_id = user_id
async def get_current_tenant(
request: Request,
current_user: Dict[str, Any] = Depends(get_current_user_dep)
) -> TenantAccess:
"""Get current tenant from user context"""
# For now, create a simple tenant access from user data
# In a real implementation, this would validate tenant access
tenant_id = current_user.get('tenant_id')
if not tenant_id:
# Try to get from headers as fallback
tenant_id = request.headers.get('x-tenant-id')
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant access required"
)
try:
tenant_uuid = uuid.UUID(tenant_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant ID format"
)
return TenantAccess(
tenant_id=tenant_uuid,
user_id=current_user['user_id']
)
async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
"""Get procurement service instance with all required clients"""
from shared.clients.suppliers_client import SuppliersServiceClient
inventory_client = InventoryServiceClient(service_settings)
forecast_client = ForecastServiceClient(service_settings, "orders-service")
suppliers_client = SuppliersServiceClient(service_settings)
return ProcurementService(db, service_settings, inventory_client, forecast_client, suppliers_client)
# ================================================================
# PROCUREMENT PLAN ENDPOINTS
# ================================================================
@router.get(
route_builder.build_operations_route("procurement/plans/current"),
response_model=Optional[ProcurementPlanResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@monitor_performance("get_current_procurement_plan")
async def get_current_procurement_plan(
tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get the procurement plan for the current day (forecasting for the next day)
Returns the plan details, including requirements per item.
"""
try:
plan = await procurement_service.get_current_plan(tenant_access.tenant_id)
return plan
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving current procurement plan: {str(e)}"
)
@router.get(
route_builder.build_operations_route("procurement/plans/date/{plan_date}"),
response_model=Optional[ProcurementPlanResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@monitor_performance("get_procurement_plan_by_date")
async def get_procurement_plan_by_date(
tenant_id: uuid.UUID,
plan_date: date,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get the procurement plan for a specific date (format: YYYY-MM-DD)
Returns the plan details, including requirements per item for the specified date.
"""
try:
plan = await procurement_service.get_plan_by_date(tenant_access.tenant_id, plan_date)
return plan
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving procurement plan for {plan_date}: {str(e)}"
)
@router.get(
route_builder.build_operations_route("procurement/plans"),
response_model=PaginatedProcurementPlans
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@monitor_performance("list_procurement_plans")
async def list_procurement_plans(
tenant_id: uuid.UUID,
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"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
List procurement plans with optional filters
Supports filtering by status, date range, and pagination.
"""
try:
# Get plans from repository directly for listing
plans = await procurement_service.plan_repo.list_plans(
tenant_id,
status=plan_status,
start_date=start_date,
end_date=end_date,
limit=limit,
offset=offset
)
# Convert to response models
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
total = len(plan_responses)
has_more = len(plan_responses) == limit
return PaginatedProcurementPlans(
plans=plan_responses,
total=total,
page=offset // limit + 1 if limit > 0 else 1,
limit=limit,
has_more=has_more
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error listing procurement plans: {str(e)}"
)
@router.post(
route_builder.build_operations_route("procurement/plans/generate"),
response_model=GeneratePlanResponse
)
@require_user_role(['member', 'admin', 'owner'])
@monitor_performance("generate_procurement_plan")
async def generate_procurement_plan(
tenant_id: uuid.UUID,
request: GeneratePlanRequest,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Manually trigger the generation of a procurement plan
This can serve as a fallback if the daily scheduler hasn't run,
or for testing purposes. Can be forced to regenerate an existing plan.
"""
try:
if not settings.PROCUREMENT_PLANNING_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Procurement planning is currently disabled"
)
result = await procurement_service.generate_procurement_plan(
tenant_access.tenant_id,
request
)
if not result.success:
# Return the result with errors but don't raise an exception
# since the service method handles the error state properly
return result
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error generating procurement plan: {str(e)}"
)
@router.put(
route_builder.build_operations_route("procurement/plans/{plan_id}/status")
)
@require_user_role(['admin', 'owner'])
@monitor_performance("update_procurement_plan_status")
async def update_procurement_plan_status(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Update the status of a procurement plan
Valid statuses: draft, pending_approval, approved, in_execution, completed, cancelled
"""
try:
updated_plan = await procurement_service.update_plan_status(
tenant_access.tenant_id,
plan_id,
status,
tenant_access.user_id
)
if not updated_plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Procurement plan not found"
)
return updated_plan
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating procurement plan status: {str(e)}"
)
@router.get(
route_builder.build_operations_route("procurement/plans/id/{plan_id}"),
response_model=Optional[ProcurementPlanResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@monitor_performance("get_procurement_plan_by_id")
async def get_procurement_plan_by_id(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get a specific procurement plan by its ID
Returns detailed plan information including all requirements.
"""
try:
plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Procurement plan not found"
)
return plan
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving procurement plan: {str(e)}"
)
# ================================================================
# DASHBOARD ENDPOINTS
# ================================================================
@router.get(
route_builder.build_dashboard_route("procurement"),
response_model=Optional[DashboardData]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@monitor_performance("get_procurement_dashboard")
async def get_procurement_dashboard(
tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get procurement dashboard data
Returns comprehensive dashboard information including:
- Current plan
- Summary statistics
- Upcoming deliveries
- Overdue requirements
- Low stock alerts
- Performance metrics
"""
try:
dashboard_data = await procurement_service.get_dashboard_data(tenant_access.tenant_id)
return dashboard_data
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving dashboard data: {str(e)}"
)
# ================================================================
# REQUIREMENT MANAGEMENT ENDPOINTS
# ================================================================
@router.get(
route_builder.build_operations_route("procurement/plans/{plan_id}/requirements")
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@monitor_performance("get_plan_requirements")
async def get_plan_requirements(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by requirement status"),
priority: Optional[str] = Query(None, description="Filter by priority level"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get all requirements for a specific procurement plan
Supports filtering by status and priority level.
"""
try:
# Verify plan exists and belongs to tenant
plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Procurement plan not found"
)
# Get requirements from repository
requirements = await procurement_service.requirement_repo.get_requirements_by_plan(plan_id)
# Apply filters if provided
if status:
requirements = [r for r in requirements if r.status == status]
if priority:
requirements = [r for r in requirements if r.priority == priority]
return requirements
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving plan requirements: {str(e)}"
)
@router.get(
route_builder.build_operations_route("procurement/requirements/critical")
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
@monitor_performance("get_critical_requirements")
async def get_critical_requirements(
tenant_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get all critical priority requirements across all active plans
Returns requirements that need immediate attention.
"""
try:
requirements = await procurement_service.requirement_repo.get_critical_requirements(
tenant_access.tenant_id
)
return requirements
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving critical requirements: {str(e)}"
)
# ================================================================
# NEW FEATURE ENDPOINTS
# ================================================================
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/recalculate"),
response_model=GeneratePlanResponse
)
@require_user_role(['member', 'admin', 'owner'])
@monitor_performance("recalculate_procurement_plan")
async def recalculate_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Recalculate an existing procurement plan (Edge Case #3)
Useful when inventory has changed significantly after plan creation
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
result = await procurement_service.recalculate_plan(tenant_id, plan_id)
if not result.success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.message
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error recalculating procurement plan: {str(e)}"
)
@router.post(
route_builder.build_operations_route("procurement/requirements/{requirement_id}/link-purchase-order")
)
@require_user_role(['member', 'admin', 'owner'])
@monitor_performance("link_requirement_to_po")
async def link_requirement_to_purchase_order(
tenant_id: uuid.UUID,
requirement_id: uuid.UUID,
purchase_order_id: uuid.UUID,
purchase_order_number: str,
ordered_quantity: float,
expected_delivery_date: Optional[date] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Link a procurement requirement to a purchase order (Bug #4 FIX, Feature #1)
Updates requirement status and tracks PO information
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
from decimal import Decimal
success = await procurement_service.link_requirement_to_purchase_order(
tenant_id=tenant_id,
requirement_id=requirement_id,
purchase_order_id=purchase_order_id,
purchase_order_number=purchase_order_number,
ordered_quantity=Decimal(str(ordered_quantity)),
expected_delivery_date=expected_delivery_date
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Requirement not found or unauthorized"
)
return {
"success": True,
"message": "Requirement linked to purchase order successfully",
"requirement_id": str(requirement_id),
"purchase_order_id": str(purchase_order_id)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error linking requirement to PO: {str(e)}"
)
@router.put(
route_builder.build_operations_route("procurement/requirements/{requirement_id}/delivery-status")
)
@require_user_role(['member', 'admin', 'owner'])
@monitor_performance("update_delivery_status")
async def update_requirement_delivery_status(
tenant_id: uuid.UUID,
requirement_id: uuid.UUID,
delivery_status: str,
received_quantity: Optional[float] = None,
actual_delivery_date: Optional[date] = None,
quality_rating: Optional[float] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Update delivery status for a requirement (Feature #2)
Tracks received quantities, delivery dates, and quality ratings
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
from decimal import Decimal
success = await procurement_service.update_delivery_status(
tenant_id=tenant_id,
requirement_id=requirement_id,
delivery_status=delivery_status,
received_quantity=Decimal(str(received_quantity)) if received_quantity is not None else None,
actual_delivery_date=actual_delivery_date,
quality_rating=Decimal(str(quality_rating)) if quality_rating is not None else None
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Requirement not found or unauthorized"
)
return {
"success": True,
"message": "Delivery status updated successfully",
"requirement_id": str(requirement_id),
"delivery_status": delivery_status
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating delivery status: {str(e)}"
)
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/approve")
)
@require_user_role(['admin', 'owner'])
@monitor_performance("approve_procurement_plan")
async def approve_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
approval_notes: Optional[str] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Approve a procurement plan (Edge Case #7: Enhanced approval workflow)
Includes approval notes and workflow tracking
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
try:
user_id = uuid.UUID(tenant_access.user_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)
result = await procurement_service.update_plan_status(
tenant_id=tenant_id,
plan_id=plan_id,
status="approved",
updated_by=user_id,
approval_notes=approval_notes
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Plan not found"
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error approving plan: {str(e)}"
)
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/reject")
)
@require_user_role(['admin', 'owner'])
@monitor_performance("reject_procurement_plan")
async def reject_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
rejection_notes: Optional[str] = None,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Reject a procurement plan (Edge Case #7: Enhanced approval workflow)
Marks plan as cancelled with rejection notes
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
try:
user_id = uuid.UUID(tenant_access.user_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)
result = await procurement_service.update_plan_status(
tenant_id=tenant_id,
plan_id=plan_id,
status="cancelled",
updated_by=user_id,
approval_notes=f"REJECTED: {rejection_notes}" if rejection_notes else "REJECTED"
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Plan not found"
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error rejecting plan: {str(e)}"
)
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/create-purchase-orders")
)
@require_user_role(['admin', 'owner'])
@monitor_performance("create_pos_from_plan")
async def create_purchase_orders_from_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
auto_approve: bool = False,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Automatically create purchase orders from procurement plan (Feature #1)
Groups requirements by supplier and creates POs automatically
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
result = await procurement_service.create_purchase_orders_from_plan(
tenant_id=tenant_id,
plan_id=plan_id,
auto_approve=auto_approve
)
if not result.get('success'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.get('error', 'Failed to create purchase orders')
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating purchase orders: {str(e)}"
)
# ================================================================
# UTILITY ENDPOINTS
# ================================================================
@router.post(
route_builder.build_operations_route("procurement/scheduler/trigger")
)
@monitor_performance("trigger_daily_scheduler")
async def trigger_daily_scheduler(
tenant_id: uuid.UUID,
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:
# 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,
detail=f"Error triggering daily scheduler: {str(e)}"
)
@router.get(
route_builder.build_base_route("procurement/health")
)
async def procurement_health_check():
"""
Health check endpoint for procurement service
"""
return {
"status": "healthy",
"service": "procurement-planning",
"procurement_enabled": settings.PROCUREMENT_PLANNING_ENABLED,
"timestamp": date.today().isoformat()
}

View File

@@ -57,7 +57,6 @@ async def init_database():
# Import all models to ensure they are registered
from app.models.order import CustomerOrder, OrderItem, OrderStatusHistory
from app.models.customer import Customer, CustomerContact
from app.models.procurement import ProcurementPlan, ProcurementRequirement
# Create all tables
await conn.run_sync(Base.metadata.create_all)

View File

@@ -3,7 +3,7 @@
# ================================================================
"""
Orders Service - FastAPI Application
Customer orders and procurement planning service
Customer orders management service
"""
from fastapi import FastAPI, Request
@@ -13,9 +13,7 @@ from app.core.database import database_manager
from app.api.orders import router as orders_router
from app.api.customers import router as customers_router
from app.api.order_operations import router as order_operations_router
from app.api.procurement_operations import router as procurement_operations_router
from app.api import internal_demo
from app.services.procurement_scheduler_service import ProcurementSchedulerService
from shared.service_base import StandardFastAPIService
@@ -47,7 +45,7 @@ class OrdersService(StandardFastAPIService):
# Define expected database tables for health checks
orders_expected_tables = [
'customers', 'customer_contacts', 'customer_orders', 'order_items',
'order_status_history', 'procurement_plans', 'procurement_requirements'
'order_status_history', 'audit_logs'
]
super().__init__(
@@ -62,29 +60,22 @@ class OrdersService(StandardFastAPIService):
async def on_startup(self, app: FastAPI):
"""Custom startup logic for orders service"""
# Initialize procurement scheduler service
scheduler_service = ProcurementSchedulerService(settings)
await scheduler_service.start()
self.logger.info("Procurement scheduler service started")
# Store scheduler service in app state
app.state.scheduler_service = scheduler_service
# REMOVED: Procurement scheduler service initialization
# Procurement scheduling is now handled by the Orchestrator Service
# which calls the Procurement Service's /auto-generate endpoint
pass
async def on_shutdown(self, app: FastAPI):
"""Custom shutdown logic for orders service"""
# Stop scheduler service
if hasattr(app.state, 'scheduler_service'):
await app.state.scheduler_service.stop()
self.logger.info("Scheduler service stopped")
# REMOVED: Scheduler service shutdown
pass
def get_service_features(self):
"""Return orders-specific features"""
return [
"customer_management",
"order_processing",
"procurement_planning",
"order_tracking",
"automated_scheduling"
"order_tracking"
]
@@ -106,25 +97,12 @@ service.add_router(orders_router)
# BUSINESS: Complex operations and workflows
service.add_router(order_operations_router)
service.add_router(procurement_operations_router)
# INTERNAL: Service-to-service endpoints
service.add_router(internal_demo.router)
@app.post("/test/procurement-scheduler")
async def test_procurement_scheduler():
"""Test endpoint to manually trigger procurement scheduler"""
try:
if hasattr(app.state, 'scheduler_service'):
scheduler_service = app.state.scheduler_service
await scheduler_service.test_procurement_generation()
return {"message": "Procurement scheduler test triggered successfully"}
else:
return {"error": "Scheduler service not available"}
except Exception as e:
service.logger.error("Error testing procurement scheduler", error=str(e))
return {"error": f"Failed to trigger scheduler test: {str(e)}"}
# REMOVED: test_procurement_scheduler endpoint
# Procurement scheduling is now triggered by the Orchestrator Service
@app.middleware("http")

View File

@@ -14,7 +14,6 @@ AuditLog = create_audit_log_model(Base)
# Import all models to register them with the Base metadata
from .customer import Customer, CustomerContact
from .order import CustomerOrder, OrderItem, OrderStatusHistory
from .procurement import ProcurementPlan, ProcurementRequirement
# Import enums
from .enums import (
@@ -46,8 +45,6 @@ __all__ = [
"CustomerOrder",
"OrderItem",
"OrderStatusHistory",
"ProcurementPlan",
"ProcurementRequirement",
# Enums
"CustomerType",
"DeliveryMethod",

View File

@@ -1,227 +0,0 @@
# ================================================================
# services/orders/app/models/procurement.py
# ================================================================
"""
Procurement planning database models for Orders Service
"""
import uuid
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List
from sqlalchemy import Column, String, Boolean, DateTime, Date, Numeric, Text, Integer, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from shared.database.base import Base
class ProcurementPlan(Base):
"""Master procurement plan for coordinating supply needs across orders and production"""
__tablename__ = "procurement_plans"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
plan_number = Column(String(50), nullable=False, unique=True, index=True)
# Plan scope and timing
plan_date = Column(Date, nullable=False, index=True)
plan_period_start = Column(Date, nullable=False)
plan_period_end = Column(Date, nullable=False)
planning_horizon_days = Column(Integer, nullable=False, default=14)
# Plan status and lifecycle
status = Column(String(50), nullable=False, default="draft", index=True)
# Status values: draft, pending_approval, approved, in_execution, completed, cancelled
plan_type = Column(String(50), nullable=False, default="regular") # regular, emergency, seasonal
priority = Column(String(20), nullable=False, default="normal") # high, normal, low
# Business model context
business_model = Column(String(50), nullable=True) # individual_bakery, central_bakery
procurement_strategy = Column(String(50), nullable=False, default="just_in_time") # just_in_time, bulk, mixed
# Plan totals and summary
total_requirements = Column(Integer, nullable=False, default=0)
total_estimated_cost = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
total_approved_cost = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
cost_variance = Column(Numeric(12, 2), nullable=False, default=Decimal("0.00"))
# Demand analysis
total_demand_orders = Column(Integer, nullable=False, default=0)
total_demand_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
total_production_requirements = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
safety_stock_buffer = Column(Numeric(5, 2), nullable=False, default=Decimal("20.00")) # Percentage
# Supplier coordination
primary_suppliers_count = Column(Integer, nullable=False, default=0)
backup_suppliers_count = Column(Integer, nullable=False, default=0)
supplier_diversification_score = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
# Risk assessment
supply_risk_level = Column(String(20), nullable=False, default="low") # low, medium, high, critical
demand_forecast_confidence = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
seasonality_adjustment = Column(Numeric(5, 2), nullable=False, default=Decimal("0.00"))
# Execution tracking
approved_at = Column(DateTime(timezone=True), nullable=True)
approved_by = Column(UUID(as_uuid=True), nullable=True)
execution_started_at = Column(DateTime(timezone=True), nullable=True)
execution_completed_at = Column(DateTime(timezone=True), nullable=True)
# Performance metrics
fulfillment_rate = Column(Numeric(5, 2), nullable=True) # Percentage
on_time_delivery_rate = Column(Numeric(5, 2), nullable=True) # Percentage
cost_accuracy = Column(Numeric(5, 2), nullable=True) # Percentage
quality_score = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
# Integration data
source_orders = Column(JSONB, nullable=True) # Orders that drove this plan
production_schedules = Column(JSONB, nullable=True) # Associated production schedules
inventory_snapshots = Column(JSONB, nullable=True) # Inventory levels at planning time
# Communication and collaboration
stakeholder_notifications = Column(JSONB, nullable=True) # Who was notified and when
approval_workflow = Column(JSONB, nullable=True) # Approval chain and status
# Special considerations
special_requirements = Column(Text, nullable=True)
seasonal_adjustments = Column(JSONB, nullable=True)
emergency_provisions = Column(JSONB, nullable=True)
# External references
erp_reference = Column(String(100), nullable=True)
supplier_portal_reference = Column(String(100), nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
created_by = Column(UUID(as_uuid=True), nullable=True)
updated_by = Column(UUID(as_uuid=True), nullable=True)
# Additional metadata
plan_metadata = Column(JSONB, nullable=True)
# Relationships
requirements = relationship("ProcurementRequirement", back_populates="plan", cascade="all, delete-orphan")
class ProcurementRequirement(Base):
"""Individual procurement requirements within a procurement plan"""
__tablename__ = "procurement_requirements"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plan_id = Column(UUID(as_uuid=True), ForeignKey("procurement_plans.id", ondelete="CASCADE"), nullable=False)
requirement_number = Column(String(50), nullable=False, index=True)
# Product/ingredient information
product_id = Column(UUID(as_uuid=True), nullable=False, index=True) # Reference to products/ingredients
product_name = Column(String(200), nullable=False)
product_sku = Column(String(100), nullable=True)
product_category = Column(String(100), nullable=True)
product_type = Column(String(50), nullable=False, default="ingredient") # ingredient, packaging, supplies
# Requirement details
required_quantity = Column(Numeric(12, 3), nullable=False)
unit_of_measure = Column(String(50), nullable=False)
safety_stock_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
total_quantity_needed = Column(Numeric(12, 3), nullable=False)
# Current inventory situation
current_stock_level = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
reserved_stock = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
available_stock = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
net_requirement = Column(Numeric(12, 3), nullable=False)
# Demand breakdown
order_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
production_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
forecast_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
buffer_demand = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
# Supplier information
preferred_supplier_id = Column(UUID(as_uuid=True), nullable=True)
backup_supplier_id = Column(UUID(as_uuid=True), nullable=True)
supplier_name = Column(String(200), nullable=True)
supplier_lead_time_days = Column(Integer, nullable=True)
minimum_order_quantity = Column(Numeric(12, 3), nullable=True)
# Pricing and cost
estimated_unit_cost = Column(Numeric(10, 4), nullable=True)
estimated_total_cost = Column(Numeric(12, 2), nullable=True)
last_purchase_cost = Column(Numeric(10, 4), nullable=True)
cost_variance = Column(Numeric(10, 2), nullable=False, default=Decimal("0.00"))
# Timing requirements
required_by_date = Column(Date, nullable=False)
lead_time_buffer_days = Column(Integer, nullable=False, default=1)
suggested_order_date = Column(Date, nullable=False)
latest_order_date = Column(Date, nullable=False)
# Quality and specifications
quality_specifications = Column(JSONB, nullable=True)
special_requirements = Column(Text, nullable=True)
storage_requirements = Column(String(200), nullable=True)
shelf_life_days = Column(Integer, nullable=True)
# Requirement status
status = Column(String(50), nullable=False, default="pending")
# Status values: pending, approved, ordered, partially_received, received, cancelled
priority = Column(String(20), nullable=False, default="normal") # critical, high, normal, low
risk_level = Column(String(20), nullable=False, default="low") # low, medium, high, critical
# Purchase order tracking
purchase_order_id = Column(UUID(as_uuid=True), nullable=True)
purchase_order_number = Column(String(50), nullable=True)
ordered_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
ordered_at = Column(DateTime(timezone=True), nullable=True)
# Delivery tracking
expected_delivery_date = Column(Date, nullable=True)
actual_delivery_date = Column(Date, nullable=True)
received_quantity = Column(Numeric(12, 3), nullable=False, default=Decimal("0.000"))
delivery_status = Column(String(50), nullable=False, default="pending")
# Performance tracking
fulfillment_rate = Column(Numeric(5, 2), nullable=True) # Percentage
on_time_delivery = Column(Boolean, nullable=True)
quality_rating = Column(Numeric(3, 1), nullable=True) # 1.0 to 10.0
# Source traceability
source_orders = Column(JSONB, nullable=True) # Orders that contributed to this requirement
source_production_batches = Column(JSONB, nullable=True) # Production batches needing this
demand_analysis = Column(JSONB, nullable=True) # Detailed demand breakdown
# Smart procurement calculation metadata
calculation_method = Column(String(100), nullable=True) # Method used: REORDER_POINT_TRIGGERED, FORECAST_DRIVEN_PROACTIVE, etc.
ai_suggested_quantity = Column(Numeric(12, 3), nullable=True) # Pure AI forecast quantity
adjusted_quantity = Column(Numeric(12, 3), nullable=True) # Final quantity after applying constraints
adjustment_reason = Column(Text, nullable=True) # Human-readable explanation of adjustments
price_tier_applied = Column(JSONB, nullable=True) # Price tier information if applicable
supplier_minimum_applied = Column(Boolean, nullable=False, default=False) # Whether supplier minimum was enforced
storage_limit_applied = Column(Boolean, nullable=False, default=False) # Whether storage limit was hit
reorder_rule_applied = Column(Boolean, nullable=False, default=False) # Whether reorder rules were used
# Approval and authorization
approved_quantity = Column(Numeric(12, 3), nullable=True)
approved_cost = Column(Numeric(12, 2), nullable=True)
approved_at = Column(DateTime(timezone=True), nullable=True)
approved_by = Column(UUID(as_uuid=True), nullable=True)
# Notes and communication
procurement_notes = Column(Text, nullable=True)
supplier_communication = Column(JSONB, nullable=True)
# Audit fields
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Additional metadata
requirement_metadata = Column(JSONB, nullable=True)
# Relationships
plan = relationship("ProcurementPlan", back_populates="requirements")

View File

@@ -1,283 +0,0 @@
# ================================================================
# services/orders/app/repositories/procurement_repository.py
# ================================================================
"""
Procurement Repository - Database operations for procurement plans and requirements
"""
import uuid
from datetime import datetime, date
from decimal import Decimal
from typing import List, Optional, Dict, Any
from sqlalchemy import select, and_, or_, desc, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.procurement import ProcurementPlan, ProcurementRequirement
from app.repositories.base_repository import BaseRepository
class ProcurementPlanRepository(BaseRepository):
"""Repository for procurement plan operations"""
def __init__(self, db: AsyncSession):
super().__init__(ProcurementPlan)
self.db = db
async def create_plan(self, plan_data: Dict[str, Any]) -> ProcurementPlan:
"""Create a new procurement plan"""
plan = ProcurementPlan(**plan_data)
self.db.add(plan)
await self.db.flush()
return plan
async def get_plan_by_id(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]:
"""Get procurement plan by ID"""
stmt = select(ProcurementPlan).where(
and_(
ProcurementPlan.id == plan_id,
ProcurementPlan.tenant_id == tenant_id
)
).options(selectinload(ProcurementPlan.requirements))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_plan_by_date(self, plan_date: date, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]:
"""Get procurement plan for a specific date"""
stmt = select(ProcurementPlan).where(
and_(
ProcurementPlan.plan_date == plan_date,
ProcurementPlan.tenant_id == tenant_id
)
).options(selectinload(ProcurementPlan.requirements))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_current_plan(self, tenant_id: uuid.UUID) -> Optional[ProcurementPlan]:
"""Get the current day's procurement plan"""
today = date.today()
return await self.get_plan_by_date(today, tenant_id)
async def list_plans(
self,
tenant_id: uuid.UUID,
status: Optional[str] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
limit: int = 50,
offset: int = 0
) -> List[ProcurementPlan]:
"""List procurement plans with filters"""
conditions = [ProcurementPlan.tenant_id == tenant_id]
if status:
conditions.append(ProcurementPlan.status == status)
if start_date:
conditions.append(ProcurementPlan.plan_date >= start_date)
if end_date:
conditions.append(ProcurementPlan.plan_date <= end_date)
stmt = (
select(ProcurementPlan)
.where(and_(*conditions))
.order_by(desc(ProcurementPlan.plan_date))
.limit(limit)
.offset(offset)
.options(selectinload(ProcurementPlan.requirements))
)
result = await self.db.execute(stmt)
return result.scalars().all()
async def update_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID, updates: Dict[str, Any]) -> Optional[ProcurementPlan]:
"""Update procurement plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
if not plan:
return None
for key, value in updates.items():
if hasattr(plan, key):
setattr(plan, key, value)
plan.updated_at = datetime.utcnow()
await self.db.flush()
return plan
async def delete_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> bool:
"""Delete procurement plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
if not plan:
return False
await self.db.delete(plan)
return True
async def generate_plan_number(self, tenant_id: uuid.UUID, plan_date: date) -> str:
"""Generate unique plan number"""
date_str = plan_date.strftime("%Y%m%d")
# Count existing plans for the same date
stmt = select(func.count(ProcurementPlan.id)).where(
and_(
ProcurementPlan.tenant_id == tenant_id,
ProcurementPlan.plan_date == plan_date
)
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"PP-{date_str}-{count + 1:03d}"
async def archive_plan(self, plan_id: uuid.UUID, tenant_id: uuid.UUID) -> bool:
"""Archive a completed plan"""
plan = await self.get_plan_by_id(plan_id, tenant_id)
if not plan:
return False
# Add archived flag to metadata if you have a JSONB field
# or just mark as archived in status
if hasattr(plan, 'metadata'):
metadata = plan.metadata or {}
metadata['archived'] = True
metadata['archived_at'] = datetime.utcnow().isoformat()
plan.metadata = metadata
plan.status = 'archived'
plan.updated_at = datetime.utcnow()
await self.db.flush()
return True
class ProcurementRequirementRepository(BaseRepository):
"""Repository for procurement requirement operations"""
def __init__(self, db: AsyncSession):
super().__init__(ProcurementRequirement)
self.db = db
async def create_requirement(self, requirement_data: Dict[str, Any]) -> ProcurementRequirement:
"""Create a new procurement requirement"""
requirement = ProcurementRequirement(**requirement_data)
self.db.add(requirement)
await self.db.flush()
return requirement
async def create_requirements_batch(self, requirements_data: List[Dict[str, Any]]) -> List[ProcurementRequirement]:
"""Create multiple procurement requirements"""
requirements = [ProcurementRequirement(**data) for data in requirements_data]
self.db.add_all(requirements)
await self.db.flush()
return requirements
async def get_requirement_by_id(self, requirement_id: uuid.UUID, tenant_id: uuid.UUID) -> Optional[ProcurementRequirement]:
"""Get procurement requirement by ID"""
stmt = select(ProcurementRequirement).join(ProcurementPlan).where(
and_(
ProcurementRequirement.id == requirement_id,
ProcurementPlan.tenant_id == tenant_id
)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_requirements_by_plan(self, plan_id: uuid.UUID) -> List[ProcurementRequirement]:
"""Get all requirements for a specific plan"""
stmt = select(ProcurementRequirement).where(
ProcurementRequirement.plan_id == plan_id
).order_by(ProcurementRequirement.priority.desc(), ProcurementRequirement.required_by_date)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_requirements_by_product(
self,
tenant_id: uuid.UUID,
product_id: uuid.UUID,
status: Optional[str] = None
) -> List[ProcurementRequirement]:
"""Get requirements for a specific product"""
conditions = [
ProcurementPlan.tenant_id == tenant_id,
ProcurementRequirement.product_id == product_id
]
if status:
conditions.append(ProcurementRequirement.status == status)
stmt = select(ProcurementRequirement).join(ProcurementPlan).where(
and_(*conditions)
).order_by(desc(ProcurementRequirement.required_by_date))
result = await self.db.execute(stmt)
return result.scalars().all()
async def update_requirement(
self,
requirement_id: uuid.UUID,
updates: Dict[str, Any]
) -> Optional[ProcurementRequirement]:
"""Update procurement requirement (without tenant_id check for internal use)"""
stmt = select(ProcurementRequirement).where(
ProcurementRequirement.id == requirement_id
)
result = await self.db.execute(stmt)
requirement = result.scalar_one_or_none()
if not requirement:
return None
for key, value in updates.items():
if hasattr(requirement, key):
setattr(requirement, key, value)
requirement.updated_at = datetime.utcnow()
await self.db.flush()
return requirement
async def get_by_id(self, requirement_id: uuid.UUID) -> Optional[ProcurementRequirement]:
"""Get requirement by ID with plan preloaded"""
stmt = select(ProcurementRequirement).where(
ProcurementRequirement.id == requirement_id
).options(selectinload(ProcurementRequirement.plan))
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_pending_requirements(self, tenant_id: uuid.UUID) -> List[ProcurementRequirement]:
"""Get all pending requirements across plans"""
stmt = select(ProcurementRequirement).join(ProcurementPlan).where(
and_(
ProcurementPlan.tenant_id == tenant_id,
ProcurementRequirement.status == 'pending'
)
).order_by(ProcurementRequirement.priority.desc(), ProcurementRequirement.required_by_date)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_critical_requirements(self, tenant_id: uuid.UUID) -> List[ProcurementRequirement]:
"""Get critical priority requirements"""
stmt = select(ProcurementRequirement).join(ProcurementPlan).where(
and_(
ProcurementPlan.tenant_id == tenant_id,
ProcurementRequirement.priority == 'critical',
ProcurementRequirement.status.in_(['pending', 'approved'])
)
).order_by(ProcurementRequirement.required_by_date)
result = await self.db.execute(stmt)
return result.scalars().all()
async def generate_requirement_number(self, plan_id: uuid.UUID) -> str:
"""Generate unique requirement number within a plan"""
# Count existing requirements in the plan
stmt = select(func.count(ProcurementRequirement.id)).where(
ProcurementRequirement.plan_id == plan_id
)
result = await self.db.execute(stmt)
count = result.scalar() or 0
return f"REQ-{count + 1:05d}"

View File

@@ -14,8 +14,7 @@ from pydantic import BaseModel, Field, validator
from app.models.enums import (
CustomerType, DeliveryMethod, PaymentTerms, PaymentMethod, PaymentStatus,
CustomerSegment, PriorityLevel, OrderType, OrderStatus, OrderSource,
SalesChannel, BusinessModel, ProcurementPlanType, ProcurementStrategy,
RiskLevel, RequirementStatus, PlanStatus, DeliveryStatus
SalesChannel, BusinessModel, DeliveryStatus
)
@@ -220,95 +219,6 @@ class OrderResponse(OrderBase):
from_attributes = True
# ===== Procurement Schemas =====
class ProcurementRequirementBase(BaseModel):
product_id: UUID
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")
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)
required_by_date: date
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
preferred_supplier_id: Optional[UUID] = None
quality_specifications: Optional[Dict[str, Any]] = None
special_requirements: Optional[str] = None
storage_requirements: Optional[str] = Field(None, max_length=200)
class Config:
from_attributes = True
use_enum_values = True
class ProcurementRequirementCreate(ProcurementRequirementBase):
pass
class ProcurementRequirementResponse(ProcurementRequirementBase):
id: UUID
plan_id: UUID
requirement_number: str
total_quantity_needed: Decimal
current_stock_level: Decimal
available_stock: Decimal
net_requirement: Decimal
order_demand: Decimal
production_demand: Decimal
forecast_demand: Decimal
status: str
estimated_unit_cost: Optional[Decimal]
estimated_total_cost: Optional[Decimal]
supplier_name: Optional[str]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class ProcurementPlanBase(BaseModel):
plan_date: date
plan_period_start: date
plan_period_end: date
planning_horizon_days: int = Field(default=14, ge=1, le=365)
plan_type: ProcurementPlanType = Field(default=ProcurementPlanType.REGULAR)
priority: PriorityLevel = Field(default=PriorityLevel.NORMAL)
business_model: Optional[BusinessModel] = None
procurement_strategy: ProcurementStrategy = Field(default=ProcurementStrategy.JUST_IN_TIME)
safety_stock_buffer: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
special_requirements: Optional[str] = None
class Config:
from_attributes = True
use_enum_values = True
class ProcurementPlanCreate(ProcurementPlanBase):
requirements: List[ProcurementRequirementCreate] = Field(..., min_items=1)
class ProcurementPlanResponse(ProcurementPlanBase):
id: UUID
tenant_id: UUID
plan_number: str
status: str
total_requirements: int
total_estimated_cost: Decimal
total_approved_cost: Decimal
total_demand_orders: int
supply_risk_level: str
approved_at: Optional[datetime]
created_at: datetime
updated_at: datetime
requirements: List[ProcurementRequirementResponse] = []
class Config:
from_attributes = True
# ===== Dashboard and Analytics Schemas =====
class OrdersDashboardSummary(BaseModel):
@@ -371,26 +281,3 @@ class DemandRequirements(BaseModel):
earliest_delivery: datetime
latest_delivery: datetime
average_lead_time_hours: int
class ProcurementPlanningData(BaseModel):
"""Data for procurement planning decisions"""
planning_date: date
planning_horizon_days: int
# Demand forecast
demand_forecast: List[Dict[str, Any]]
# Current inventory status
inventory_levels: Dict[str, Any]
# Supplier information
supplier_performance: Dict[str, Any]
# Risk factors
supply_risks: List[str]
demand_volatility: Decimal
# Recommendations
recommended_purchases: List[Dict[str, Any]]
critical_shortages: List[Dict[str, Any]]

View File

@@ -1,313 +0,0 @@
# ================================================================
# services/orders/app/schemas/procurement_schemas.py
# ================================================================
"""
Procurement Schemas - Request/response models for procurement plans
"""
import uuid
from datetime import datetime, date
from decimal import Decimal
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
# ================================================================
# BASE SCHEMAS
# ================================================================
class ProcurementBase(BaseModel):
"""Base schema for procurement entities"""
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
# ================================================================
# PROCUREMENT REQUIREMENT SCHEMAS
# ================================================================
class ProcurementRequirementBase(ProcurementBase):
"""Base procurement requirement schema"""
product_id: uuid.UUID
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", max_length=50)
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)
total_quantity_needed: Decimal = Field(..., gt=0)
current_stock_level: Decimal = Field(default=Decimal("0.000"), ge=0)
reserved_stock: Decimal = Field(default=Decimal("0.000"), ge=0)
available_stock: Decimal = Field(default=Decimal("0.000"), ge=0)
net_requirement: Decimal = Field(..., ge=0)
order_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
production_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
forecast_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
buffer_demand: Decimal = Field(default=Decimal("0.000"), ge=0)
required_by_date: date
lead_time_buffer_days: int = Field(default=1, ge=0)
suggested_order_date: date
latest_order_date: date
priority: str = Field(default="normal", pattern="^(critical|high|normal|low)$")
risk_level: str = Field(default="low", pattern="^(low|medium|high|critical)$")
preferred_supplier_id: Optional[uuid.UUID] = None
backup_supplier_id: Optional[uuid.UUID] = None
supplier_name: Optional[str] = Field(None, max_length=200)
supplier_lead_time_days: Optional[int] = Field(None, ge=0)
minimum_order_quantity: Optional[Decimal] = Field(None, ge=0)
estimated_unit_cost: Optional[Decimal] = Field(None, ge=0)
estimated_total_cost: Optional[Decimal] = Field(None, ge=0)
last_purchase_cost: Optional[Decimal] = Field(None, ge=0)
class ProcurementRequirementCreate(ProcurementRequirementBase):
"""Schema for creating procurement requirements"""
special_requirements: Optional[str] = None
storage_requirements: Optional[str] = Field(None, max_length=200)
shelf_life_days: Optional[int] = Field(None, gt=0)
quality_specifications: Optional[Dict[str, Any]] = None
procurement_notes: Optional[str] = None
# Smart procurement calculation metadata
calculation_method: Optional[str] = Field(None, max_length=100)
ai_suggested_quantity: Optional[Decimal] = Field(None, ge=0)
adjusted_quantity: Optional[Decimal] = Field(None, ge=0)
adjustment_reason: Optional[str] = None
price_tier_applied: Optional[Dict[str, Any]] = None
supplier_minimum_applied: bool = False
storage_limit_applied: bool = False
reorder_rule_applied: bool = False
class ProcurementRequirementUpdate(ProcurementBase):
"""Schema for updating procurement requirements"""
status: Optional[str] = Field(None, pattern="^(pending|approved|ordered|partially_received|received|cancelled)$")
priority: Optional[str] = Field(None, pattern="^(critical|high|normal|low)$")
approved_quantity: Optional[Decimal] = Field(None, ge=0)
approved_cost: Optional[Decimal] = Field(None, ge=0)
purchase_order_id: Optional[uuid.UUID] = None
purchase_order_number: Optional[str] = Field(None, max_length=50)
ordered_quantity: Optional[Decimal] = Field(None, ge=0)
expected_delivery_date: Optional[date] = None
actual_delivery_date: Optional[date] = None
received_quantity: Optional[Decimal] = Field(None, ge=0)
delivery_status: Optional[str] = Field(None, pattern="^(pending|in_transit|delivered|delayed|cancelled)$")
procurement_notes: Optional[str] = None
class ProcurementRequirementResponse(ProcurementRequirementBase):
"""Schema for procurement requirement responses"""
id: uuid.UUID
plan_id: uuid.UUID
requirement_number: str
status: str
created_at: datetime
updated_at: datetime
purchase_order_id: Optional[uuid.UUID] = None
purchase_order_number: Optional[str] = None
ordered_quantity: Decimal
ordered_at: Optional[datetime] = None
expected_delivery_date: Optional[date] = None
actual_delivery_date: Optional[date] = None
received_quantity: Decimal
delivery_status: str
fulfillment_rate: Optional[Decimal] = None
on_time_delivery: Optional[bool] = None
quality_rating: Optional[Decimal] = None
approved_quantity: Optional[Decimal] = None
approved_cost: Optional[Decimal] = None
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
special_requirements: Optional[str] = None
storage_requirements: Optional[str] = None
shelf_life_days: Optional[int] = None
quality_specifications: Optional[Dict[str, Any]] = None
procurement_notes: Optional[str] = None
# Smart procurement calculation metadata
calculation_method: Optional[str] = None
ai_suggested_quantity: Optional[Decimal] = None
adjusted_quantity: Optional[Decimal] = None
adjustment_reason: Optional[str] = None
price_tier_applied: Optional[Dict[str, Any]] = None
supplier_minimum_applied: bool = False
storage_limit_applied: bool = False
reorder_rule_applied: bool = False
# ================================================================
# PROCUREMENT PLAN SCHEMAS
# ================================================================
class ProcurementPlanBase(ProcurementBase):
"""Base procurement plan schema"""
plan_date: date
plan_period_start: date
plan_period_end: date
planning_horizon_days: int = Field(default=14, gt=0)
plan_type: str = Field(default="regular", pattern="^(regular|emergency|seasonal|urgent)$")
priority: str = Field(default="normal", pattern="^(critical|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|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)$")
demand_forecast_confidence: Optional[Decimal] = Field(None, ge=1, le=10)
seasonality_adjustment: Decimal = Field(default=Decimal("0.00"))
special_requirements: Optional[str] = None
class ProcurementPlanCreate(ProcurementPlanBase):
"""Schema for creating procurement plans"""
tenant_id: uuid.UUID
requirements: Optional[List[ProcurementRequirementCreate]] = []
class ProcurementPlanUpdate(ProcurementBase):
"""Schema for updating procurement plans"""
status: Optional[str] = Field(None, pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$")
priority: Optional[str] = Field(None, pattern="^(critical|high|normal|low)$")
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
execution_started_at: Optional[datetime] = None
execution_completed_at: Optional[datetime] = None
special_requirements: Optional[str] = None
seasonal_adjustments: Optional[Dict[str, Any]] = None
class ProcurementPlanResponse(ProcurementPlanBase):
"""Schema for procurement plan responses"""
id: uuid.UUID
tenant_id: uuid.UUID
plan_number: str
status: str
total_requirements: int
total_estimated_cost: Decimal
total_approved_cost: Decimal
cost_variance: Decimal
total_demand_orders: int
total_demand_quantity: Decimal
total_production_requirements: Decimal
primary_suppliers_count: int
backup_suppliers_count: int
supplier_diversification_score: Optional[Decimal] = None
approved_at: Optional[datetime] = None
approved_by: Optional[uuid.UUID] = None
execution_started_at: Optional[datetime] = None
execution_completed_at: Optional[datetime] = None
fulfillment_rate: Optional[Decimal] = None
on_time_delivery_rate: Optional[Decimal] = None
cost_accuracy: Optional[Decimal] = None
quality_score: Optional[Decimal] = None
created_at: datetime
updated_at: datetime
created_by: Optional[uuid.UUID] = None
updated_by: Optional[uuid.UUID] = None
requirements: List[ProcurementRequirementResponse] = []
# ================================================================
# SUMMARY SCHEMAS
# ================================================================
class ProcurementSummary(ProcurementBase):
"""Summary of procurement plans"""
total_plans: int
active_plans: int
total_requirements: int
pending_requirements: int
critical_requirements: int
total_estimated_cost: Decimal
total_approved_cost: Decimal
cost_variance: Decimal
average_fulfillment_rate: Optional[Decimal] = None
average_on_time_delivery: Optional[Decimal] = None
top_suppliers: List[Dict[str, Any]] = []
critical_items: List[Dict[str, Any]] = []
class DashboardData(ProcurementBase):
"""Dashboard data for procurement overview"""
current_plan: Optional[ProcurementPlanResponse] = None
summary: ProcurementSummary
upcoming_deliveries: List[Dict[str, Any]] = []
overdue_requirements: List[Dict[str, Any]] = []
low_stock_alerts: List[Dict[str, Any]] = []
performance_metrics: Dict[str, Any] = {}
# ================================================================
# REQUEST SCHEMAS
# ================================================================
class GeneratePlanRequest(ProcurementBase):
"""Request to generate procurement plan"""
plan_date: Optional[date] = None
force_regenerate: bool = False
planning_horizon_days: int = Field(default=14, gt=0, le=30)
include_safety_stock: bool = True
safety_stock_percentage: Decimal = Field(default=Decimal("20.00"), ge=0, le=100)
class ForecastRequest(ProcurementBase):
"""Request parameters for demand forecasting"""
target_date: date
horizon_days: int = Field(default=1, gt=0, le=7)
include_confidence_intervals: bool = True
product_ids: Optional[List[uuid.UUID]] = None
# ================================================================
# RESPONSE SCHEMAS
# ================================================================
class GeneratePlanResponse(ProcurementBase):
"""Response from plan generation"""
success: bool
message: str
plan: Optional[ProcurementPlanResponse] = None
warnings: List[str] = []
errors: List[str] = []
class PaginatedProcurementPlans(ProcurementBase):
"""Paginated list of procurement plans"""
plans: List[ProcurementPlanResponse]
total: int
page: int
limit: int
has_more: bool

View File

@@ -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

View File

@@ -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

View File

@@ -1,7 +1,7 @@
"""initial_schema_20251015_1229
Revision ID: 7f882c2ca25c
Revises:
Revises:
Create Date: 2025-10-15 12:29:27.201743+02:00
"""
@@ -91,62 +91,6 @@ def upgrade() -> None:
)
op.create_index(op.f('ix_customers_customer_code'), 'customers', ['customer_code'], unique=False)
op.create_index(op.f('ix_customers_tenant_id'), 'customers', ['tenant_id'], unique=False)
op.create_table('procurement_plans',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('tenant_id', sa.UUID(), nullable=False),
sa.Column('plan_number', sa.String(length=50), nullable=False),
sa.Column('plan_date', sa.Date(), nullable=False),
sa.Column('plan_period_start', sa.Date(), nullable=False),
sa.Column('plan_period_end', sa.Date(), nullable=False),
sa.Column('planning_horizon_days', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('plan_type', sa.String(length=50), nullable=False),
sa.Column('priority', sa.String(length=20), nullable=False),
sa.Column('business_model', sa.String(length=50), nullable=True),
sa.Column('procurement_strategy', sa.String(length=50), nullable=False),
sa.Column('total_requirements', sa.Integer(), nullable=False),
sa.Column('total_estimated_cost', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('total_approved_cost', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('cost_variance', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('total_demand_orders', sa.Integer(), nullable=False),
sa.Column('total_demand_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('total_production_requirements', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('safety_stock_buffer', sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column('primary_suppliers_count', sa.Integer(), nullable=False),
sa.Column('backup_suppliers_count', sa.Integer(), nullable=False),
sa.Column('supplier_diversification_score', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('supply_risk_level', sa.String(length=20), nullable=False),
sa.Column('demand_forecast_confidence', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('seasonality_adjustment', sa.Numeric(precision=5, scale=2), nullable=False),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('approved_by', sa.UUID(), nullable=True),
sa.Column('execution_started_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('execution_completed_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('fulfillment_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('on_time_delivery_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('cost_accuracy', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('quality_score', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('source_orders', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('production_schedules', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('inventory_snapshots', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('stakeholder_notifications', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('approval_workflow', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('special_requirements', sa.Text(), nullable=True),
sa.Column('seasonal_adjustments', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('emergency_provisions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('erp_reference', sa.String(length=100), nullable=True),
sa.Column('supplier_portal_reference', sa.String(length=100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('created_by', sa.UUID(), nullable=True),
sa.Column('updated_by', sa.UUID(), nullable=True),
sa.Column('plan_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_procurement_plans_plan_date'), 'procurement_plans', ['plan_date'], unique=False)
op.create_index(op.f('ix_procurement_plans_plan_number'), 'procurement_plans', ['plan_number'], unique=True)
op.create_index(op.f('ix_procurement_plans_status'), 'procurement_plans', ['status'], unique=False)
op.create_index(op.f('ix_procurement_plans_tenant_id'), 'procurement_plans', ['tenant_id'], unique=False)
op.create_table('customer_contacts',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('customer_id', sa.UUID(), nullable=False),
@@ -233,75 +177,6 @@ def upgrade() -> None:
op.create_index(op.f('ix_customer_orders_order_number'), 'customer_orders', ['order_number'], unique=True)
op.create_index(op.f('ix_customer_orders_status'), 'customer_orders', ['status'], unique=False)
op.create_index(op.f('ix_customer_orders_tenant_id'), 'customer_orders', ['tenant_id'], unique=False)
op.create_table('procurement_requirements',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('plan_id', sa.UUID(), nullable=False),
sa.Column('requirement_number', sa.String(length=50), nullable=False),
sa.Column('product_id', sa.UUID(), nullable=False),
sa.Column('product_name', sa.String(length=200), nullable=False),
sa.Column('product_sku', sa.String(length=100), nullable=True),
sa.Column('product_category', sa.String(length=100), nullable=True),
sa.Column('product_type', sa.String(length=50), nullable=False),
sa.Column('required_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('unit_of_measure', sa.String(length=50), nullable=False),
sa.Column('safety_stock_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('total_quantity_needed', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('current_stock_level', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('reserved_stock', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('available_stock', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('net_requirement', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('order_demand', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('production_demand', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('forecast_demand', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('buffer_demand', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('preferred_supplier_id', sa.UUID(), nullable=True),
sa.Column('backup_supplier_id', sa.UUID(), nullable=True),
sa.Column('supplier_name', sa.String(length=200), nullable=True),
sa.Column('supplier_lead_time_days', sa.Integer(), nullable=True),
sa.Column('minimum_order_quantity', sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column('estimated_unit_cost', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('estimated_total_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('last_purchase_cost', sa.Numeric(precision=10, scale=4), nullable=True),
sa.Column('cost_variance', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('required_by_date', sa.Date(), nullable=False),
sa.Column('lead_time_buffer_days', sa.Integer(), nullable=False),
sa.Column('suggested_order_date', sa.Date(), nullable=False),
sa.Column('latest_order_date', sa.Date(), nullable=False),
sa.Column('quality_specifications', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('special_requirements', sa.Text(), nullable=True),
sa.Column('storage_requirements', sa.String(length=200), nullable=True),
sa.Column('shelf_life_days', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=False),
sa.Column('priority', sa.String(length=20), nullable=False),
sa.Column('risk_level', sa.String(length=20), nullable=False),
sa.Column('purchase_order_id', sa.UUID(), nullable=True),
sa.Column('purchase_order_number', sa.String(length=50), nullable=True),
sa.Column('ordered_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('ordered_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('expected_delivery_date', sa.Date(), nullable=True),
sa.Column('actual_delivery_date', sa.Date(), nullable=True),
sa.Column('received_quantity', sa.Numeric(precision=12, scale=3), nullable=False),
sa.Column('delivery_status', sa.String(length=50), nullable=False),
sa.Column('fulfillment_rate', sa.Numeric(precision=5, scale=2), nullable=True),
sa.Column('on_time_delivery', sa.Boolean(), nullable=True),
sa.Column('quality_rating', sa.Numeric(precision=3, scale=1), nullable=True),
sa.Column('source_orders', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('source_production_batches', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('demand_analysis', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('approved_quantity', sa.Numeric(precision=12, scale=3), nullable=True),
sa.Column('approved_cost', sa.Numeric(precision=12, scale=2), nullable=True),
sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('approved_by', sa.UUID(), nullable=True),
sa.Column('procurement_notes', sa.Text(), nullable=True),
sa.Column('supplier_communication', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('requirement_metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.ForeignKeyConstraint(['plan_id'], ['procurement_plans.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_procurement_requirements_product_id'), 'procurement_requirements', ['product_id'], unique=False)
op.create_index(op.f('ix_procurement_requirements_requirement_number'), 'procurement_requirements', ['requirement_number'], unique=False)
op.create_table('order_items',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('order_id', sa.UUID(), nullable=False),
@@ -367,20 +242,12 @@ def downgrade() -> None:
op.drop_table('order_status_history')
op.drop_index(op.f('ix_order_items_product_id'), table_name='order_items')
op.drop_table('order_items')
op.drop_index(op.f('ix_procurement_requirements_requirement_number'), table_name='procurement_requirements')
op.drop_index(op.f('ix_procurement_requirements_product_id'), table_name='procurement_requirements')
op.drop_table('procurement_requirements')
op.drop_index(op.f('ix_customer_orders_tenant_id'), table_name='customer_orders')
op.drop_index(op.f('ix_customer_orders_status'), table_name='customer_orders')
op.drop_index(op.f('ix_customer_orders_order_number'), table_name='customer_orders')
op.drop_index(op.f('ix_customer_orders_customer_id'), table_name='customer_orders')
op.drop_table('customer_orders')
op.drop_table('customer_contacts')
op.drop_index(op.f('ix_procurement_plans_tenant_id'), table_name='procurement_plans')
op.drop_index(op.f('ix_procurement_plans_status'), table_name='procurement_plans')
op.drop_index(op.f('ix_procurement_plans_plan_number'), table_name='procurement_plans')
op.drop_index(op.f('ix_procurement_plans_plan_date'), table_name='procurement_plans')
op.drop_table('procurement_plans')
op.drop_index(op.f('ix_customers_tenant_id'), table_name='customers')
op.drop_index(op.f('ix_customers_customer_code'), table_name='customers')
op.drop_table('customers')

View File

@@ -1,44 +0,0 @@
"""add smart procurement calculation fields
Revision ID: smart_procurement_v1
Revises: 7f882c2ca25c
Create Date: 2025-10-25
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
# revision identifiers, used by Alembic.
revision = 'smart_procurement_v1'
down_revision = '7f882c2ca25c'
branch_labels = None
depends_on = None
def upgrade():
"""Add smart procurement calculation tracking fields"""
# Add new columns to procurement_requirements table
op.add_column('procurement_requirements', sa.Column('calculation_method', sa.String(100), nullable=True))
op.add_column('procurement_requirements', sa.Column('ai_suggested_quantity', sa.Numeric(12, 3), nullable=True))
op.add_column('procurement_requirements', sa.Column('adjusted_quantity', sa.Numeric(12, 3), nullable=True))
op.add_column('procurement_requirements', sa.Column('adjustment_reason', sa.Text, nullable=True))
op.add_column('procurement_requirements', sa.Column('price_tier_applied', JSONB, nullable=True))
op.add_column('procurement_requirements', sa.Column('supplier_minimum_applied', sa.Boolean, nullable=False, server_default='false'))
op.add_column('procurement_requirements', sa.Column('storage_limit_applied', sa.Boolean, nullable=False, server_default='false'))
op.add_column('procurement_requirements', sa.Column('reorder_rule_applied', sa.Boolean, nullable=False, server_default='false'))
def downgrade():
"""Remove smart procurement calculation tracking fields"""
# Remove columns from procurement_requirements table
op.drop_column('procurement_requirements', 'reorder_rule_applied')
op.drop_column('procurement_requirements', 'storage_limit_applied')
op.drop_column('procurement_requirements', 'supplier_minimum_applied')
op.drop_column('procurement_requirements', 'price_tier_applied')
op.drop_column('procurement_requirements', 'adjustment_reason')
op.drop_column('procurement_requirements', 'adjusted_quantity')
op.drop_column('procurement_requirements', 'ai_suggested_quantity')
op.drop_column('procurement_requirements', 'calculation_method')

View File

@@ -1,496 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Procurement Seeding Script for Orders Service
Creates procurement plans and requirements for demo template tenants
This script runs as a Kubernetes init job inside the orders-service container.
"""
import asyncio
import uuid
import sys
import os
import json
import random
from datetime import datetime, timezone, timedelta, date
from pathlib import Path
from decimal import Decimal
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from app.models.procurement import ProcurementPlan, ProcurementRequirement
# Configure logging
logger = structlog.get_logger()
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") # Individual bakery
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7") # Central bakery
# Base reference date for date calculations
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def load_procurement_config():
"""Load procurement configuration from JSON file"""
config_file = Path(__file__).parent / "compras_config_es.json"
if not config_file.exists():
raise FileNotFoundError(f"Procurement config file not found: {config_file}")
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
def calculate_date_from_offset(offset_days: int) -> date:
"""Calculate a date based on offset from BASE_REFERENCE_DATE"""
return (BASE_REFERENCE_DATE + timedelta(days=offset_days)).date()
def calculate_datetime_from_offset(offset_days: int) -> datetime:
"""Calculate a datetime based on offset from BASE_REFERENCE_DATE"""
return BASE_REFERENCE_DATE + timedelta(days=offset_days)
def weighted_choice(choices: list) -> dict:
"""Make a weighted random choice from list of dicts with 'peso' key"""
total_weight = sum(c.get("peso", 1.0) for c in choices)
r = random.uniform(0, total_weight)
cumulative = 0
for choice in choices:
cumulative += choice.get("peso", 1.0)
if r <= cumulative:
return choice
return choices[-1]
def generate_plan_number(tenant_id: uuid.UUID, index: int, plan_type: str) -> str:
"""Generate a unique plan number"""
tenant_prefix = "SP" if tenant_id == DEMO_TENANT_SAN_PABLO else "LE"
type_code = plan_type[0:3].upper()
return f"PROC-{tenant_prefix}-{type_code}-{BASE_REFERENCE_DATE.year}-{index:03d}"
async def generate_procurement_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
business_model: str,
config: dict
):
"""Generate procurement plans and requirements for a specific tenant"""
logger.info(f"Generating procurement data for: {tenant_name}", tenant_id=str(tenant_id))
# Check if procurement plans already exist
result = await db.execute(
select(ProcurementPlan).where(ProcurementPlan.tenant_id == tenant_id).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
logger.info(f"Procurement plans already exist for {tenant_name}, skipping seed")
return {"tenant_id": str(tenant_id), "plans_created": 0, "requirements_created": 0, "skipped": True}
proc_config = config["configuracion_compras"]
total_plans = proc_config["planes_por_tenant"]
plans_created = 0
requirements_created = 0
for i in range(total_plans):
# Determine temporal distribution
rand_temporal = random.random()
cumulative = 0
temporal_category = None
for category, details in proc_config["distribucion_temporal"].items():
cumulative += details["porcentaje"]
if rand_temporal <= cumulative:
temporal_category = details
break
if not temporal_category:
temporal_category = proc_config["distribucion_temporal"]["completados"]
# Calculate plan date
offset_days = random.randint(
temporal_category["offset_dias_min"],
temporal_category["offset_dias_max"]
)
plan_date = calculate_date_from_offset(offset_days)
# Select status
status = random.choice(temporal_category["estados"])
# Select plan type
plan_type_choice = weighted_choice(proc_config["tipos_plan"])
plan_type = plan_type_choice["tipo"]
# Select priority
priority_rand = random.random()
cumulative_priority = 0
priority = "normal"
for p, weight in proc_config["prioridades"].items():
cumulative_priority += weight
if priority_rand <= cumulative_priority:
priority = p
break
# Select procurement strategy
strategy_choice = weighted_choice(proc_config["estrategias_compra"])
procurement_strategy = strategy_choice["estrategia"]
# Select supply risk level
risk_rand = random.random()
cumulative_risk = 0
supply_risk_level = "low"
for risk, weight in proc_config["niveles_riesgo"].items():
cumulative_risk += weight
if risk_rand <= cumulative_risk:
supply_risk_level = risk
break
# Calculate planning horizon
planning_horizon = proc_config["horizonte_planificacion_dias"][business_model]
# Calculate period dates
period_start = plan_date
period_end = plan_date + timedelta(days=planning_horizon)
# Generate plan number
plan_number = generate_plan_number(tenant_id, i + 1, plan_type)
# Calculate safety stock buffer
safety_stock_buffer = Decimal(str(random.uniform(
proc_config["buffer_seguridad_porcentaje"]["min"],
proc_config["buffer_seguridad_porcentaje"]["max"]
)))
# Calculate approval/execution dates based on status
approved_at = None
execution_started_at = None
execution_completed_at = None
approved_by = None
if status in ["approved", "in_execution", "completed"]:
approved_at = calculate_datetime_from_offset(offset_days - 1)
approved_by = uuid.uuid4() # Would be actual user ID
if status in ["in_execution", "completed"]:
execution_started_at = calculate_datetime_from_offset(offset_days)
if status == "completed":
execution_completed_at = calculate_datetime_from_offset(offset_days + planning_horizon)
# Calculate performance metrics for completed plans
fulfillment_rate = None
on_time_delivery_rate = None
cost_accuracy = None
quality_score = None
if status == "completed":
metrics = proc_config["metricas_rendimiento"]
fulfillment_rate = Decimal(str(random.uniform(
metrics["tasa_cumplimiento"]["min"],
metrics["tasa_cumplimiento"]["max"]
)))
on_time_delivery_rate = Decimal(str(random.uniform(
metrics["entrega_puntual"]["min"],
metrics["entrega_puntual"]["max"]
)))
cost_accuracy = Decimal(str(random.uniform(
metrics["precision_costo"]["min"],
metrics["precision_costo"]["max"]
)))
quality_score = Decimal(str(random.uniform(
metrics["puntuacion_calidad"]["min"],
metrics["puntuacion_calidad"]["max"]
)))
# Create procurement plan
plan = ProcurementPlan(
id=uuid.uuid4(),
tenant_id=tenant_id,
plan_number=plan_number,
plan_date=plan_date,
plan_period_start=period_start,
plan_period_end=period_end,
planning_horizon_days=planning_horizon,
status=status,
plan_type=plan_type,
priority=priority,
business_model=business_model,
procurement_strategy=procurement_strategy,
total_requirements=0, # Will update after adding requirements
total_estimated_cost=Decimal("0.00"), # Will calculate
total_approved_cost=Decimal("0.00"),
safety_stock_buffer=safety_stock_buffer,
supply_risk_level=supply_risk_level,
demand_forecast_confidence=Decimal(str(random.uniform(7.0, 9.5))),
approved_at=approved_at,
approved_by=approved_by,
execution_started_at=execution_started_at,
execution_completed_at=execution_completed_at,
fulfillment_rate=fulfillment_rate,
on_time_delivery_rate=on_time_delivery_rate,
cost_accuracy=cost_accuracy,
quality_score=quality_score,
created_at=calculate_datetime_from_offset(offset_days - 2),
updated_at=calculate_datetime_from_offset(offset_days)
)
db.add(plan)
await db.flush() # Get plan ID
# Generate requirements for this plan
num_requirements = random.randint(
proc_config["requisitos_por_plan"]["min"],
proc_config["requisitos_por_plan"]["max"]
)
# Select random ingredients
selected_ingredients = random.sample(
proc_config["ingredientes_demo"],
min(num_requirements, len(proc_config["ingredientes_demo"]))
)
total_estimated_cost = Decimal("0.00")
for req_num, ingredient in enumerate(selected_ingredients, 1):
# Get quantity range for category
category = ingredient["categoria"]
cantidad_range = proc_config["rangos_cantidad"].get(
category,
{"min": 50.0, "max": 200.0}
)
# Calculate required quantity
required_quantity = Decimal(str(random.uniform(
cantidad_range["min"],
cantidad_range["max"]
)))
# Calculate safety stock
safety_stock_quantity = required_quantity * (safety_stock_buffer / 100)
# Total quantity needed
total_quantity_needed = required_quantity + safety_stock_quantity
# Current stock simulation
current_stock_level = required_quantity * Decimal(str(random.uniform(0.1, 0.4)))
reserved_stock = current_stock_level * Decimal(str(random.uniform(0.0, 0.3)))
available_stock = current_stock_level - reserved_stock
# Net requirement
net_requirement = total_quantity_needed - available_stock
# Demand breakdown
order_demand = required_quantity * Decimal(str(random.uniform(0.5, 0.7)))
production_demand = required_quantity * Decimal(str(random.uniform(0.2, 0.4)))
forecast_demand = required_quantity * Decimal(str(random.uniform(0.05, 0.15)))
buffer_demand = safety_stock_quantity
# Pricing
estimated_unit_cost = Decimal(str(ingredient["costo_unitario"])) * Decimal(str(random.uniform(0.95, 1.05)))
estimated_total_cost = estimated_unit_cost * net_requirement
# Timing
lead_time_days = ingredient["lead_time_dias"]
required_by_date = period_start + timedelta(days=random.randint(3, planning_horizon - 2))
lead_time_buffer_days = random.randint(1, 2)
suggested_order_date = required_by_date - timedelta(days=lead_time_days + lead_time_buffer_days)
latest_order_date = required_by_date - timedelta(days=lead_time_days)
# Requirement status based on plan status
if status == "draft":
req_status = "pending"
elif status == "pending_approval":
req_status = "pending"
elif status == "approved":
req_status = "approved"
elif status == "in_execution":
req_status = random.choice(["ordered", "partially_received"])
elif status == "completed":
req_status = "received"
else:
req_status = "pending"
# Requirement priority
if priority == "critical":
req_priority = "critical"
elif priority == "high":
req_priority = random.choice(["high", "critical"])
else:
req_priority = random.choice(["normal", "high"])
# Risk level
if supply_risk_level == "critical":
req_risk_level = random.choice(["high", "critical"])
elif supply_risk_level == "high":
req_risk_level = random.choice(["medium", "high"])
else:
req_risk_level = "low"
# Create requirement
requirement = ProcurementRequirement(
id=uuid.uuid4(),
plan_id=plan.id,
requirement_number=f"{plan_number}-REQ-{req_num:03d}",
product_id=uuid.UUID(ingredient["id"]),
product_name=ingredient["nombre"],
product_sku=ingredient["sku"],
product_category=ingredient["categoria"],
product_type=ingredient["tipo"],
required_quantity=required_quantity,
unit_of_measure=ingredient["unidad"],
safety_stock_quantity=safety_stock_quantity,
total_quantity_needed=total_quantity_needed,
current_stock_level=current_stock_level,
reserved_stock=reserved_stock,
available_stock=available_stock,
net_requirement=net_requirement,
order_demand=order_demand,
production_demand=production_demand,
forecast_demand=forecast_demand,
buffer_demand=buffer_demand,
supplier_lead_time_days=lead_time_days,
minimum_order_quantity=Decimal(str(ingredient["cantidad_minima"])),
estimated_unit_cost=estimated_unit_cost,
estimated_total_cost=estimated_total_cost,
required_by_date=required_by_date,
lead_time_buffer_days=lead_time_buffer_days,
suggested_order_date=suggested_order_date,
latest_order_date=latest_order_date,
shelf_life_days=ingredient["vida_util_dias"],
status=req_status,
priority=req_priority,
risk_level=req_risk_level,
created_at=plan.created_at,
updated_at=plan.updated_at
)
db.add(requirement)
total_estimated_cost += estimated_total_cost
requirements_created += 1
# Update plan totals
plan.total_requirements = num_requirements
plan.total_estimated_cost = total_estimated_cost
if status in ["approved", "in_execution", "completed"]:
plan.total_approved_cost = total_estimated_cost * Decimal(str(random.uniform(0.95, 1.05)))
plans_created += 1
await db.commit()
logger.info(f"Successfully created {plans_created} plans with {requirements_created} requirements for {tenant_name}")
return {
"tenant_id": str(tenant_id),
"plans_created": plans_created,
"requirements_created": requirements_created,
"skipped": False
}
async def seed_all(db: AsyncSession):
"""Seed all demo tenants with procurement data"""
logger.info("Starting demo procurement seed process")
# Load configuration
config = load_procurement_config()
results = []
# Seed San Pablo (Individual Bakery)
result_san_pablo = await generate_procurement_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"San Pablo - Individual Bakery",
"individual_bakery",
config
)
results.append(result_san_pablo)
# Seed La Espiga (Central Bakery)
result_la_espiga = await generate_procurement_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"La Espiga - Central Bakery",
"central_bakery",
config
)
results.append(result_la_espiga)
total_plans = sum(r["plans_created"] for r in results)
total_requirements = sum(r["requirements_created"] for r in results)
return {
"results": results,
"total_plans_created": total_plans,
"total_requirements_created": total_requirements,
"status": "completed"
}
async def main():
"""Main execution function"""
# Get database URL from environment
database_url = os.getenv("ORDERS_DATABASE_URL")
if not database_url:
logger.error("ORDERS_DATABASE_URL environment variable must be set")
return 1
# Ensure asyncpg driver
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
# Create async engine
engine = create_async_engine(database_url, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with async_session() as session:
result = await seed_all(session)
logger.info(
"Procurement seed completed successfully!",
total_plans=result["total_plans_created"],
total_requirements=result["total_requirements_created"],
status=result["status"]
)
# Print summary
print("\n" + "="*60)
print("DEMO PROCUREMENT SEED SUMMARY")
print("="*60)
for tenant_result in result["results"]:
tenant_id = tenant_result["tenant_id"]
plans = tenant_result["plans_created"]
requirements = tenant_result["requirements_created"]
skipped = tenant_result.get("skipped", False)
status = "SKIPPED (already exists)" if skipped else f"CREATED {plans} plans, {requirements} requirements"
print(f"Tenant {tenant_id}: {status}")
print(f"\nTotal Plans: {result['total_plans_created']}")
print(f"Total Requirements: {result['total_requirements_created']}")
print("="*60 + "\n")
return 0
except Exception as e:
logger.error(f"Procurement seed failed: {str(e)}", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)