Improve the frontend 3
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user