320 lines
11 KiB
Python
320 lines
11 KiB
Python
|
|
# ================================================================
|
||
|
|
# 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))
|