Files
bakery-ia/services/procurement/app/api/procurement_plans.py

320 lines
11 KiB
Python
Raw Normal View History

2025-10-30 21:08:07 +01:00
# ================================================================
# 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))