Files
bakery-ia/services/orders/app/api/procurement_operations.py

850 lines
29 KiB
Python
Raw Normal View History

2025-08-23 19:47:08 +02:00
# ================================================================
2025-10-06 15:27:01 +02:00
# services/orders/app/api/procurement_operations.py
2025-08-23 19:47:08 +02:00
# ================================================================
"""
2025-10-06 15:27:01 +02:00
Procurement Operations API Endpoints - BUSINESS logic for procurement planning
RESTful APIs for procurement planning, approval workflows, and PO management
2025-08-23 19:47:08 +02:00
"""
import uuid
from datetime import date
from typing import List, Optional
2025-09-22 16:10:08 +02:00
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
2025-08-23 19:47:08 +02:00
from sqlalchemy.ext.asyncio import AsyncSession
2025-09-22 16:10:08 +02:00
import structlog
logger = structlog.get_logger()
2025-08-23 19:47:08 +02:00
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,
DashboardData, PaginatedProcurementPlans
)
from shared.auth.decorators import require_authentication, get_current_user_dep
from fastapi import Depends, Request
from typing import Dict, Any
import uuid
from shared.clients.inventory_client import InventoryServiceClient
from shared.clients.forecast_client import ForecastServiceClient
from shared.config.base import BaseServiceSettings
from shared.monitoring.decorators import monitor_performance
2025-10-06 15:27:01 +02:00
from shared.routing import RouteBuilder
from shared.auth.access_control import (
require_user_role,
admin_role_required,
owner_role_required,
require_subscription_tier,
analytics_tier_required,
enterprise_tier_required
)
# Create route builder for consistent URL structure
route_builder = RouteBuilder('orders')
2025-08-23 19:47:08 +02:00
2025-10-06 15:27:01 +02:00
# Create router
router = APIRouter(tags=["Procurement Planning"])
2025-08-23 19:47:08 +02:00
# Create service settings
service_settings = BaseServiceSettings()
# Simple TenantAccess class for compatibility
class TenantAccess:
def __init__(self, tenant_id: uuid.UUID, user_id: str):
self.tenant_id = tenant_id
self.user_id = user_id
async def get_current_tenant(
request: Request,
current_user: Dict[str, Any] = Depends(get_current_user_dep)
) -> TenantAccess:
"""Get current tenant from user context"""
# For now, create a simple tenant access from user data
# In a real implementation, this would validate tenant access
tenant_id = current_user.get('tenant_id')
if not tenant_id:
# Try to get from headers as fallback
tenant_id = request.headers.get('x-tenant-id')
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Tenant access required"
)
try:
tenant_uuid = uuid.UUID(tenant_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid tenant ID format"
)
return TenantAccess(
tenant_id=tenant_uuid,
user_id=current_user['user_id']
)
async def get_procurement_service(db: AsyncSession = Depends(get_db)) -> ProcurementService:
"""Get procurement service instance with all required clients"""
from shared.clients.suppliers_client import SuppliersServiceClient
2025-08-23 19:47:08 +02:00
inventory_client = InventoryServiceClient(service_settings)
forecast_client = ForecastServiceClient(service_settings, "orders-service")
suppliers_client = SuppliersServiceClient(service_settings)
return ProcurementService(db, service_settings, inventory_client, forecast_client, suppliers_client)
2025-08-23 19:47:08 +02:00
# ================================================================
# PROCUREMENT PLAN ENDPOINTS
# ================================================================
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_operations_route("procurement/plans/current"),
response_model=Optional[ProcurementPlanResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("get_current_procurement_plan")
async def get_current_procurement_plan(
tenant_id: uuid.UUID,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get the procurement plan for the current day (forecasting for the next day)
Returns the plan details, including requirements per item.
"""
try:
plan = await procurement_service.get_current_plan(tenant_access.tenant_id)
return plan
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving current procurement plan: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_operations_route("procurement/plans/date/{plan_date}"),
response_model=Optional[ProcurementPlanResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("get_procurement_plan_by_date")
async def get_procurement_plan_by_date(
tenant_id: uuid.UUID,
2025-08-23 19:47:08 +02:00
plan_date: date,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get the procurement plan for a specific date (format: YYYY-MM-DD)
Returns the plan details, including requirements per item for the specified date.
"""
try:
plan = await procurement_service.get_plan_by_date(tenant_access.tenant_id, plan_date)
return plan
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving procurement plan for {plan_date}: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_operations_route("procurement/plans"),
response_model=PaginatedProcurementPlans
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("list_procurement_plans")
async def list_procurement_plans(
tenant_id: uuid.UUID,
2025-09-22 16:10:08 +02:00
plan_status: Optional[str] = Query(None, description="Filter by plan status"),
2025-08-23 19:47:08 +02:00
start_date: Optional[date] = Query(None, description="Start date filter (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="End date filter (YYYY-MM-DD)"),
limit: int = Query(50, ge=1, le=100, description="Number of plans to return"),
offset: int = Query(0, ge=0, description="Number of plans to skip"),
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
List procurement plans with optional filters
Supports filtering by status, date range, and pagination.
"""
try:
# Get plans from repository directly for listing
plans = await procurement_service.plan_repo.list_plans(
2025-09-22 16:10:08 +02:00
tenant_id,
status=plan_status,
2025-08-23 19:47:08 +02:00
start_date=start_date,
end_date=end_date,
limit=limit,
offset=offset
)
# Convert to response models
2025-09-22 16:10:08 +02:00
plan_responses = []
for plan in plans:
try:
plan_response = ProcurementPlanResponse.model_validate(plan)
plan_responses.append(plan_response)
except Exception as validation_error:
logger.error(f"Error validating plan {plan.id}: {validation_error}")
raise
2025-08-23 19:47:08 +02:00
# For simplicity, we'll use the returned count as total
# In a production system, you'd want a separate count query
total = len(plan_responses)
has_more = len(plan_responses) == limit
return PaginatedProcurementPlans(
plans=plan_responses,
total=total,
page=offset // limit + 1 if limit > 0 else 1,
limit=limit,
has_more=has_more
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error listing procurement plans: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("procurement/plans/generate"),
response_model=GeneratePlanResponse
)
@require_user_role(['member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("generate_procurement_plan")
async def generate_procurement_plan(
tenant_id: uuid.UUID,
2025-08-23 19:47:08 +02:00
request: GeneratePlanRequest,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Manually trigger the generation of a procurement plan
This can serve as a fallback if the daily scheduler hasn't run,
or for testing purposes. Can be forced to regenerate an existing plan.
"""
try:
if not settings.PROCUREMENT_PLANNING_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Procurement planning is currently disabled"
)
result = await procurement_service.generate_procurement_plan(
tenant_access.tenant_id,
request
)
if not result.success:
# Return the result with errors but don't raise an exception
# since the service method handles the error state properly
return result
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error generating procurement plan: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.put(
route_builder.build_operations_route("procurement/plans/{plan_id}/status")
)
@require_user_role(['admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("update_procurement_plan_status")
async def update_procurement_plan_status(
tenant_id: uuid.UUID,
2025-08-23 19:47:08 +02:00
plan_id: uuid.UUID,
status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Update the status of a procurement plan
Valid statuses: draft, pending_approval, approved, in_execution, completed, cancelled
"""
try:
updated_plan = await procurement_service.update_plan_status(
tenant_access.tenant_id,
plan_id,
status,
tenant_access.user_id
)
if not updated_plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Procurement plan not found"
)
return updated_plan
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating procurement plan status: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_operations_route("procurement/plans/id/{plan_id}"),
response_model=Optional[ProcurementPlanResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("get_procurement_plan_by_id")
async def get_procurement_plan_by_id(
tenant_id: uuid.UUID,
2025-08-23 19:47:08 +02:00
plan_id: uuid.UUID,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get a specific procurement plan by its ID
Returns detailed plan information including all requirements.
"""
try:
plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Procurement plan not found"
)
return plan
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving procurement plan: {str(e)}"
)
# ================================================================
# DASHBOARD ENDPOINTS
# ================================================================
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_dashboard_route("procurement"),
response_model=Optional[DashboardData]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("get_procurement_dashboard")
async def get_procurement_dashboard(
tenant_id: uuid.UUID,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get procurement dashboard data
Returns comprehensive dashboard information including:
- Current plan
- Summary statistics
- Upcoming deliveries
- Overdue requirements
- Low stock alerts
- Performance metrics
"""
try:
dashboard_data = await procurement_service.get_dashboard_data(tenant_access.tenant_id)
return dashboard_data
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving dashboard data: {str(e)}"
)
# ================================================================
# REQUIREMENT MANAGEMENT ENDPOINTS
# ================================================================
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_operations_route("procurement/plans/{plan_id}/requirements")
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("get_plan_requirements")
async def get_plan_requirements(
tenant_id: uuid.UUID,
2025-08-23 19:47:08 +02:00
plan_id: uuid.UUID,
status: Optional[str] = Query(None, description="Filter by requirement status"),
priority: Optional[str] = Query(None, description="Filter by priority level"),
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get all requirements for a specific procurement plan
Supports filtering by status and priority level.
"""
try:
# Verify plan exists and belongs to tenant
plan = await procurement_service.get_plan_by_id(tenant_access.tenant_id, plan_id)
if not plan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Procurement plan not found"
)
# Get requirements from repository
requirements = await procurement_service.requirement_repo.get_requirements_by_plan(plan_id)
# Apply filters if provided
if status:
requirements = [r for r in requirements if r.status == status]
if priority:
requirements = [r for r in requirements if r.priority == priority]
return requirements
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving plan requirements: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_operations_route("procurement/requirements/critical")
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
2025-08-23 19:47:08 +02:00
@monitor_performance("get_critical_requirements")
async def get_critical_requirements(
tenant_id: uuid.UUID,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
2025-08-23 19:47:08 +02:00
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Get all critical priority requirements across all active plans
Returns requirements that need immediate attention.
"""
try:
requirements = await procurement_service.requirement_repo.get_critical_requirements(
tenant_access.tenant_id
)
return requirements
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error retrieving critical requirements: {str(e)}"
)
# ================================================================
# NEW FEATURE ENDPOINTS
# ================================================================
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/recalculate"),
response_model=GeneratePlanResponse
)
@require_user_role(['member', 'admin', 'owner'])
@monitor_performance("recalculate_procurement_plan")
async def recalculate_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Recalculate an existing procurement plan (Edge Case #3)
Useful when inventory has changed significantly after plan creation
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
result = await procurement_service.recalculate_plan(tenant_id, plan_id)
if not result.success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.message
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error recalculating procurement plan: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("procurement/requirements/{requirement_id}/link-purchase-order")
)
@require_user_role(['member', 'admin', 'owner'])
@monitor_performance("link_requirement_to_po")
async def link_requirement_to_purchase_order(
tenant_id: uuid.UUID,
requirement_id: uuid.UUID,
purchase_order_id: uuid.UUID,
purchase_order_number: str,
ordered_quantity: float,
expected_delivery_date: Optional[date] = None,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Link a procurement requirement to a purchase order (Bug #4 FIX, Feature #1)
Updates requirement status and tracks PO information
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
from decimal import Decimal
success = await procurement_service.link_requirement_to_purchase_order(
tenant_id=tenant_id,
requirement_id=requirement_id,
purchase_order_id=purchase_order_id,
purchase_order_number=purchase_order_number,
ordered_quantity=Decimal(str(ordered_quantity)),
expected_delivery_date=expected_delivery_date
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Requirement not found or unauthorized"
)
return {
"success": True,
"message": "Requirement linked to purchase order successfully",
"requirement_id": str(requirement_id),
"purchase_order_id": str(purchase_order_id)
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error linking requirement to PO: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.put(
route_builder.build_operations_route("procurement/requirements/{requirement_id}/delivery-status")
)
@require_user_role(['member', 'admin', 'owner'])
@monitor_performance("update_delivery_status")
async def update_requirement_delivery_status(
tenant_id: uuid.UUID,
requirement_id: uuid.UUID,
delivery_status: str,
received_quantity: Optional[float] = None,
actual_delivery_date: Optional[date] = None,
quality_rating: Optional[float] = None,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Update delivery status for a requirement (Feature #2)
Tracks received quantities, delivery dates, and quality ratings
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
from decimal import Decimal
success = await procurement_service.update_delivery_status(
tenant_id=tenant_id,
requirement_id=requirement_id,
delivery_status=delivery_status,
received_quantity=Decimal(str(received_quantity)) if received_quantity is not None else None,
actual_delivery_date=actual_delivery_date,
quality_rating=Decimal(str(quality_rating)) if quality_rating is not None else None
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Requirement not found or unauthorized"
)
return {
"success": True,
"message": "Delivery status updated successfully",
"requirement_id": str(requirement_id),
"delivery_status": delivery_status
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error updating delivery status: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/approve")
)
@require_user_role(['admin', 'owner'])
@monitor_performance("approve_procurement_plan")
async def approve_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
approval_notes: Optional[str] = None,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Approve a procurement plan (Edge Case #7: Enhanced approval workflow)
Includes approval notes and workflow tracking
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
try:
user_id = uuid.UUID(tenant_access.user_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)
result = await procurement_service.update_plan_status(
tenant_id=tenant_id,
plan_id=plan_id,
status="approved",
updated_by=user_id,
approval_notes=approval_notes
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Plan not found"
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error approving plan: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/reject")
)
@require_user_role(['admin', 'owner'])
@monitor_performance("reject_procurement_plan")
async def reject_procurement_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
rejection_notes: Optional[str] = None,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Reject a procurement plan (Edge Case #7: Enhanced approval workflow)
Marks plan as cancelled with rejection notes
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
try:
user_id = uuid.UUID(tenant_access.user_id)
except (ValueError, TypeError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid user ID"
)
result = await procurement_service.update_plan_status(
tenant_id=tenant_id,
plan_id=plan_id,
status="cancelled",
updated_by=user_id,
approval_notes=f"REJECTED: {rejection_notes}" if rejection_notes else "REJECTED"
)
if not result:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Plan not found"
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error rejecting plan: {str(e)}"
)
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("procurement/plans/{plan_id}/create-purchase-orders")
)
@require_user_role(['admin', 'owner'])
@monitor_performance("create_pos_from_plan")
async def create_purchase_orders_from_plan(
tenant_id: uuid.UUID,
plan_id: uuid.UUID,
auto_approve: bool = False,
2025-10-07 07:15:07 +02:00
current_user: Dict[str, Any] = Depends(get_current_user_dep),
tenant_access: TenantAccess = Depends(get_current_tenant),
procurement_service: ProcurementService = Depends(get_procurement_service)
):
"""
Automatically create purchase orders from procurement plan (Feature #1)
Groups requirements by supplier and creates POs automatically
"""
try:
if tenant_access.tenant_id != tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this tenant"
)
result = await procurement_service.create_purchase_orders_from_plan(
tenant_id=tenant_id,
plan_id=plan_id,
auto_approve=auto_approve
)
if not result.get('success'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.get('error', 'Failed to create purchase orders')
)
return result
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error creating purchase orders: {str(e)}"
)
2025-08-23 19:47:08 +02:00
# ================================================================
# UTILITY ENDPOINTS
# ================================================================
2025-10-06 15:27:01 +02:00
@router.post(
route_builder.build_operations_route("procurement/scheduler/trigger")
)
2025-08-23 19:47:08 +02:00
@monitor_performance("trigger_daily_scheduler")
async def trigger_daily_scheduler(
tenant_id: uuid.UUID,
2025-09-22 16:10:08 +02:00
request: Request
2025-08-23 19:47:08 +02:00
):
"""
Manually trigger the daily scheduler for the current tenant
2025-09-22 16:10:08 +02:00
2025-08-23 19:47:08 +02:00
This endpoint is primarily for testing and maintenance purposes.
2025-09-22 16:10:08 +02:00
Note: Authentication temporarily disabled for development testing.
2025-08-23 19:47:08 +02:00
"""
try:
2025-09-22 16:10:08 +02:00
# Get the scheduler service from app state and call process_tenant_procurement
if hasattr(request.app.state, 'scheduler_service'):
scheduler_service = request.app.state.scheduler_service
await scheduler_service.process_tenant_procurement(tenant_id)
return {
"success": True,
"message": "Daily scheduler executed successfully for tenant",
"tenant_id": str(tenant_id)
}
else:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Scheduler service is not available"
)
except HTTPException:
raise
2025-08-23 19:47:08 +02:00
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error triggering daily scheduler: {str(e)}"
)
2025-09-22 16:10:08 +02:00
2025-10-06 15:27:01 +02:00
@router.get(
route_builder.build_base_route("procurement/health")
)
2025-08-23 19:47:08 +02:00
async def procurement_health_check():
"""
Health check endpoint for procurement service
"""
return {
"status": "healthy",
"service": "procurement-planning",
"procurement_enabled": settings.PROCUREMENT_PLANNING_ENABLED,
"timestamp": date.today().isoformat()
}