Improve the frontend 3

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

View File

@@ -0,0 +1,13 @@
"""Procurement Service API"""
from .procurement_plans import router as procurement_plans_router
from .purchase_orders import router as purchase_orders_router
from .replenishment import router as replenishment_router
from .internal_demo import router as internal_demo_router
__all__ = [
"procurement_plans_router",
"purchase_orders_router",
"replenishment_router",
"internal_demo_router"
]

View File

@@ -0,0 +1,523 @@
"""
Internal Demo Cloning API for Procurement Service
Service-to-service endpoint for cloning procurement and purchase order data
"""
from fastapi import APIRouter, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
import structlog
import uuid
from datetime import datetime, timezone, timedelta, date
from typing import Optional
import os
from app.core.database import get_db
from app.models.procurement_plan import ProcurementPlan, ProcurementRequirement
from app.models.purchase_order import PurchaseOrder, PurchaseOrderItem
from app.models.replenishment import ReplenishmentPlan, ReplenishmentPlanItem
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Internal API key for service-to-service auth
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True
@router.post("/clone")
async def clone_demo_data(
base_tenant_id: str,
virtual_tenant_id: str,
demo_account_type: str,
session_id: Optional[str] = None,
session_created_at: Optional[str] = None,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""
Clone procurement service data for a virtual demo tenant
Clones:
- Procurement plans with requirements
- Purchase orders with line items
- Replenishment plans with items
- Adjusts dates to recent timeframe
Args:
base_tenant_id: Template tenant UUID to clone from
virtual_tenant_id: Target virtual tenant UUID
demo_account_type: Type of demo account
session_id: Originating session ID for tracing
Returns:
Cloning status and record counts
"""
start_time = datetime.now(timezone.utc)
# Parse session creation time for date adjustment
if session_created_at:
try:
session_time = datetime.fromisoformat(session_created_at.replace('Z', '+00:00'))
except (ValueError, AttributeError):
session_time = start_time
else:
session_time = start_time
logger.info(
"Starting procurement data cloning",
base_tenant_id=base_tenant_id,
virtual_tenant_id=virtual_tenant_id,
demo_account_type=demo_account_type,
session_id=session_id,
session_created_at=session_created_at
)
try:
# Validate UUIDs
base_uuid = uuid.UUID(base_tenant_id)
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Track cloning statistics
stats = {
"procurement_plans": 0,
"procurement_requirements": 0,
"purchase_orders": 0,
"purchase_order_items": 0,
"replenishment_plans": 0,
"replenishment_items": 0
}
# 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 if plan.plan_date)
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=session_time,
updated_at=session_time
)
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=session_time,
updated_at=session_time
)
db.add(new_req)
stats["procurement_requirements"] += 1
# Clone Purchase Orders with Line Items
result = await db.execute(
select(PurchaseOrder).where(PurchaseOrder.tenant_id == base_uuid)
)
base_orders = result.scalars().all()
logger.info(
"Found purchase orders to clone",
count=len(base_orders),
base_tenant=str(base_uuid)
)
order_id_map = {}
for order in base_orders:
new_order_id = uuid.uuid4()
order_id_map[order.id] = new_order_id
# Adjust dates using demo_dates utility
adjusted_order_date = adjust_date_for_demo(
order.order_date, session_time, BASE_REFERENCE_DATE
)
adjusted_required_delivery = adjust_date_for_demo(
order.required_delivery_date, session_time, BASE_REFERENCE_DATE
)
adjusted_estimated_delivery = adjust_date_for_demo(
order.estimated_delivery_date, session_time, BASE_REFERENCE_DATE
)
adjusted_supplier_confirmation = adjust_date_for_demo(
order.supplier_confirmation_date, session_time, BASE_REFERENCE_DATE
)
adjusted_approved_at = adjust_date_for_demo(
order.approved_at, session_time, BASE_REFERENCE_DATE
)
adjusted_sent_to_supplier_at = adjust_date_for_demo(
order.sent_to_supplier_at, session_time, BASE_REFERENCE_DATE
)
# Generate a system user UUID for audit fields (demo purposes)
system_user_id = uuid.uuid4()
new_order = PurchaseOrder(
id=new_order_id,
tenant_id=virtual_uuid,
po_number=f"PO-{uuid.uuid4().hex[:8].upper()}", # New PO number
reference_number=order.reference_number,
supplier_id=order.supplier_id,
procurement_plan_id=plan_id_map.get(order.procurement_plan_id) if hasattr(order, 'procurement_plan_id') and order.procurement_plan_id else None,
order_date=adjusted_order_date,
required_delivery_date=adjusted_required_delivery,
estimated_delivery_date=adjusted_estimated_delivery,
status=order.status,
priority=order.priority,
subtotal=order.subtotal,
tax_amount=order.tax_amount,
discount_amount=order.discount_amount,
shipping_cost=order.shipping_cost,
total_amount=order.total_amount,
currency=order.currency,
delivery_address=order.delivery_address if hasattr(order, 'delivery_address') else None,
delivery_instructions=order.delivery_instructions if hasattr(order, 'delivery_instructions') else None,
delivery_contact=order.delivery_contact if hasattr(order, 'delivery_contact') else None,
delivery_phone=order.delivery_phone if hasattr(order, 'delivery_phone') else None,
requires_approval=order.requires_approval if hasattr(order, 'requires_approval') else False,
approved_by=order.approved_by if hasattr(order, 'approved_by') else None,
approved_at=adjusted_approved_at,
rejection_reason=order.rejection_reason if hasattr(order, 'rejection_reason') else None,
auto_approved=order.auto_approved if hasattr(order, 'auto_approved') else False,
auto_approval_rule_id=order.auto_approval_rule_id if hasattr(order, 'auto_approval_rule_id') else None,
sent_to_supplier_at=adjusted_sent_to_supplier_at,
supplier_confirmation_date=adjusted_supplier_confirmation,
supplier_reference=order.supplier_reference if hasattr(order, 'supplier_reference') else None,
notes=order.notes if hasattr(order, 'notes') else None,
internal_notes=order.internal_notes if hasattr(order, 'internal_notes') else None,
terms_and_conditions=order.terms_and_conditions if hasattr(order, 'terms_and_conditions') else None,
created_at=session_time,
updated_at=session_time,
created_by=system_user_id,
updated_by=system_user_id
)
db.add(new_order)
stats["purchase_orders"] += 1
# Clone Purchase Order Items
for old_order_id, new_order_id in order_id_map.items():
result = await db.execute(
select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == old_order_id)
)
order_items = result.scalars().all()
for item in order_items:
new_item = PurchaseOrderItem(
id=uuid.uuid4(),
tenant_id=virtual_uuid,
purchase_order_id=new_order_id,
procurement_requirement_id=item.procurement_requirement_id if hasattr(item, 'procurement_requirement_id') else None,
inventory_product_id=item.inventory_product_id,
product_code=item.product_code if hasattr(item, 'product_code') else None,
product_name=item.product_name,
supplier_price_list_id=item.supplier_price_list_id if hasattr(item, 'supplier_price_list_id') else None,
ordered_quantity=item.ordered_quantity,
unit_of_measure=item.unit_of_measure,
unit_price=item.unit_price,
line_total=item.line_total,
received_quantity=item.received_quantity if hasattr(item, 'received_quantity') else 0,
remaining_quantity=item.remaining_quantity if hasattr(item, 'remaining_quantity') else item.ordered_quantity,
quality_requirements=item.quality_requirements if hasattr(item, 'quality_requirements') else None,
item_notes=item.item_notes if hasattr(item, 'item_notes') else None,
created_at=session_time,
updated_at=session_time
)
db.add(new_item)
stats["purchase_order_items"] += 1
# Clone Replenishment Plans with Items
result = await db.execute(
select(ReplenishmentPlan).where(ReplenishmentPlan.tenant_id == base_uuid)
)
base_replenishment_plans = result.scalars().all()
logger.info(
"Found replenishment plans to clone",
count=len(base_replenishment_plans),
base_tenant=str(base_uuid)
)
replan_id_map = {}
for replan in base_replenishment_plans:
new_replan_id = uuid.uuid4()
replan_id_map[replan.id] = new_replan_id
new_replan = ReplenishmentPlan(
id=new_replan_id,
tenant_id=virtual_uuid,
plan_number=f"REPL-{uuid.uuid4().hex[:8].upper()}",
plan_date=replan.plan_date + plan_date_offset if replan.plan_date else None,
plan_period_start=replan.plan_period_start + plan_date_offset if replan.plan_period_start else None,
plan_period_end=replan.plan_period_end + plan_date_offset if replan.plan_period_end else None,
planning_horizon_days=replan.planning_horizon_days,
status=replan.status,
plan_type=replan.plan_type,
priority=replan.priority,
business_model=replan.business_model,
total_items=replan.total_items,
total_estimated_cost=replan.total_estimated_cost,
created_at=session_time,
updated_at=session_time
)
db.add(new_replan)
stats["replenishment_plans"] += 1
# Clone Replenishment Plan Items
for old_replan_id, new_replan_id in replan_id_map.items():
result = await db.execute(
select(ReplenishmentPlanItem).where(ReplenishmentPlanItem.plan_id == old_replan_id)
)
replan_items = result.scalars().all()
for item in replan_items:
new_item = ReplenishmentPlanItem(
id=uuid.uuid4(),
plan_id=new_replan_id,
product_id=item.product_id,
product_name=item.product_name,
product_sku=item.product_sku,
required_quantity=item.required_quantity,
unit_of_measure=item.unit_of_measure,
current_stock_level=item.current_stock_level,
safety_stock_quantity=item.safety_stock_quantity,
suggested_order_quantity=item.suggested_order_quantity,
supplier_id=item.supplier_id,
supplier_name=item.supplier_name,
estimated_delivery_days=item.estimated_delivery_days,
required_by_date=item.required_by_date + plan_date_offset if item.required_by_date else None,
status=item.status,
priority=item.priority,
notes=item.notes,
created_at=session_time,
updated_at=session_time
)
db.add(new_item)
stats["replenishment_items"] += 1
# Commit cloned data
await db.commit()
total_records = sum(stats.values())
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info(
"Procurement data cloning completed",
virtual_tenant_id=virtual_tenant_id,
total_records=total_records,
stats=stats,
duration_ms=duration_ms
)
return {
"service": "procurement",
"status": "completed",
"records_cloned": total_records,
"duration_ms": duration_ms,
"details": stats
}
except ValueError as e:
logger.error("Invalid UUID format", error=str(e))
raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}")
except Exception as e:
logger.error(
"Failed to clone procurement data",
error=str(e),
virtual_tenant_id=virtual_tenant_id,
exc_info=True
)
# Rollback on error
await db.rollback()
return {
"service": "procurement",
"status": "failed",
"records_cloned": 0,
"duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000),
"error": str(e)
}
@router.get("/clone/health")
async def clone_health_check(_: bool = Depends(verify_internal_api_key)):
"""
Health check for internal cloning endpoint
Used by orchestrator to verify service availability
"""
return {
"service": "procurement",
"clone_endpoint": "available",
"version": "2.0.0"
}
@router.delete("/tenant/{virtual_tenant_id}")
async def delete_demo_data(
virtual_tenant_id: str,
db: AsyncSession = Depends(get_db),
_: bool = Depends(verify_internal_api_key)
):
"""Delete all procurement data for a virtual demo tenant"""
logger.info("Deleting procurement data for virtual tenant", virtual_tenant_id=virtual_tenant_id)
start_time = datetime.now(timezone.utc)
try:
virtual_uuid = uuid.UUID(virtual_tenant_id)
# Count records
po_count = await db.scalar(select(func.count(PurchaseOrder.id)).where(PurchaseOrder.tenant_id == virtual_uuid))
item_count = await db.scalar(select(func.count(PurchaseOrderItem.id)).where(PurchaseOrderItem.tenant_id == virtual_uuid))
plan_count = await db.scalar(select(func.count(ProcurementPlan.id)).where(ProcurementPlan.tenant_id == virtual_uuid))
req_count = await db.scalar(select(func.count(ProcurementRequirement.id)).where(ProcurementRequirement.tenant_id == virtual_uuid))
replan_count = await db.scalar(select(func.count(ReplenishmentPlan.id)).where(ReplenishmentPlan.tenant_id == virtual_uuid))
replan_item_count = await db.scalar(select(func.count(ReplenishmentPlanItem.id)).where(ReplenishmentPlanItem.tenant_id == virtual_uuid))
# Delete in order (respecting foreign key constraints)
await db.execute(delete(PurchaseOrderItem).where(PurchaseOrderItem.tenant_id == virtual_uuid))
await db.execute(delete(PurchaseOrder).where(PurchaseOrder.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(ReplenishmentPlanItem).where(ReplenishmentPlanItem.tenant_id == virtual_uuid))
await db.execute(delete(ReplenishmentPlan).where(ReplenishmentPlan.tenant_id == virtual_uuid))
await db.commit()
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
logger.info("Procurement data deleted successfully", virtual_tenant_id=virtual_tenant_id, duration_ms=duration_ms)
return {
"service": "procurement",
"status": "deleted",
"virtual_tenant_id": virtual_tenant_id,
"records_deleted": {
"purchase_orders": po_count,
"purchase_order_items": item_count,
"procurement_plans": plan_count,
"procurement_requirements": req_count,
"replenishment_plans": replan_count,
"replenishment_items": replan_item_count,
"total": po_count + item_count + plan_count + req_count + replan_count + replan_item_count
},
"duration_ms": duration_ms
}
except Exception as e:
logger.error("Failed to delete procurement data", error=str(e), exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,319 @@
# ================================================================
# services/procurement/app/api/procurement_plans.py
# ================================================================
"""
Procurement Plans API - Endpoints for procurement planning
"""
import uuid
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
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,
AutoGenerateProcurementRequest,
AutoGenerateProcurementResponse,
PaginatedProcurementPlans,
)
import structlog
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/procurement", tags=["Procurement Plans"])
def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
"""Dependency to get procurement service"""
return ProcurementService(db, settings)
# ================================================================
# ORCHESTRATOR ENTRY POINT
# ================================================================
@router.post("/auto-generate", response_model=AutoGenerateProcurementResponse)
async def auto_generate_procurement(
tenant_id: str,
request_data: AutoGenerateProcurementRequest,
service: ProcurementService = Depends(get_procurement_service),
db: AsyncSession = Depends(get_db)
):
"""
Auto-generate procurement plan from forecast data (called by Orchestrator)
This is the main entry point for orchestrated procurement planning.
The Orchestrator calls Forecasting Service first, then passes forecast data here.
Flow:
1. Receive forecast data from orchestrator
2. Calculate procurement requirements
3. Apply Recipe Explosion for locally-produced items
4. Create procurement plan
5. Optionally create and auto-approve purchase orders
Returns:
AutoGenerateProcurementResponse with plan details and created POs
"""
try:
logger.info("Auto-generate procurement endpoint called",
tenant_id=tenant_id,
has_forecast_data=bool(request_data.forecast_data))
result = await service.auto_generate_procurement(
tenant_id=uuid.UUID(tenant_id),
request=request_data
)
return result
except Exception as e:
logger.error("Error in auto_generate_procurement endpoint", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# MANUAL PROCUREMENT PLAN GENERATION
# ================================================================
@router.post("/plans/generate", response_model=GeneratePlanResponse)
async def generate_procurement_plan(
tenant_id: str,
request_data: GeneratePlanRequest,
service: ProcurementService = Depends(get_procurement_service)
):
"""
Generate a new procurement plan (manual/UI-driven)
This endpoint is used for manual procurement planning from the UI.
Unlike auto_generate_procurement, this generates its own forecasts.
Args:
tenant_id: Tenant UUID
request_data: Plan generation parameters
Returns:
GeneratePlanResponse with the created plan
"""
try:
logger.info("Generate procurement plan endpoint called",
tenant_id=tenant_id,
plan_date=request_data.plan_date)
result = await service.generate_procurement_plan(
tenant_id=uuid.UUID(tenant_id),
request=request_data
)
return result
except Exception as e:
logger.error("Error generating procurement plan", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# PROCUREMENT PLAN CRUD
# ================================================================
@router.get("/plans/current", response_model=Optional[ProcurementPlanResponse])
async def get_current_plan(
tenant_id: str,
service: ProcurementService = Depends(get_procurement_service)
):
"""Get the current day's procurement plan"""
try:
plan = await service.get_current_plan(uuid.UUID(tenant_id))
return plan
except Exception as e:
logger.error("Error getting current plan", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/plans/{plan_id}", response_model=ProcurementPlanResponse)
async def get_plan_by_id(
tenant_id: str,
plan_id: str,
service: ProcurementService = Depends(get_procurement_service)
):
"""Get procurement plan by ID"""
try:
plan = await service.get_plan_by_id(uuid.UUID(tenant_id), uuid.UUID(plan_id))
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
return plan
except HTTPException:
raise
except Exception as e:
logger.error("Error getting plan by ID", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/plans/date/{plan_date}", response_model=Optional[ProcurementPlanResponse])
async def get_plan_by_date(
tenant_id: str,
plan_date: date,
service: ProcurementService = Depends(get_procurement_service)
):
"""Get procurement plan for a specific date"""
try:
plan = await service.get_plan_by_date(uuid.UUID(tenant_id), plan_date)
return plan
except Exception as e:
logger.error("Error getting plan by date", error=str(e), tenant_id=tenant_id, plan_date=plan_date)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/plans", response_model=PaginatedProcurementPlans)
async def list_procurement_plans(
tenant_id: str,
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100),
service: ProcurementService = Depends(get_procurement_service),
db: AsyncSession = Depends(get_db)
):
"""List all procurement plans for tenant with pagination"""
try:
from app.repositories.procurement_plan_repository import ProcurementPlanRepository
repo = ProcurementPlanRepository(db)
plans = await repo.list_plans(uuid.UUID(tenant_id), skip=skip, limit=limit)
total = await repo.count_plans(uuid.UUID(tenant_id))
plans_response = [ProcurementPlanResponse.model_validate(p) for p in plans]
return PaginatedProcurementPlans(
plans=plans_response,
total=total,
page=skip // limit + 1,
limit=limit,
has_more=(skip + limit) < total
)
except Exception as e:
logger.error("Error listing procurement plans", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/plans/{plan_id}/status")
async def update_plan_status(
tenant_id: str,
plan_id: str,
status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
notes: Optional[str] = None,
service: ProcurementService = Depends(get_procurement_service)
):
"""Update procurement plan status"""
try:
updated_plan = await service.update_plan_status(
tenant_id=uuid.UUID(tenant_id),
plan_id=uuid.UUID(plan_id),
status=status,
approval_notes=notes
)
if not updated_plan:
raise HTTPException(status_code=404, detail="Plan not found")
return updated_plan
except HTTPException:
raise
except Exception as e:
logger.error("Error updating plan status", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/plans/{plan_id}/create-purchase-orders")
async def create_purchase_orders_from_plan(
tenant_id: str,
plan_id: str,
auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"),
service: ProcurementService = Depends(get_procurement_service)
):
"""
Create purchase orders from procurement plan requirements
Groups requirements by supplier and creates POs automatically.
Optionally evaluates auto-approval rules for qualifying POs.
Args:
tenant_id: Tenant UUID
plan_id: Procurement plan UUID
auto_approve: Whether to auto-approve qualifying POs
Returns:
Summary of created, approved, and failed purchase orders
"""
try:
result = await service.create_purchase_orders_from_plan(
tenant_id=uuid.UUID(tenant_id),
plan_id=uuid.UUID(plan_id),
auto_approve=auto_approve
)
if not result.get('success'):
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create purchase orders'))
return result
except HTTPException:
raise
except Exception as e:
logger.error("Error creating POs from plan", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# TESTING AND UTILITIES
# ================================================================
@router.get("/plans/{plan_id}/requirements")
async def get_plan_requirements(
tenant_id: str,
plan_id: str,
service: ProcurementService = Depends(get_procurement_service),
db: AsyncSession = Depends(get_db)
):
"""Get all requirements for a procurement plan"""
try:
from app.repositories.procurement_plan_repository import ProcurementRequirementRepository
repo = ProcurementRequirementRepository(db)
requirements = await repo.get_requirements_by_plan(uuid.UUID(plan_id))
return {
"plan_id": plan_id,
"requirements_count": len(requirements),
"requirements": [
{
"id": str(req.id),
"requirement_number": req.requirement_number,
"product_name": req.product_name,
"net_requirement": float(req.net_requirement),
"unit_of_measure": req.unit_of_measure,
"priority": req.priority,
"status": req.status,
"is_locally_produced": req.is_locally_produced,
"bom_explosion_level": req.bom_explosion_level,
"supplier_name": req.supplier_name,
"estimated_total_cost": float(req.estimated_total_cost or 0)
}
for req in requirements
]
}
except Exception as e:
logger.error("Error getting plan requirements", error=str(e), tenant_id=tenant_id, plan_id=plan_id)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,458 @@
# ================================================================
# services/procurement/app/api/purchase_orders.py
# ================================================================
"""
Purchase Orders API - Endpoints for purchase order management
"""
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.config import settings
from app.services.purchase_order_service import PurchaseOrderService
from app.schemas.purchase_order_schemas import (
PurchaseOrderCreate,
PurchaseOrderUpdate,
PurchaseOrderResponse,
PurchaseOrderApproval,
DeliveryCreate,
DeliveryResponse,
SupplierInvoiceCreate,
SupplierInvoiceResponse,
)
import structlog
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/purchase-orders", tags=["Purchase Orders"])
def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService:
"""Dependency to get purchase order service"""
return PurchaseOrderService(db, settings)
# ================================================================
# PURCHASE ORDER CRUD
# ================================================================
@router.post("", response_model=PurchaseOrderResponse, status_code=201)
async def create_purchase_order(
tenant_id: str,
po_data: PurchaseOrderCreate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Create a new purchase order with items
Creates a PO with automatic approval rules evaluation.
Links to procurement plan if procurement_plan_id is provided.
Args:
tenant_id: Tenant UUID
po_data: Purchase order creation data
Returns:
PurchaseOrderResponse with created PO details
"""
try:
logger.info("Create PO endpoint called", tenant_id=tenant_id)
po = await service.create_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_data=po_data
)
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating purchase order", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{po_id}", response_model=PurchaseOrderResponse)
async def get_purchase_order(
tenant_id: str,
po_id: str,
service: PurchaseOrderService = Depends(get_po_service)
):
"""Get purchase order by ID with items"""
try:
po = await service.get_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id)
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting purchase order", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.get("", response_model=List[PurchaseOrderResponse])
async def list_purchase_orders(
tenant_id: str,
skip: int = Query(default=0, ge=0),
limit: int = Query(default=50, ge=1, le=100),
supplier_id: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
List purchase orders with filters
Args:
tenant_id: Tenant UUID
skip: Number of records to skip (pagination)
limit: Maximum number of records to return
supplier_id: Filter by supplier ID (optional)
status: Filter by status (optional)
Returns:
List of purchase orders
"""
try:
pos = await service.list_purchase_orders(
tenant_id=uuid.UUID(tenant_id),
skip=skip,
limit=limit,
supplier_id=uuid.UUID(supplier_id) if supplier_id else None,
status=status
)
return [PurchaseOrderResponse.model_validate(po) for po in pos]
except Exception as e:
logger.error("Error listing purchase orders", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/{po_id}", response_model=PurchaseOrderResponse)
async def update_purchase_order(
tenant_id: str,
po_id: str,
po_data: PurchaseOrderUpdate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Update purchase order information
Only draft or pending_approval orders can be modified.
Financial field changes trigger automatic total recalculation.
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
po_data: Update data
Returns:
Updated purchase order
"""
try:
po = await service.update_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
po_data=po_data
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error updating purchase order", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/{po_id}/status")
async def update_order_status(
tenant_id: str,
po_id: str,
status: str = Query(..., description="New status"),
notes: Optional[str] = Query(default=None),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Update purchase order status
Validates status transitions to prevent invalid state changes.
Valid transitions:
- draft -> pending_approval, approved, cancelled
- pending_approval -> approved, rejected, cancelled
- approved -> sent_to_supplier, cancelled
- sent_to_supplier -> confirmed, cancelled
- confirmed -> in_production, cancelled
- in_production -> shipped, cancelled
- shipped -> delivered, cancelled
- delivered -> completed
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
status: New status
notes: Optional status change notes
Returns:
Updated purchase order
"""
try:
po = await service.update_order_status(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
status=status,
notes=notes
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error updating PO status", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# APPROVAL WORKFLOW
# ================================================================
@router.post("/{po_id}/approve", response_model=PurchaseOrderResponse)
async def approve_purchase_order(
tenant_id: str,
po_id: str,
approval_data: PurchaseOrderApproval,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Approve or reject a purchase order
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
approval_data: Approval or rejection data
Returns:
Updated purchase order
"""
try:
if approval_data.action == "approve":
po = await service.approve_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
approved_by=approval_data.approved_by,
approval_notes=approval_data.notes
)
elif approval_data.action == "reject":
po = await service.reject_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
rejected_by=approval_data.approved_by,
rejection_reason=approval_data.notes or "No reason provided"
)
else:
raise ValueError("Invalid action. Must be 'approve' or 'reject'")
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error in PO approval workflow", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{po_id}/cancel", response_model=PurchaseOrderResponse)
async def cancel_purchase_order(
tenant_id: str,
po_id: str,
reason: str = Query(..., description="Cancellation reason"),
cancelled_by: Optional[str] = Query(default=None),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Cancel a purchase order
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
reason: Cancellation reason
cancelled_by: User ID performing cancellation
Returns:
Cancelled purchase order
"""
try:
po = await service.cancel_purchase_order(
tenant_id=uuid.UUID(tenant_id),
po_id=uuid.UUID(po_id),
cancelled_by=uuid.UUID(cancelled_by) if cancelled_by else None,
cancellation_reason=reason
)
if not po:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.model_validate(po)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error cancelling purchase order", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# DELIVERY MANAGEMENT
# ================================================================
@router.post("/{po_id}/deliveries", response_model=DeliveryResponse, status_code=201)
async def create_delivery(
tenant_id: str,
po_id: str,
delivery_data: DeliveryCreate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Create a delivery record for a purchase order
Tracks delivery scheduling, items, quality inspection, and receipt.
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
delivery_data: Delivery creation data
Returns:
DeliveryResponse with created delivery details
"""
try:
# Validate PO ID matches
if str(delivery_data.purchase_order_id) != po_id:
raise ValueError("Purchase order ID mismatch")
delivery = await service.create_delivery(
tenant_id=uuid.UUID(tenant_id),
delivery_data=delivery_data,
created_by=uuid.uuid4() # TODO: Get from auth context
)
return DeliveryResponse.model_validate(delivery)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating delivery", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/deliveries/{delivery_id}/status")
async def update_delivery_status(
tenant_id: str,
delivery_id: str,
status: str = Query(..., description="New delivery status"),
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Update delivery status
Valid statuses: scheduled, in_transit, delivered, completed, cancelled
Args:
tenant_id: Tenant UUID
delivery_id: Delivery UUID
status: New status
Returns:
Updated delivery
"""
try:
delivery = await service.update_delivery_status(
tenant_id=uuid.UUID(tenant_id),
delivery_id=uuid.UUID(delivery_id),
status=status,
updated_by=uuid.uuid4() # TODO: Get from auth context
)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
return DeliveryResponse.model_validate(delivery)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e:
logger.error("Error updating delivery status", error=str(e), delivery_id=delivery_id)
raise HTTPException(status_code=500, detail=str(e))
# ================================================================
# INVOICE MANAGEMENT
# ================================================================
@router.post("/{po_id}/invoices", response_model=SupplierInvoiceResponse, status_code=201)
async def create_invoice(
tenant_id: str,
po_id: str,
invoice_data: SupplierInvoiceCreate,
service: PurchaseOrderService = Depends(get_po_service)
):
"""
Create a supplier invoice for a purchase order
Args:
tenant_id: Tenant UUID
po_id: Purchase order UUID
invoice_data: Invoice creation data
Returns:
SupplierInvoiceResponse with created invoice details
"""
try:
# Validate PO ID matches
if str(invoice_data.purchase_order_id) != po_id:
raise ValueError("Purchase order ID mismatch")
invoice = await service.create_invoice(
tenant_id=uuid.UUID(tenant_id),
invoice_data=invoice_data,
created_by=uuid.uuid4() # TODO: Get from auth context
)
return SupplierInvoiceResponse.model_validate(invoice)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating invoice", error=str(e), po_id=po_id)
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,430 @@
"""
Replenishment Planning API Routes
Provides endpoints for advanced replenishment planning including:
- Generate replenishment plans
- View inventory projections
- Review supplier allocations
- Get planning analytics
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional
from uuid import UUID
from datetime import date
from app.schemas.replenishment import (
GenerateReplenishmentPlanRequest,
GenerateReplenishmentPlanResponse,
ReplenishmentPlanResponse,
ReplenishmentPlanSummary,
InventoryProjectionResponse,
SupplierAllocationResponse,
SupplierSelectionRequest,
SupplierSelectionResult,
SafetyStockRequest,
SafetyStockResponse,
ProjectInventoryRequest,
ProjectInventoryResponse,
ReplenishmentAnalytics,
MOQAggregationRequest,
MOQAggregationResponse
)
from app.services.procurement_service import ProcurementService
from app.services.replenishment_planning_service import ReplenishmentPlanningService
from app.services.safety_stock_calculator import SafetyStockCalculator
from app.services.inventory_projector import InventoryProjector, DailyDemand, ScheduledReceipt
from app.services.moq_aggregator import MOQAggregator
from app.services.supplier_selector import SupplierSelector
from app.core.dependencies import get_db, get_current_tenant_id
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
logger = structlog.get_logger()
router = APIRouter(prefix="/replenishment-plans", tags=["Replenishment Planning"])
# ============================================================
# Replenishment Plan Endpoints
# ============================================================
@router.post("/generate", response_model=GenerateReplenishmentPlanResponse)
async def generate_replenishment_plan(
request: GenerateReplenishmentPlanRequest,
tenant_id: UUID = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db)
):
"""
Generate advanced replenishment plan with:
- Lead-time-aware order date calculation
- Dynamic safety stock
- Inventory projection
- Shelf-life management
"""
try:
logger.info("Generating replenishment plan", tenant_id=tenant_id)
# Initialize replenishment planner
planner = ReplenishmentPlanningService(
projection_horizon_days=request.projection_horizon_days,
default_service_level=request.service_level,
default_buffer_days=request.buffer_days
)
# Generate plan
plan = await planner.generate_replenishment_plan(
tenant_id=str(tenant_id),
requirements=request.requirements,
forecast_id=request.forecast_id,
production_schedule_id=request.production_schedule_id
)
# Export to response
plan_dict = planner.export_plan_to_dict(plan)
return GenerateReplenishmentPlanResponse(**plan_dict)
except Exception as e:
logger.error("Failed to generate replenishment plan",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("", response_model=List[ReplenishmentPlanSummary])
async def list_replenishment_plans(
tenant_id: UUID = Depends(get_current_tenant_id),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
status: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""
List replenishment plans for tenant
"""
try:
# Query from database (implementation depends on your repo)
# This is a placeholder - implement based on your repository
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
repo = ReplenishmentPlanRepository(db)
plans = await repo.list_plans(
tenant_id=tenant_id,
skip=skip,
limit=limit,
status=status
)
return plans
except Exception as e:
logger.error("Failed to list replenishment plans",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{plan_id}", response_model=ReplenishmentPlanResponse)
async def get_replenishment_plan(
plan_id: UUID = Path(...),
tenant_id: UUID = Depends(get_current_tenant_id),
db: AsyncSession = Depends(get_db)
):
"""
Get replenishment plan by ID
"""
try:
from app.repositories.replenishment_repository import ReplenishmentPlanRepository
repo = ReplenishmentPlanRepository(db)
plan = await repo.get_plan_by_id(plan_id, tenant_id)
if not plan:
raise HTTPException(status_code=404, detail="Replenishment plan not found")
return plan
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get replenishment plan",
tenant_id=tenant_id, plan_id=plan_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Inventory Projection Endpoints
# ============================================================
@router.post("/inventory-projections/project", response_model=ProjectInventoryResponse)
async def project_inventory(
request: ProjectInventoryRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Project inventory levels to identify future stockouts
"""
try:
logger.info("Projecting inventory", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
projector = InventoryProjector(request.projection_horizon_days)
# Build daily demand objects
daily_demand = [
DailyDemand(
ingredient_id=request.ingredient_id,
date=d['date'],
quantity=d['quantity']
)
for d in request.daily_demand
]
# Build scheduled receipts
scheduled_receipts = [
ScheduledReceipt(
ingredient_id=request.ingredient_id,
date=r['date'],
quantity=r['quantity'],
source=r.get('source', 'purchase_order'),
reference_id=r.get('reference_id')
)
for r in request.scheduled_receipts
]
# Project inventory
projection = projector.project_inventory(
ingredient_id=request.ingredient_id,
ingredient_name=request.ingredient_name,
current_stock=request.current_stock,
unit_of_measure=request.unit_of_measure,
daily_demand=daily_demand,
scheduled_receipts=scheduled_receipts
)
# Export to response
projection_dict = projector.export_projection_to_dict(projection)
return ProjectInventoryResponse(**projection_dict)
except Exception as e:
logger.error("Failed to project inventory",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/inventory-projections", response_model=List[InventoryProjectionResponse])
async def list_inventory_projections(
tenant_id: UUID = Depends(get_current_tenant_id),
ingredient_id: Optional[UUID] = None,
projection_date: Optional[date] = None,
stockout_only: bool = False,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db)
):
"""
List inventory projections
"""
try:
from app.repositories.replenishment_repository import InventoryProjectionRepository
repo = InventoryProjectionRepository(db)
projections = await repo.list_projections(
tenant_id=tenant_id,
ingredient_id=ingredient_id,
projection_date=projection_date,
stockout_only=stockout_only,
skip=skip,
limit=limit
)
return projections
except Exception as e:
logger.error("Failed to list inventory projections",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Safety Stock Endpoints
# ============================================================
@router.post("/safety-stock/calculate", response_model=SafetyStockResponse)
async def calculate_safety_stock(
request: SafetyStockRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Calculate dynamic safety stock using statistical methods
"""
try:
logger.info("Calculating safety stock", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
calculator = SafetyStockCalculator(request.service_level)
result = calculator.calculate_from_demand_history(
daily_demands=request.daily_demands,
lead_time_days=request.lead_time_days,
service_level=request.service_level
)
return SafetyStockResponse(**calculator.export_to_dict(result))
except Exception as e:
logger.error("Failed to calculate safety stock",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Supplier Selection Endpoints
# ============================================================
@router.post("/supplier-selections/evaluate", response_model=SupplierSelectionResult)
async def evaluate_supplier_selection(
request: SupplierSelectionRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Evaluate supplier options using multi-criteria decision analysis
"""
try:
logger.info("Evaluating supplier selection", tenant_id=tenant_id,
ingredient_id=request.ingredient_id)
selector = SupplierSelector()
# Convert supplier options
from app.services.supplier_selector import SupplierOption
supplier_options = [
SupplierOption(**opt) for opt in request.supplier_options
]
result = selector.select_suppliers(
ingredient_id=request.ingredient_id,
ingredient_name=request.ingredient_name,
required_quantity=request.required_quantity,
supplier_options=supplier_options
)
return SupplierSelectionResult(**selector.export_result_to_dict(result))
except Exception as e:
logger.error("Failed to evaluate supplier selection",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/supplier-allocations", response_model=List[SupplierAllocationResponse])
async def list_supplier_allocations(
tenant_id: UUID = Depends(get_current_tenant_id),
requirement_id: Optional[UUID] = None,
supplier_id: Optional[UUID] = None,
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db)
):
"""
List supplier allocations
"""
try:
from app.repositories.replenishment_repository import SupplierAllocationRepository
repo = SupplierAllocationRepository(db)
allocations = await repo.list_allocations(
tenant_id=tenant_id,
requirement_id=requirement_id,
supplier_id=supplier_id,
skip=skip,
limit=limit
)
return allocations
except Exception as e:
logger.error("Failed to list supplier allocations",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# MOQ Aggregation Endpoints
# ============================================================
@router.post("/moq-aggregation/aggregate", response_model=MOQAggregationResponse)
async def aggregate_for_moq(
request: MOQAggregationRequest,
tenant_id: UUID = Depends(get_current_tenant_id)
):
"""
Aggregate requirements to meet Minimum Order Quantities
"""
try:
logger.info("Aggregating requirements for MOQ", tenant_id=tenant_id)
aggregator = MOQAggregator()
# Convert requirements and constraints
from app.services.moq_aggregator import (
ProcurementRequirement as MOQReq,
SupplierConstraints
)
requirements = [MOQReq(**req) for req in request.requirements]
constraints = {
k: SupplierConstraints(**v)
for k, v in request.supplier_constraints.items()
}
# Aggregate
aggregated_orders = aggregator.aggregate_requirements(
requirements=requirements,
supplier_constraints=constraints
)
# Calculate efficiency
efficiency = aggregator.calculate_order_efficiency(aggregated_orders)
return MOQAggregationResponse(
aggregated_orders=[aggregator.export_to_dict(order) for order in aggregated_orders],
efficiency_metrics=efficiency
)
except Exception as e:
logger.error("Failed to aggregate for MOQ",
tenant_id=tenant_id, error=str(e), exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Analytics Endpoints
# ============================================================
@router.get("/analytics", response_model=ReplenishmentAnalytics)
async def get_replenishment_analytics(
tenant_id: UUID = Depends(get_current_tenant_id),
start_date: Optional[date] = None,
end_date: Optional[date] = None,
db: AsyncSession = Depends(get_db)
):
"""
Get replenishment planning analytics
"""
try:
from app.repositories.replenishment_repository import ReplenishmentAnalyticsRepository
repo = ReplenishmentAnalyticsRepository(db)
analytics = await repo.get_analytics(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
return analytics
except Exception as e:
logger.error("Failed to get replenishment analytics",
tenant_id=tenant_id, error=str(e))
raise HTTPException(status_code=500, detail=str(e))