Improve the frontend 3
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
Internal Demo Cloning API for Orders Service
|
||||
Service-to-service endpoint for cloning order and procurement data
|
||||
Service-to-service endpoint for cloning order and customer data
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header
|
||||
@@ -15,7 +15,6 @@ from decimal import Decimal
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.order import CustomerOrder, OrderItem
|
||||
from app.models.procurement import ProcurementPlan, ProcurementRequirement
|
||||
from app.models.customer import Customer
|
||||
from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
|
||||
|
||||
@@ -54,7 +53,6 @@ async def clone_demo_data(
|
||||
Clones:
|
||||
- Customers
|
||||
- Customer orders with line items
|
||||
- Procurement plans with requirements
|
||||
- Adjusts dates to recent timeframe
|
||||
|
||||
Args:
|
||||
@@ -96,8 +94,6 @@ async def clone_demo_data(
|
||||
"customers": 0,
|
||||
"customer_orders": 0,
|
||||
"order_line_items": 0,
|
||||
"procurement_plans": 0,
|
||||
"procurement_requirements": 0,
|
||||
"alerts_generated": 0
|
||||
}
|
||||
|
||||
@@ -255,132 +251,6 @@ async def clone_demo_data(
|
||||
db.add(new_item)
|
||||
stats["order_line_items"] += 1
|
||||
|
||||
# Clone Procurement Plans with Requirements
|
||||
result = await db.execute(
|
||||
select(ProcurementPlan).where(ProcurementPlan.tenant_id == base_uuid)
|
||||
)
|
||||
base_plans = result.scalars().all()
|
||||
|
||||
logger.info(
|
||||
"Found procurement plans to clone",
|
||||
count=len(base_plans),
|
||||
base_tenant=str(base_uuid)
|
||||
)
|
||||
|
||||
# Calculate date offset for procurement
|
||||
if base_plans:
|
||||
max_plan_date = max(plan.plan_date for plan in base_plans)
|
||||
today_date = date.today()
|
||||
days_diff = (today_date - max_plan_date).days
|
||||
plan_date_offset = timedelta(days=days_diff)
|
||||
else:
|
||||
plan_date_offset = timedelta(days=0)
|
||||
|
||||
plan_id_map = {}
|
||||
|
||||
for plan in base_plans:
|
||||
new_plan_id = uuid.uuid4()
|
||||
plan_id_map[plan.id] = new_plan_id
|
||||
|
||||
new_plan = ProcurementPlan(
|
||||
id=new_plan_id,
|
||||
tenant_id=virtual_uuid,
|
||||
plan_number=f"PROC-{uuid.uuid4().hex[:8].upper()}",
|
||||
plan_date=plan.plan_date + plan_date_offset if plan.plan_date else None,
|
||||
plan_period_start=plan.plan_period_start + plan_date_offset if plan.plan_period_start else None,
|
||||
plan_period_end=plan.plan_period_end + plan_date_offset if plan.plan_period_end else None,
|
||||
planning_horizon_days=plan.planning_horizon_days,
|
||||
status=plan.status,
|
||||
plan_type=plan.plan_type,
|
||||
priority=plan.priority,
|
||||
business_model=plan.business_model,
|
||||
procurement_strategy=plan.procurement_strategy,
|
||||
total_requirements=plan.total_requirements,
|
||||
total_estimated_cost=plan.total_estimated_cost,
|
||||
total_approved_cost=plan.total_approved_cost,
|
||||
cost_variance=plan.cost_variance,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(new_plan)
|
||||
stats["procurement_plans"] += 1
|
||||
|
||||
# Clone Procurement Requirements
|
||||
for old_plan_id, new_plan_id in plan_id_map.items():
|
||||
result = await db.execute(
|
||||
select(ProcurementRequirement).where(ProcurementRequirement.plan_id == old_plan_id)
|
||||
)
|
||||
requirements = result.scalars().all()
|
||||
|
||||
for req in requirements:
|
||||
new_req = ProcurementRequirement(
|
||||
id=uuid.uuid4(),
|
||||
plan_id=new_plan_id,
|
||||
requirement_number=req.requirement_number,
|
||||
product_id=req.product_id,
|
||||
product_name=req.product_name,
|
||||
product_sku=req.product_sku,
|
||||
product_category=req.product_category,
|
||||
product_type=req.product_type,
|
||||
required_quantity=req.required_quantity,
|
||||
unit_of_measure=req.unit_of_measure,
|
||||
safety_stock_quantity=req.safety_stock_quantity,
|
||||
total_quantity_needed=req.total_quantity_needed,
|
||||
current_stock_level=req.current_stock_level,
|
||||
reserved_stock=req.reserved_stock,
|
||||
available_stock=req.available_stock,
|
||||
net_requirement=req.net_requirement,
|
||||
order_demand=req.order_demand,
|
||||
production_demand=req.production_demand,
|
||||
forecast_demand=req.forecast_demand,
|
||||
buffer_demand=req.buffer_demand,
|
||||
preferred_supplier_id=req.preferred_supplier_id,
|
||||
backup_supplier_id=req.backup_supplier_id,
|
||||
supplier_name=req.supplier_name,
|
||||
supplier_lead_time_days=req.supplier_lead_time_days,
|
||||
minimum_order_quantity=req.minimum_order_quantity,
|
||||
estimated_unit_cost=req.estimated_unit_cost,
|
||||
estimated_total_cost=req.estimated_total_cost,
|
||||
last_purchase_cost=req.last_purchase_cost,
|
||||
cost_variance=req.cost_variance,
|
||||
required_by_date=req.required_by_date + plan_date_offset if req.required_by_date else None,
|
||||
lead_time_buffer_days=req.lead_time_buffer_days,
|
||||
suggested_order_date=req.suggested_order_date + plan_date_offset if req.suggested_order_date else None,
|
||||
latest_order_date=req.latest_order_date + plan_date_offset if req.latest_order_date else None,
|
||||
quality_specifications=req.quality_specifications,
|
||||
special_requirements=req.special_requirements,
|
||||
storage_requirements=req.storage_requirements,
|
||||
shelf_life_days=req.shelf_life_days,
|
||||
status=req.status,
|
||||
priority=req.priority,
|
||||
risk_level=req.risk_level,
|
||||
purchase_order_id=req.purchase_order_id,
|
||||
purchase_order_number=req.purchase_order_number,
|
||||
ordered_quantity=req.ordered_quantity,
|
||||
ordered_at=req.ordered_at,
|
||||
expected_delivery_date=req.expected_delivery_date + plan_date_offset if req.expected_delivery_date else None,
|
||||
actual_delivery_date=req.actual_delivery_date + plan_date_offset if req.actual_delivery_date else None,
|
||||
received_quantity=req.received_quantity,
|
||||
delivery_status=req.delivery_status,
|
||||
fulfillment_rate=req.fulfillment_rate,
|
||||
on_time_delivery=req.on_time_delivery,
|
||||
quality_rating=req.quality_rating,
|
||||
source_orders=req.source_orders,
|
||||
source_production_batches=req.source_production_batches,
|
||||
demand_analysis=req.demand_analysis,
|
||||
approved_quantity=req.approved_quantity,
|
||||
approved_cost=req.approved_cost,
|
||||
approved_at=req.approved_at,
|
||||
approved_by=req.approved_by,
|
||||
procurement_notes=req.procurement_notes,
|
||||
supplier_communication=req.supplier_communication,
|
||||
requirement_metadata=req.requirement_metadata,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.add(new_req)
|
||||
stats["procurement_requirements"] += 1
|
||||
|
||||
# Commit cloned data
|
||||
await db.commit()
|
||||
|
||||
@@ -389,7 +259,7 @@ async def clone_demo_data(
|
||||
# This eliminates duplicate alerts and provides a more realistic demo experience.
|
||||
stats["alerts_generated"] = 0
|
||||
|
||||
total_records = stats["customers"] + stats["customer_orders"] + stats["order_line_items"] + stats["procurement_plans"] + stats["procurement_requirements"]
|
||||
total_records = stats["customers"] + stats["customer_orders"] + stats["order_line_items"]
|
||||
duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
|
||||
|
||||
logger.info(
|
||||
@@ -462,13 +332,10 @@ async def delete_demo_data(
|
||||
order_count = await db.scalar(select(func.count(CustomerOrder.id)).where(CustomerOrder.tenant_id == virtual_uuid))
|
||||
item_count = await db.scalar(select(func.count(OrderItem.id)).where(OrderItem.tenant_id == virtual_uuid))
|
||||
customer_count = await db.scalar(select(func.count(Customer.id)).where(Customer.tenant_id == virtual_uuid))
|
||||
procurement_count = await db.scalar(select(func.count(ProcurementPlan.id)).where(ProcurementPlan.tenant_id == virtual_uuid))
|
||||
|
||||
# Delete in order
|
||||
await db.execute(delete(OrderItem).where(OrderItem.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(CustomerOrder).where(CustomerOrder.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(ProcurementRequirement).where(ProcurementRequirement.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(ProcurementPlan).where(ProcurementPlan.tenant_id == virtual_uuid))
|
||||
await db.execute(delete(Customer).where(Customer.tenant_id == virtual_uuid))
|
||||
await db.commit()
|
||||
|
||||
@@ -483,8 +350,7 @@ async def delete_demo_data(
|
||||
"orders": order_count,
|
||||
"items": item_count,
|
||||
"customers": customer_count,
|
||||
"procurement": procurement_count,
|
||||
"total": order_count + item_count + customer_count + procurement_count
|
||||
"total": order_count + item_count + customer_count
|
||||
},
|
||||
"duration_ms": duration_ms
|
||||
}
|
||||
|
||||
@@ -1,850 +0,0 @@
|
||||
# ================================================================
|
||||
# services/orders/app/api/procurement_operations.py
|
||||
# ================================================================
|
||||
"""
|
||||
Procurement Operations API Endpoints - BUSINESS logic for procurement planning
|
||||
RESTful APIs for procurement planning, approval workflows, and PO management
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
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
|
||||
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')
|
||||
|
||||
|
||||
# Create router
|
||||
router = APIRouter(tags=["Procurement Planning"])
|
||||
|
||||
# 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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# PROCUREMENT PLAN ENDPOINTS
|
||||
# ================================================================
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("procurement/plans/current"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@monitor_performance("get_current_procurement_plan")
|
||||
async def get_current_procurement_plan(
|
||||
tenant_id: uuid.UUID,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("procurement/plans/date/{plan_date}"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@monitor_performance("get_procurement_plan_by_date")
|
||||
async def get_procurement_plan_by_date(
|
||||
tenant_id: uuid.UUID,
|
||||
plan_date: date,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("procurement/plans"),
|
||||
response_model=PaginatedProcurementPlans
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@monitor_performance("list_procurement_plans")
|
||||
async def list_procurement_plans(
|
||||
tenant_id: uuid.UUID,
|
||||
plan_status: Optional[str] = Query(None, description="Filter by plan status"),
|
||||
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"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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(
|
||||
tenant_id,
|
||||
status=plan_status,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
# Convert to response models
|
||||
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
|
||||
|
||||
# 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)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
route_builder.build_operations_route("procurement/plans/generate"),
|
||||
response_model=GeneratePlanResponse
|
||||
)
|
||||
@require_user_role(['member', 'admin', 'owner'])
|
||||
@monitor_performance("generate_procurement_plan")
|
||||
async def generate_procurement_plan(
|
||||
tenant_id: uuid.UUID,
|
||||
request: GeneratePlanRequest,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
route_builder.build_operations_route("procurement/plans/{plan_id}/status")
|
||||
)
|
||||
@require_user_role(['admin', 'owner'])
|
||||
@monitor_performance("update_procurement_plan_status")
|
||||
async def update_procurement_plan_status(
|
||||
tenant_id: uuid.UUID,
|
||||
plan_id: uuid.UUID,
|
||||
status: str = Query(..., description="New status", pattern="^(draft|pending_approval|approved|in_execution|completed|cancelled)$"),
|
||||
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 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)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("procurement/plans/id/{plan_id}"),
|
||||
response_model=Optional[ProcurementPlanResponse]
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@monitor_performance("get_procurement_plan_by_id")
|
||||
async def get_procurement_plan_by_id(
|
||||
tenant_id: uuid.UUID,
|
||||
plan_id: uuid.UUID,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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
|
||||
# ================================================================
|
||||
|
||||
@router.get(
|
||||
route_builder.build_dashboard_route("procurement"),
|
||||
response_model=Optional[DashboardData]
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@monitor_performance("get_procurement_dashboard")
|
||||
async def get_procurement_dashboard(
|
||||
tenant_id: uuid.UUID,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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
|
||||
# ================================================================
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("procurement/plans/{plan_id}/requirements")
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@monitor_performance("get_plan_requirements")
|
||||
async def get_plan_requirements(
|
||||
tenant_id: uuid.UUID,
|
||||
plan_id: uuid.UUID,
|
||||
status: Optional[str] = Query(None, description="Filter by requirement status"),
|
||||
priority: Optional[str] = Query(None, description="Filter by priority level"),
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_operations_route("procurement/requirements/critical")
|
||||
)
|
||||
@require_user_role(['viewer', 'member', 'admin', 'owner'])
|
||||
@monitor_performance("get_critical_requirements")
|
||||
async def get_critical_requirements(
|
||||
tenant_id: uuid.UUID,
|
||||
current_user: Dict[str, Any] = Depends(get_current_user_dep),
|
||||
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
|
||||
# ================================================================
|
||||
|
||||
@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,
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# UTILITY ENDPOINTS
|
||||
# ================================================================
|
||||
|
||||
@router.post(
|
||||
route_builder.build_operations_route("procurement/scheduler/trigger")
|
||||
)
|
||||
@monitor_performance("trigger_daily_scheduler")
|
||||
async def trigger_daily_scheduler(
|
||||
tenant_id: uuid.UUID,
|
||||
request: Request
|
||||
):
|
||||
"""
|
||||
Manually trigger the daily scheduler for the current tenant
|
||||
|
||||
This endpoint is primarily for testing and maintenance purposes.
|
||||
Note: Authentication temporarily disabled for development testing.
|
||||
"""
|
||||
try:
|
||||
# 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
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error triggering daily scheduler: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get(
|
||||
route_builder.build_base_route("procurement/health")
|
||||
)
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user