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

347 lines
12 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
2025-11-05 13:34:56 +01:00
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
2025-10-30 21:08:07 +01:00
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,
)
2025-11-05 13:34:56 +01:00
from shared.routing import RouteBuilder
2025-10-30 21:08:07 +01:00
import structlog
logger = structlog.get_logger()
2025-11-05 13:34:56 +01:00
# Create route builder for consistent URL structure
route_builder = RouteBuilder('procurement')
router = APIRouter(tags=["procurement-plans"])
2025-10-30 21:08:07 +01:00
def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
"""Dependency to get procurement service"""
return ProcurementService(db, settings)
# ================================================================
# ORCHESTRATOR ENTRY POINT
# ================================================================
2025-11-05 13:34:56 +01:00
@router.post(
route_builder.build_operations_route("auto-generate"),
response_model=AutoGenerateProcurementResponse
)
2025-10-30 21:08:07 +01:00
async def auto_generate_procurement(
request_data: AutoGenerateProcurementRequest,
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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
# ================================================================
2025-11-05 13:34:56 +01:00
@router.post(
route_builder.build_base_route("plans"),
response_model=GeneratePlanResponse
)
2025-10-30 21:08:07 +01:00
async def generate_procurement_plan(
request_data: GeneratePlanRequest,
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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
# ================================================================
2025-11-05 13:34:56 +01:00
@router.get(
route_builder.build_base_route("plans/current"),
response_model=Optional[ProcurementPlanResponse]
)
2025-10-30 21:08:07 +01:00
async def get_current_plan(
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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))
2025-11-05 13:34:56 +01:00
@router.get(
route_builder.build_resource_detail_route("plans", "plan_id"),
response_model=ProcurementPlanResponse
)
2025-10-30 21:08:07 +01:00
async def get_plan_by_id(
plan_id: str,
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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))
2025-11-05 13:34:56 +01:00
@router.get(
route_builder.build_base_route("plans/date/{plan_date}"),
response_model=Optional[ProcurementPlanResponse]
)
2025-10-30 21:08:07 +01:00
async def get_plan_by_date(
plan_date: date,
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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))
2025-11-05 13:34:56 +01:00
@router.get(
route_builder.build_base_route("plans"),
response_model=PaginatedProcurementPlans
)
2025-10-30 21:08:07 +01:00
async def list_procurement_plans(
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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))
2025-11-05 13:34:56 +01:00
@router.patch(
route_builder.build_resource_action_route("plans", "plan_id", "status")
)
2025-10-30 21:08:07 +01:00
async def update_plan_status(
plan_id: str,
status: str = Query(..., regex="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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))
2025-11-05 13:34:56 +01:00
@router.post(
route_builder.build_resource_action_route("plans", "plan_id", "create-purchase-orders")
)
2025-10-30 21:08:07 +01:00
async def create_purchase_orders_from_plan(
plan_id: str,
auto_approve: bool = Query(default=False, description="Auto-approve qualifying purchase orders"),
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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
# ================================================================
2025-11-05 13:34:56 +01:00
@router.get(
route_builder.build_resource_action_route("plans", "plan_id", "requirements")
)
2025-10-30 21:08:07 +01:00
async def get_plan_requirements(
plan_id: str,
2025-11-05 13:34:56 +01:00
tenant_id: str = Path(..., description="Tenant ID"),
2025-10-30 21:08:07 +01:00
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))