Improve AI logic
This commit is contained in:
532
services/procurement/app/api/ml_insights.py
Normal file
532
services/procurement/app/api/ml_insights.py
Normal file
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
ML Insights API Endpoints for Procurement Service
|
||||
|
||||
Provides endpoints to trigger ML insight generation for:
|
||||
- Supplier performance analysis
|
||||
- Price forecasting and timing recommendations
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
from datetime import datetime, timedelta
|
||||
import structlog
|
||||
import pandas as pd
|
||||
|
||||
from app.core.database import get_db
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/tenants/{tenant_id}/procurement/ml/insights",
|
||||
tags=["ML Insights"]
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS - SUPPLIER ANALYSIS
|
||||
# ================================================================
|
||||
|
||||
class SupplierAnalysisRequest(BaseModel):
|
||||
"""Request schema for supplier performance analysis"""
|
||||
supplier_ids: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Specific supplier IDs to analyze. If None, analyzes all suppliers"
|
||||
)
|
||||
lookback_days: int = Field(
|
||||
180,
|
||||
description="Days of historical orders to analyze",
|
||||
ge=30,
|
||||
le=730
|
||||
)
|
||||
min_orders: int = Field(
|
||||
10,
|
||||
description="Minimum orders required for analysis",
|
||||
ge=5,
|
||||
le=100
|
||||
)
|
||||
|
||||
|
||||
class SupplierAnalysisResponse(BaseModel):
|
||||
"""Response schema for supplier performance analysis"""
|
||||
success: bool
|
||||
message: str
|
||||
tenant_id: str
|
||||
suppliers_analyzed: int
|
||||
total_insights_generated: int
|
||||
total_insights_posted: int
|
||||
high_risk_suppliers: int
|
||||
insights_by_supplier: dict
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# ================================================================
|
||||
# REQUEST/RESPONSE SCHEMAS - PRICE FORECASTING
|
||||
# ================================================================
|
||||
|
||||
class PriceForecastRequest(BaseModel):
|
||||
"""Request schema for price forecasting"""
|
||||
ingredient_ids: Optional[List[str]] = Field(
|
||||
None,
|
||||
description="Specific ingredient IDs to forecast. If None, forecasts all ingredients"
|
||||
)
|
||||
lookback_days: int = Field(
|
||||
180,
|
||||
description="Days of historical price data to analyze",
|
||||
ge=90,
|
||||
le=730
|
||||
)
|
||||
forecast_horizon_days: int = Field(
|
||||
30,
|
||||
description="Days to forecast ahead",
|
||||
ge=7,
|
||||
le=90
|
||||
)
|
||||
|
||||
|
||||
class PriceForecastResponse(BaseModel):
|
||||
"""Response schema for price forecasting"""
|
||||
success: bool
|
||||
message: str
|
||||
tenant_id: str
|
||||
ingredients_forecasted: int
|
||||
total_insights_generated: int
|
||||
total_insights_posted: int
|
||||
buy_now_recommendations: int
|
||||
bulk_opportunities: int
|
||||
insights_by_ingredient: dict
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
# ================================================================
|
||||
# API ENDPOINTS - SUPPLIER ANALYSIS
|
||||
# ================================================================
|
||||
|
||||
@router.post("/analyze-suppliers", response_model=SupplierAnalysisResponse)
|
||||
async def trigger_supplier_analysis(
|
||||
tenant_id: str,
|
||||
request_data: SupplierAnalysisRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Trigger supplier performance analysis.
|
||||
|
||||
This endpoint:
|
||||
1. Fetches historical purchase order data for specified suppliers
|
||||
2. Runs the SupplierInsightsOrchestrator to analyze reliability
|
||||
3. Generates insights about supplier performance and risk
|
||||
4. Posts insights to AI Insights Service
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
request_data: Analysis parameters
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
SupplierAnalysisResponse with analysis results
|
||||
"""
|
||||
logger.info(
|
||||
"ML insights supplier analysis requested",
|
||||
tenant_id=tenant_id,
|
||||
supplier_ids=request_data.supplier_ids,
|
||||
lookback_days=request_data.lookback_days
|
||||
)
|
||||
|
||||
try:
|
||||
# Import ML orchestrator and clients
|
||||
from app.ml.supplier_insights_orchestrator import SupplierInsightsOrchestrator
|
||||
from app.models.purchase_order import PurchaseOrder
|
||||
from shared.clients.suppliers_client import SuppliersServiceClient
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select
|
||||
|
||||
# Initialize orchestrator and clients
|
||||
orchestrator = SupplierInsightsOrchestrator()
|
||||
suppliers_client = SuppliersServiceClient(settings)
|
||||
|
||||
# Get suppliers to analyze from suppliers service via API
|
||||
if request_data.supplier_ids:
|
||||
# Fetch specific suppliers
|
||||
suppliers = []
|
||||
for supplier_id in request_data.supplier_ids:
|
||||
supplier = await suppliers_client.get_supplier_by_id(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id
|
||||
)
|
||||
if supplier:
|
||||
suppliers.append(supplier)
|
||||
else:
|
||||
# Fetch all active suppliers (limit to 10)
|
||||
all_suppliers = await suppliers_client.get_all_suppliers(
|
||||
tenant_id=tenant_id,
|
||||
is_active=True
|
||||
)
|
||||
suppliers = (all_suppliers or [])[:10] # Limit to prevent timeout
|
||||
|
||||
if not suppliers:
|
||||
return SupplierAnalysisResponse(
|
||||
success=False,
|
||||
message="No suppliers found for analysis",
|
||||
tenant_id=tenant_id,
|
||||
suppliers_analyzed=0,
|
||||
total_insights_generated=0,
|
||||
total_insights_posted=0,
|
||||
high_risk_suppliers=0,
|
||||
insights_by_supplier={},
|
||||
errors=["No suppliers found"]
|
||||
)
|
||||
|
||||
# Calculate date range for order history
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=request_data.lookback_days)
|
||||
|
||||
# Process each supplier
|
||||
total_insights_generated = 0
|
||||
total_insights_posted = 0
|
||||
high_risk_suppliers = 0
|
||||
insights_by_supplier = {}
|
||||
errors = []
|
||||
|
||||
for supplier in suppliers:
|
||||
try:
|
||||
supplier_id = str(supplier['id'])
|
||||
supplier_name = supplier.get('name', 'Unknown')
|
||||
logger.info(f"Analyzing supplier {supplier_name} ({supplier_id})")
|
||||
|
||||
# Get purchase orders for this supplier from local database
|
||||
po_query = select(PurchaseOrder).where(
|
||||
PurchaseOrder.tenant_id == UUID(tenant_id),
|
||||
PurchaseOrder.supplier_id == UUID(supplier_id),
|
||||
PurchaseOrder.order_date >= start_date,
|
||||
PurchaseOrder.order_date <= end_date
|
||||
)
|
||||
|
||||
po_result = await db.execute(po_query)
|
||||
purchase_orders = po_result.scalars().all()
|
||||
|
||||
if len(purchase_orders) < request_data.min_orders:
|
||||
logger.warning(
|
||||
f"Insufficient orders for supplier {supplier_id}: "
|
||||
f"{len(purchase_orders)} < {request_data.min_orders} required"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create order history DataFrame
|
||||
order_data = []
|
||||
for po in purchase_orders:
|
||||
# Calculate delivery performance
|
||||
if po.delivery_date and po.expected_delivery_date:
|
||||
days_late = (po.delivery_date - po.expected_delivery_date).days
|
||||
on_time = days_late <= 0
|
||||
else:
|
||||
days_late = 0
|
||||
on_time = True
|
||||
|
||||
# Calculate quality score (based on status)
|
||||
quality_score = 100 if po.status == 'completed' else 80
|
||||
|
||||
order_data.append({
|
||||
'order_date': po.order_date,
|
||||
'expected_delivery_date': po.expected_delivery_date,
|
||||
'delivery_date': po.delivery_date,
|
||||
'days_late': days_late,
|
||||
'on_time': on_time,
|
||||
'quality_score': quality_score,
|
||||
'total_amount': float(po.total_amount) if po.total_amount else 0
|
||||
})
|
||||
|
||||
order_history = pd.DataFrame(order_data)
|
||||
|
||||
# Run supplier analysis
|
||||
results = await orchestrator.analyze_and_post_supplier_insights(
|
||||
tenant_id=tenant_id,
|
||||
supplier_id=supplier_id,
|
||||
order_history=order_history,
|
||||
min_orders=request_data.min_orders
|
||||
)
|
||||
|
||||
# Track results
|
||||
total_insights_generated += results['insights_generated']
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
reliability_score = results.get('reliability_score', 100)
|
||||
if reliability_score < 70:
|
||||
high_risk_suppliers += 1
|
||||
|
||||
insights_by_supplier[supplier_id] = {
|
||||
'supplier_name': supplier_name,
|
||||
'insights_posted': results['insights_posted'],
|
||||
'reliability_score': reliability_score,
|
||||
'orders_analyzed': results['orders_analyzed']
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Supplier {supplier_id} analysis complete",
|
||||
insights_posted=results['insights_posted'],
|
||||
reliability_score=reliability_score
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error analyzing supplier {supplier_id}: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Close orchestrator
|
||||
await orchestrator.close()
|
||||
|
||||
# Build response
|
||||
response = SupplierAnalysisResponse(
|
||||
success=total_insights_posted > 0,
|
||||
message=f"Successfully analyzed {len(insights_by_supplier)} suppliers, generated {total_insights_posted} insights",
|
||||
tenant_id=tenant_id,
|
||||
suppliers_analyzed=len(insights_by_supplier),
|
||||
total_insights_generated=total_insights_generated,
|
||||
total_insights_posted=total_insights_posted,
|
||||
high_risk_suppliers=high_risk_suppliers,
|
||||
insights_by_supplier=insights_by_supplier,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ML insights supplier analysis complete",
|
||||
tenant_id=tenant_id,
|
||||
total_insights=total_insights_posted,
|
||||
high_risk_suppliers=high_risk_suppliers
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"ML insights supplier analysis failed",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Supplier analysis failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# API ENDPOINTS - PRICE FORECASTING
|
||||
# ================================================================
|
||||
|
||||
@router.post("/forecast-prices", response_model=PriceForecastResponse)
|
||||
async def trigger_price_forecasting(
|
||||
tenant_id: str,
|
||||
request_data: PriceForecastRequest,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Trigger price forecasting for procurement ingredients.
|
||||
|
||||
This endpoint:
|
||||
1. Fetches historical price data for specified ingredients
|
||||
2. Runs the PriceInsightsOrchestrator to forecast future prices
|
||||
3. Generates insights about optimal purchase timing
|
||||
4. Posts insights to AI Insights Service
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
request_data: Forecasting parameters
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
PriceForecastResponse with forecasting results
|
||||
"""
|
||||
logger.info(
|
||||
"ML insights price forecasting requested",
|
||||
tenant_id=tenant_id,
|
||||
ingredient_ids=request_data.ingredient_ids,
|
||||
lookback_days=request_data.lookback_days
|
||||
)
|
||||
|
||||
try:
|
||||
# Import ML orchestrator and clients
|
||||
from app.ml.price_insights_orchestrator import PriceInsightsOrchestrator
|
||||
from shared.clients.inventory_client import InventoryServiceClient
|
||||
from app.models.purchase_order import PurchaseOrderItem
|
||||
from app.core.config import settings
|
||||
from sqlalchemy import select
|
||||
|
||||
# Initialize orchestrator and inventory client
|
||||
orchestrator = PriceInsightsOrchestrator()
|
||||
inventory_client = InventoryServiceClient(settings)
|
||||
|
||||
# Get ingredients to forecast from inventory service via API
|
||||
if request_data.ingredient_ids:
|
||||
# Fetch specific ingredients
|
||||
ingredients = []
|
||||
for ingredient_id in request_data.ingredient_ids:
|
||||
ingredient = await inventory_client.get_ingredient_by_id(
|
||||
ingredient_id=ingredient_id,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
if ingredient:
|
||||
ingredients.append(ingredient)
|
||||
else:
|
||||
# Fetch all ingredients for tenant (limit to 10)
|
||||
all_ingredients = await inventory_client.get_all_ingredients(tenant_id=tenant_id)
|
||||
ingredients = all_ingredients[:10] if all_ingredients else [] # Limit to prevent timeout
|
||||
|
||||
if not ingredients:
|
||||
return PriceForecastResponse(
|
||||
success=False,
|
||||
message="No ingredients found for forecasting",
|
||||
tenant_id=tenant_id,
|
||||
ingredients_forecasted=0,
|
||||
total_insights_generated=0,
|
||||
total_insights_posted=0,
|
||||
buy_now_recommendations=0,
|
||||
bulk_opportunities=0,
|
||||
insights_by_ingredient={},
|
||||
errors=["No ingredients found"]
|
||||
)
|
||||
|
||||
# Calculate date range for price history
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=request_data.lookback_days)
|
||||
|
||||
# Process each ingredient
|
||||
total_insights_generated = 0
|
||||
total_insights_posted = 0
|
||||
buy_now_recommendations = 0
|
||||
bulk_opportunities = 0
|
||||
insights_by_ingredient = {}
|
||||
errors = []
|
||||
|
||||
for ingredient in ingredients:
|
||||
try:
|
||||
ingredient_id = str(ingredient['id'])
|
||||
ingredient_name = ingredient.get('name', 'Unknown Ingredient')
|
||||
logger.info(f"Forecasting prices for {ingredient_name} ({ingredient_id})")
|
||||
|
||||
# Get price history from purchase order items
|
||||
poi_query = select(PurchaseOrderItem).where(
|
||||
PurchaseOrderItem.ingredient_id == UUID(ingredient_id)
|
||||
).join(
|
||||
PurchaseOrderItem.purchase_order
|
||||
).where(
|
||||
PurchaseOrderItem.purchase_order.has(
|
||||
tenant_id=UUID(tenant_id)
|
||||
)
|
||||
)
|
||||
|
||||
poi_result = await db.execute(poi_query)
|
||||
purchase_items = poi_result.scalars().all()
|
||||
|
||||
if len(purchase_items) < 30:
|
||||
logger.warning(
|
||||
f"Insufficient price history for ingredient {ingredient_id}: "
|
||||
f"{len(purchase_items)} items"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create price history DataFrame
|
||||
price_data = []
|
||||
for item in purchase_items:
|
||||
if item.unit_price and item.quantity:
|
||||
price_data.append({
|
||||
'date': item.purchase_order.order_date,
|
||||
'price': float(item.unit_price),
|
||||
'quantity': float(item.quantity),
|
||||
'supplier_id': str(item.purchase_order.supplier_id)
|
||||
})
|
||||
|
||||
price_history = pd.DataFrame(price_data)
|
||||
price_history = price_history.sort_values('date')
|
||||
|
||||
# Run price forecasting
|
||||
results = await orchestrator.forecast_and_post_insights(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=ingredient_id,
|
||||
price_history=price_history,
|
||||
forecast_horizon_days=request_data.forecast_horizon_days,
|
||||
min_history_days=request_data.lookback_days
|
||||
)
|
||||
|
||||
# Track results
|
||||
total_insights_generated += results['insights_generated']
|
||||
total_insights_posted += results['insights_posted']
|
||||
|
||||
recommendation = results.get('recommendation', {})
|
||||
if recommendation.get('action') == 'buy_now':
|
||||
buy_now_recommendations += 1
|
||||
|
||||
bulk_opp = results.get('bulk_opportunity', {})
|
||||
if bulk_opp.get('has_bulk_opportunity'):
|
||||
bulk_opportunities += 1
|
||||
|
||||
insights_by_ingredient[ingredient_id] = {
|
||||
'ingredient_name': ingredient_name,
|
||||
'insights_posted': results['insights_posted'],
|
||||
'recommendation': recommendation.get('action'),
|
||||
'has_bulk_opportunity': bulk_opp.get('has_bulk_opportunity', False)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Ingredient {ingredient_id} forecasting complete",
|
||||
insights_posted=results['insights_posted'],
|
||||
recommendation=recommendation.get('action')
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error forecasting ingredient {ingredient_id}: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
errors.append(error_msg)
|
||||
|
||||
# Close orchestrator
|
||||
await orchestrator.close()
|
||||
|
||||
# Build response
|
||||
response = PriceForecastResponse(
|
||||
success=total_insights_posted > 0,
|
||||
message=f"Successfully forecasted {len(insights_by_ingredient)} ingredients, generated {total_insights_posted} insights",
|
||||
tenant_id=tenant_id,
|
||||
ingredients_forecasted=len(insights_by_ingredient),
|
||||
total_insights_generated=total_insights_generated,
|
||||
total_insights_posted=total_insights_posted,
|
||||
buy_now_recommendations=buy_now_recommendations,
|
||||
bulk_opportunities=bulk_opportunities,
|
||||
insights_by_ingredient=insights_by_ingredient,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"ML insights price forecasting complete",
|
||||
tenant_id=tenant_id,
|
||||
total_insights=total_insights_posted,
|
||||
buy_now_recommendations=buy_now_recommendations,
|
||||
bulk_opportunities=bulk_opportunities
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"ML insights price forecasting failed",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Price forecasting failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def ml_insights_health():
|
||||
"""Health check for ML insights endpoints"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "procurement-ml-insights",
|
||||
"endpoints": [
|
||||
"POST /ml/insights/analyze-suppliers",
|
||||
"POST /ml/insights/forecast-prices"
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ 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 fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -22,11 +22,14 @@ from app.schemas.procurement_schemas import (
|
||||
AutoGenerateProcurementResponse,
|
||||
PaginatedProcurementPlans,
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/procurement", tags=["Procurement Plans"])
|
||||
# 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:
|
||||
@@ -38,10 +41,13 @@ def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementSe
|
||||
# ORCHESTRATOR ENTRY POINT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/auto-generate", response_model=AutoGenerateProcurementResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("auto-generate"),
|
||||
response_model=AutoGenerateProcurementResponse
|
||||
)
|
||||
async def auto_generate_procurement(
|
||||
tenant_id: str,
|
||||
request_data: AutoGenerateProcurementRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
@@ -82,10 +88,13 @@ async def auto_generate_procurement(
|
||||
# MANUAL PROCUREMENT PLAN GENERATION
|
||||
# ================================================================
|
||||
|
||||
@router.post("/plans/generate", response_model=GeneratePlanResponse)
|
||||
@router.post(
|
||||
route_builder.build_base_route("plans"),
|
||||
response_model=GeneratePlanResponse
|
||||
)
|
||||
async def generate_procurement_plan(
|
||||
tenant_id: str,
|
||||
request_data: GeneratePlanRequest,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""
|
||||
@@ -122,9 +131,12 @@ async def generate_procurement_plan(
|
||||
# PROCUREMENT PLAN CRUD
|
||||
# ================================================================
|
||||
|
||||
@router.get("/plans/current", response_model=Optional[ProcurementPlanResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("plans/current"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
async def get_current_plan(
|
||||
tenant_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get the current day's procurement plan"""
|
||||
@@ -137,10 +149,13 @@ async def get_current_plan(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans/{plan_id}", response_model=ProcurementPlanResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("plans", "plan_id"),
|
||||
response_model=ProcurementPlanResponse
|
||||
)
|
||||
async def get_plan_by_id(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement plan by ID"""
|
||||
@@ -159,10 +174,13 @@ async def get_plan_by_id(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans/date/{plan_date}", response_model=Optional[ProcurementPlanResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("plans/date/{plan_date}"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
async def get_plan_by_date(
|
||||
tenant_id: str,
|
||||
plan_date: date,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""Get procurement plan for a specific date"""
|
||||
@@ -175,9 +193,12 @@ async def get_plan_by_date(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/plans", response_model=PaginatedProcurementPlans)
|
||||
@router.get(
|
||||
route_builder.build_base_route("plans"),
|
||||
response_model=PaginatedProcurementPlans
|
||||
)
|
||||
async def list_procurement_plans(
|
||||
tenant_id: str,
|
||||
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),
|
||||
@@ -206,11 +227,13 @@ async def list_procurement_plans(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/plans/{plan_id}/status")
|
||||
@router.patch(
|
||||
route_builder.build_resource_action_route("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)$"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
notes: Optional[str] = None,
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
@@ -235,11 +258,13 @@ async def update_plan_status(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/plans/{plan_id}/create-purchase-orders")
|
||||
@router.post(
|
||||
route_builder.build_resource_action_route("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"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service)
|
||||
):
|
||||
"""
|
||||
@@ -279,10 +304,12 @@ async def create_purchase_orders_from_plan(
|
||||
# TESTING AND UTILITIES
|
||||
# ================================================================
|
||||
|
||||
@router.get("/plans/{plan_id}/requirements")
|
||||
@router.get(
|
||||
route_builder.build_resource_action_route("plans", "plan_id", "requirements")
|
||||
)
|
||||
async def get_plan_requirements(
|
||||
tenant_id: str,
|
||||
plan_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: ProcurementService = Depends(get_procurement_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
|
||||
@@ -7,7 +7,7 @@ Purchase Orders API - Endpoints for purchase order management
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
@@ -24,11 +24,14 @@ from app.schemas.purchase_order_schemas import (
|
||||
SupplierInvoiceCreate,
|
||||
SupplierInvoiceResponse,
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tenants/{tenant_id}/purchase-orders", tags=["Purchase Orders"])
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(tags=["purchase-orders"])
|
||||
|
||||
|
||||
def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService:
|
||||
@@ -40,10 +43,14 @@ def get_po_service(db: AsyncSession = Depends(get_db)) -> PurchaseOrderService:
|
||||
# PURCHASE ORDER CRUD
|
||||
# ================================================================
|
||||
|
||||
@router.post("", response_model=PurchaseOrderResponse, status_code=201)
|
||||
@router.post(
|
||||
route_builder.build_base_route("purchase-orders"),
|
||||
response_model=PurchaseOrderResponse,
|
||||
status_code=201
|
||||
)
|
||||
async def create_purchase_order(
|
||||
tenant_id: str,
|
||||
po_data: PurchaseOrderCreate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -76,10 +83,13 @@ async def create_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{po_id}", response_model=PurchaseOrderWithSupplierResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("purchase-orders", "po_id"),
|
||||
response_model=PurchaseOrderWithSupplierResponse
|
||||
)
|
||||
async def get_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""Get purchase order by ID with items"""
|
||||
@@ -101,9 +111,12 @@ async def get_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[PurchaseOrderResponse])
|
||||
@router.get(
|
||||
route_builder.build_base_route("purchase-orders"),
|
||||
response_model=List[PurchaseOrderResponse]
|
||||
)
|
||||
async def list_purchase_orders(
|
||||
tenant_id: str,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
supplier_id: Optional[str] = Query(default=None),
|
||||
@@ -139,11 +152,14 @@ async def list_purchase_orders(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{po_id}", response_model=PurchaseOrderResponse)
|
||||
@router.patch(
|
||||
route_builder.build_resource_detail_route("purchase-orders", "po_id"),
|
||||
response_model=PurchaseOrderResponse
|
||||
)
|
||||
async def update_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
po_data: PurchaseOrderUpdate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -181,11 +197,13 @@ async def update_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{po_id}/status")
|
||||
@router.patch(
|
||||
route_builder.build_resource_action_route("purchase-orders", "po_id", "status")
|
||||
)
|
||||
async def update_order_status(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
status: str = Query(..., description="New status"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
notes: Optional[str] = Query(default=None),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
@@ -239,11 +257,14 @@ async def update_order_status(
|
||||
# APPROVAL WORKFLOW
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/approve", response_model=PurchaseOrderResponse)
|
||||
@router.post(
|
||||
route_builder.build_resource_action_route("purchase-orders", "po_id", "approve"),
|
||||
response_model=PurchaseOrderResponse
|
||||
)
|
||||
async def approve_purchase_order(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
approval_data: PurchaseOrderApproval,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -289,12 +310,15 @@ async def approve_purchase_order(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{po_id}/cancel", response_model=PurchaseOrderResponse)
|
||||
@router.post(
|
||||
route_builder.build_resource_action_route("purchase-orders", "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),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -335,11 +359,15 @@ async def cancel_purchase_order(
|
||||
# DELIVERY MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/deliveries", response_model=DeliveryResponse, status_code=201)
|
||||
@router.post(
|
||||
route_builder.build_nested_resource_route("purchase-orders", "po_id", "deliveries"),
|
||||
response_model=DeliveryResponse,
|
||||
status_code=201
|
||||
)
|
||||
async def create_delivery(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
delivery_data: DeliveryCreate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -375,11 +403,14 @@ async def create_delivery(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/deliveries/{delivery_id}/status")
|
||||
@router.patch(
|
||||
route_builder.build_nested_resource_route("purchase-orders", "po_id", "deliveries") + "/{delivery_id}/status"
|
||||
)
|
||||
async def update_delivery_status(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
delivery_id: str,
|
||||
status: str = Query(..., description="New delivery status"),
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
@@ -421,11 +452,15 @@ async def update_delivery_status(
|
||||
# INVOICE MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
@router.post("/{po_id}/invoices", response_model=SupplierInvoiceResponse, status_code=201)
|
||||
@router.post(
|
||||
route_builder.build_nested_resource_route("purchase-orders", "po_id", "invoices"),
|
||||
response_model=SupplierInvoiceResponse,
|
||||
status_code=201
|
||||
)
|
||||
async def create_invoice(
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
invoice_data: SupplierInvoiceCreate,
|
||||
tenant_id: str = Path(..., description="Tenant ID"),
|
||||
service: PurchaseOrderService = Depends(get_po_service)
|
||||
):
|
||||
"""
|
||||
|
||||
@@ -38,18 +38,24 @@ 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
|
||||
from shared.routing import RouteBuilder
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/replenishment-plans", tags=["Replenishment Planning"])
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('procurement')
|
||||
router = APIRouter(tags=["replenishment-planning"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Replenishment Plan Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/generate", response_model=GenerateReplenishmentPlanResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/generate"),
|
||||
response_model=GenerateReplenishmentPlanResponse
|
||||
)
|
||||
async def generate_replenishment_plan(
|
||||
request: GenerateReplenishmentPlanRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
@@ -91,7 +97,10 @@ async def generate_replenishment_plan(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("", response_model=List[ReplenishmentPlanSummary])
|
||||
@router.get(
|
||||
route_builder.build_operations_route("replenishment-plans"),
|
||||
response_model=List[ReplenishmentPlanSummary]
|
||||
)
|
||||
async def list_replenishment_plans(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
skip: int = Query(0, ge=0),
|
||||
@@ -123,7 +132,10 @@ async def list_replenishment_plans(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=ReplenishmentPlanResponse)
|
||||
@router.get(
|
||||
route_builder.build_resource_detail_route("replenishment-plans", "plan_id"),
|
||||
response_model=ReplenishmentPlanResponse
|
||||
)
|
||||
async def get_replenishment_plan(
|
||||
plan_id: UUID = Path(...),
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
@@ -155,7 +167,10 @@ async def get_replenishment_plan(
|
||||
# Inventory Projection Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/inventory-projections/project", response_model=ProjectInventoryResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/inventory-projections/project"),
|
||||
response_model=ProjectInventoryResponse
|
||||
)
|
||||
async def project_inventory(
|
||||
request: ProjectInventoryRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -212,7 +227,10 @@ async def project_inventory(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/inventory-projections", response_model=List[InventoryProjectionResponse])
|
||||
@router.get(
|
||||
route_builder.build_operations_route("replenishment-plans/inventory-projections"),
|
||||
response_model=List[InventoryProjectionResponse]
|
||||
)
|
||||
async def list_inventory_projections(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
ingredient_id: Optional[UUID] = None,
|
||||
@@ -250,7 +268,10 @@ async def list_inventory_projections(
|
||||
# Safety Stock Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/safety-stock/calculate", response_model=SafetyStockResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/safety-stock/calculate"),
|
||||
response_model=SafetyStockResponse
|
||||
)
|
||||
async def calculate_safety_stock(
|
||||
request: SafetyStockRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -282,7 +303,10 @@ async def calculate_safety_stock(
|
||||
# Supplier Selection Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/supplier-selections/evaluate", response_model=SupplierSelectionResult)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/supplier-selections/evaluate"),
|
||||
response_model=SupplierSelectionResult
|
||||
)
|
||||
async def evaluate_supplier_selection(
|
||||
request: SupplierSelectionRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -317,7 +341,10 @@ async def evaluate_supplier_selection(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/supplier-allocations", response_model=List[SupplierAllocationResponse])
|
||||
@router.get(
|
||||
route_builder.build_operations_route("replenishment-plans/supplier-allocations"),
|
||||
response_model=List[SupplierAllocationResponse]
|
||||
)
|
||||
async def list_supplier_allocations(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
requirement_id: Optional[UUID] = None,
|
||||
@@ -353,7 +380,10 @@ async def list_supplier_allocations(
|
||||
# MOQ Aggregation Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.post("/moq-aggregation/aggregate", response_model=MOQAggregationResponse)
|
||||
@router.post(
|
||||
route_builder.build_operations_route("replenishment-plans/moq-aggregation/aggregate"),
|
||||
response_model=MOQAggregationResponse
|
||||
)
|
||||
async def aggregate_for_moq(
|
||||
request: MOQAggregationRequest,
|
||||
tenant_id: UUID = Depends(get_current_tenant_id)
|
||||
@@ -402,7 +432,10 @@ async def aggregate_for_moq(
|
||||
# Analytics Endpoints
|
||||
# ============================================================
|
||||
|
||||
@router.get("/analytics", response_model=ReplenishmentAnalytics)
|
||||
@router.get(
|
||||
route_builder.build_analytics_route("replenishment-plans"),
|
||||
response_model=ReplenishmentAnalytics
|
||||
)
|
||||
async def get_replenishment_analytics(
|
||||
tenant_id: UUID = Depends(get_current_tenant_id),
|
||||
start_date: Optional[date] = None,
|
||||
|
||||
Reference in New Issue
Block a user