Initial commit - production deployment
This commit is contained in:
346
services/procurement/app/api/procurement_plans.py
Normal file
346
services/procurement/app/api/procurement_plans.py
Normal file
@@ -0,0 +1,346 @@
|
||||
# ================================================================
|
||||
# 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, Path, 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,
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(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(
|
||||
route_builder.build_operations_route("auto-generate"),
|
||||
response_model=AutoGenerateProcurementResponse
|
||||
)
|
||||
async def auto_generate_procurement(
|
||||
request_data: AutoGenerateProcurementRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_base_route("plans"),
|
||||
response_model=GeneratePlanResponse
|
||||
)
|
||||
async def generate_procurement_plan(
|
||||
request_data: GeneratePlanRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_base_route("plans/current"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
async def get_current_plan(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_resource_detail_route("plans", "plan_id"),
|
||||
response_model=ProcurementPlanResponse
|
||||
)
|
||||
async def get_plan_by_id(
|
||||
plan_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_base_route("plans/date/{plan_date}"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
async def get_plan_by_date(
|
||||
plan_date: date,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_base_route("plans"),
|
||||
response_model=PaginatedProcurementPlans
|
||||
)
|
||||
async def list_procurement_plans(
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_resource_action_route("plans", "plan_id", "status")
|
||||
)
|
||||
async def update_plan_status(
|
||||
plan_id: str,
|
||||
status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_resource_action_route("plans", "plan_id", "create-purchase-orders")
|
||||
)
|
||||
async def create_purchase_orders_from_plan(
|
||||
plan_id: str,
|
||||
auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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(
|
||||
route_builder.build_resource_action_route("plans", "plan_id", "requirements")
|
||||
)
|
||||
async def get_plan_requirements(
|
||||
plan_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
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))
|
||||
Reference in New Issue
Block a user