New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -18,13 +18,11 @@ from shared.routing import RouteBuilder
from app.core.database import get_db
from app.services.performance_service import PerformanceTrackingService, AlertService
from app.services.dashboard_service import DashboardService
from app.services.delivery_service import DeliveryService
from app.schemas.performance import (
PerformanceMetric, Alert, PerformanceDashboardSummary,
SupplierPerformanceInsights, PerformanceAnalytics, BusinessModelInsights,
AlertSummary, PerformanceReportRequest, ExportDataResponse
)
from app.schemas.suppliers import DeliveryPerformanceStats, DeliverySummaryStats
from app.models.performance import PerformancePeriod, PerformanceMetricType, AlertType, AlertSeverity
logger = structlog.get_logger()
@@ -50,52 +48,6 @@ async def get_dashboard_service() -> DashboardService:
return DashboardService()
# ===== Delivery Analytics =====
@router.get(
route_builder.build_analytics_route("deliveries/performance-stats"),
response_model=DeliveryPerformanceStats
)
async def get_delivery_performance_stats(
tenant_id: UUID = Path(...),
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
supplier_id: Optional[UUID] = Query(None, description="Filter by supplier ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get delivery performance statistics"""
try:
service = DeliveryService(db)
stats = await service.get_delivery_performance_stats(
tenant_id=current_user["tenant_id"],
days_back=days_back,
supplier_id=supplier_id
)
return DeliveryPerformanceStats(**stats)
except Exception as e:
logger.error("Error getting delivery performance stats", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve delivery performance statistics")
@router.get(
route_builder.build_analytics_route("deliveries/summary-stats"),
response_model=DeliverySummaryStats
)
async def get_delivery_summary_stats(
tenant_id: UUID = Path(...),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get delivery summary statistics for dashboard"""
try:
service = DeliveryService(db)
stats = await service.get_upcoming_deliveries_summary(current_user["tenant_id"])
return DeliverySummaryStats(**stats)
except Exception as e:
logger.error("Error getting delivery summary stats", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve delivery summary statistics")
# ===== Performance Metrics =====
@router.post(

View File

@@ -1,188 +0,0 @@
# services/suppliers/app/api/deliveries.py
"""
Delivery CRUD API endpoints (ATOMIC)
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.delivery_service import DeliveryService
from app.schemas.suppliers import (
DeliveryCreate, DeliveryUpdate, DeliveryResponse, DeliverySummary,
DeliverySearchParams
)
from app.models.suppliers import DeliveryStatus
from shared.auth.decorators import get_current_user_dep
from shared.routing import RouteBuilder
from shared.auth.access_control import require_user_role
# Create route builder for consistent URL structure
route_builder = RouteBuilder('suppliers')
router = APIRouter(tags=["deliveries"])
logger = structlog.get_logger()
@router.post(route_builder.build_base_route("deliveries"), response_model=DeliveryResponse)
@require_user_role(['admin', 'owner', 'member'])
async def create_delivery(
delivery_data: DeliveryCreate,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Create a new delivery"""
# require_permissions(current_user, ["deliveries:create"])
try:
service = DeliveryService(db)
delivery = await service.create_delivery(
tenant_id=current_user["tenant_id"],
delivery_data=delivery_data,
created_by=current_user["user_id"]
)
return DeliveryResponse.from_orm(delivery)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating delivery", error=str(e))
raise HTTPException(status_code=500, detail="Failed to create delivery")
@router.get(route_builder.build_base_route("deliveries"), response_model=List[DeliverySummary])
async def list_deliveries(
supplier_id: Optional[UUID] = Query(None, description="Filter by supplier ID"),
status: Optional[str] = Query(None, description="Filter by status"),
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)"),
search_term: Optional[str] = Query(None, description="Search term"),
limit: int = Query(50, ge=1, le=1000, description="Number of results to return"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""List deliveries with optional filters"""
# require_permissions(current_user, ["deliveries:read"])
try:
from datetime import datetime
# Parse date filters
date_from_parsed = None
date_to_parsed = None
if date_from:
try:
date_from_parsed = datetime.fromisoformat(date_from)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_from format")
if date_to:
try:
date_to_parsed = datetime.fromisoformat(date_to)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_to format")
# Validate status
status_enum = None
if status:
try:
status_enum = DeliveryStatus(status.upper())
except ValueError:
raise HTTPException(status_code=400, detail="Invalid status")
service = DeliveryService(db)
search_params = DeliverySearchParams(
supplier_id=supplier_id,
status=status_enum,
date_from=date_from_parsed,
date_to=date_to_parsed,
search_term=search_term,
limit=limit,
offset=offset
)
deliveries = await service.search_deliveries(
tenant_id=current_user["tenant_id"],
search_params=search_params
)
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
except HTTPException:
raise
except Exception as e:
logger.error("Error listing deliveries", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve deliveries")
@router.get(route_builder.build_resource_detail_route("deliveries", "delivery_id"), response_model=DeliveryResponse)
async def get_delivery(
delivery_id: UUID = Path(..., description="Delivery ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get delivery by ID with items"""
# require_permissions(current_user, ["deliveries:read"])
try:
service = DeliveryService(db)
delivery = await service.get_delivery(delivery_id)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
# Check tenant access
if delivery.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return DeliveryResponse.from_orm(delivery)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting delivery", delivery_id=str(delivery_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve delivery")
@router.put(route_builder.build_resource_detail_route("deliveries", "delivery_id"), response_model=DeliveryResponse)
@require_user_role(['admin', 'owner', 'member'])
async def update_delivery(
delivery_data: DeliveryUpdate,
delivery_id: UUID = Path(..., description="Delivery ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Update delivery information"""
# require_permissions(current_user, ["deliveries:update"])
try:
service = DeliveryService(db)
# Check delivery exists and belongs to tenant
existing_delivery = await service.get_delivery(delivery_id)
if not existing_delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
if existing_delivery.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
delivery = await service.update_delivery(
delivery_id=delivery_id,
delivery_data=delivery_data,
updated_by=current_user["user_id"]
)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
return DeliveryResponse.from_orm(delivery)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error updating delivery", delivery_id=str(delivery_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to update delivery")

View File

@@ -1,272 +0,0 @@
# services/suppliers/app/api/purchase_orders.py
"""
Purchase Order CRUD API endpoints (ATOMIC)
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional, Dict, Any
from uuid import UUID
import structlog
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.purchase_order_service import PurchaseOrderService
from app.schemas.suppliers import (
PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderResponse, PurchaseOrderSummary,
PurchaseOrderSearchParams
)
from app.models.suppliers import PurchaseOrderStatus
from app.models import AuditLog
from shared.auth.decorators import get_current_user_dep
from shared.routing import RouteBuilder
from shared.auth.access_control import require_user_role
from shared.security import create_audit_logger, AuditSeverity, AuditAction
# Create route builder for consistent URL structure
route_builder = RouteBuilder('suppliers')
router = APIRouter(tags=["purchase-orders"])
logger = structlog.get_logger()
audit_logger = create_audit_logger("suppliers-service", AuditLog)
@router.post(route_builder.build_base_route("purchase-orders"), response_model=PurchaseOrderResponse)
@require_user_role(['admin', 'owner', 'member'])
async def create_purchase_order(
po_data: PurchaseOrderCreate,
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Create a new purchase order"""
# require_permissions(current_user, ["purchase_orders:create"])
try:
service = PurchaseOrderService(db)
purchase_order = await service.create_purchase_order(
tenant_id=current_user["tenant_id"],
po_data=po_data,
created_by=current_user["user_id"]
)
return PurchaseOrderResponse.from_orm(purchase_order)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating purchase order", error=str(e))
raise HTTPException(status_code=500, detail="Failed to create purchase order")
@router.get(route_builder.build_base_route("purchase-orders"), response_model=List[PurchaseOrderSummary])
async def list_purchase_orders(
supplier_id: Optional[UUID] = Query(None, description="Filter by supplier ID"),
status: Optional[str] = Query(None, description="Filter by status"),
priority: Optional[str] = Query(None, description="Filter by priority"),
date_from: Optional[str] = Query(None, description="Filter from date (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="Filter to date (YYYY-MM-DD)"),
search_term: Optional[str] = Query(None, description="Search term"),
limit: int = Query(50, ge=1, le=1000, description="Number of results to return"),
offset: int = Query(0, ge=0, description="Number of results to skip"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""List purchase orders with optional filters"""
# require_permissions(current_user, ["purchase_orders:read"])
try:
from datetime import datetime
# Parse date filters
date_from_parsed = None
date_to_parsed = None
if date_from:
try:
date_from_parsed = datetime.fromisoformat(date_from)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_from format")
if date_to:
try:
date_to_parsed = datetime.fromisoformat(date_to)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_to format")
# Validate status
status_enum = None
if status:
try:
# Convert from PENDING_APPROVAL to pending_approval format
status_value = status.lower()
status_enum = PurchaseOrderStatus(status_value)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid status")
service = PurchaseOrderService(db)
search_params = PurchaseOrderSearchParams(
supplier_id=supplier_id,
status=status_enum,
priority=priority,
date_from=date_from_parsed,
date_to=date_to_parsed,
search_term=search_term,
limit=limit,
offset=offset
)
orders = await service.search_purchase_orders(
tenant_id=current_user["tenant_id"],
search_params=search_params
)
# Convert to response with supplier names
response = []
for order in orders:
order_dict = {
"id": order.id,
"po_number": order.po_number,
"supplier_id": order.supplier_id,
"supplier_name": order.supplier.name if order.supplier else None,
"status": order.status,
"priority": order.priority,
"order_date": order.order_date,
"required_delivery_date": order.required_delivery_date,
"total_amount": order.total_amount,
"currency": order.currency,
"created_at": order.created_at
}
response.append(PurchaseOrderSummary(**order_dict))
return response
except HTTPException:
raise
except Exception as e:
logger.error("Error listing purchase orders", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve purchase orders")
@router.get(route_builder.build_resource_detail_route("purchase-orders", "po_id"), response_model=PurchaseOrderResponse)
async def get_purchase_order(
po_id: UUID = Path(..., description="Purchase order ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get purchase order by ID with items"""
# require_permissions(current_user, ["purchase_orders:read"])
try:
service = PurchaseOrderService(db)
purchase_order = await service.get_purchase_order(po_id)
if not purchase_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
# Tenant access control is handled by the gateway
return PurchaseOrderResponse.from_orm(purchase_order)
except HTTPException:
raise
except Exception as e:
logger.error("Error getting purchase order", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve purchase order")
@router.put(route_builder.build_resource_detail_route("purchase-orders", "po_id"), response_model=PurchaseOrderResponse)
@require_user_role(['admin', 'owner', 'member'])
async def update_purchase_order(
po_data: PurchaseOrderUpdate,
po_id: UUID = Path(..., description="Purchase order ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Update purchase order information"""
# require_permissions(current_user, ["purchase_orders:update"])
try:
service = PurchaseOrderService(db)
# Check order exists and belongs to tenant
existing_order = await service.get_purchase_order(po_id)
if not existing_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
if existing_order.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
purchase_order = await service.update_purchase_order(
po_id=po_id,
po_data=po_data,
updated_by=current_user["user_id"]
)
if not purchase_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.from_orm(purchase_order)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error updating purchase order", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to update purchase order")
@router.delete(route_builder.build_resource_detail_route("purchase-orders", "po_id"))
@require_user_role(['admin', 'owner'])
async def delete_purchase_order(
po_id: UUID = Path(..., description="Purchase order ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Delete purchase order (soft delete, Admin+ only)"""
try:
service = PurchaseOrderService(db)
# Check order exists and belongs to tenant
existing_order = await service.get_purchase_order(po_id)
if not existing_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
if existing_order.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
# Capture PO data before deletion
po_data = {
"po_number": existing_order.order_number,
"supplier_id": str(existing_order.supplier_id),
"status": existing_order.status.value if existing_order.status else None,
"total_amount": float(existing_order.total_amount) if existing_order.total_amount else 0.0,
"expected_delivery_date": existing_order.expected_delivery_date.isoformat() if existing_order.expected_delivery_date else None
}
# Delete purchase order (likely soft delete in service)
success = await service.delete_purchase_order(po_id)
if not success:
raise HTTPException(status_code=404, detail="Purchase order not found")
# Log audit event for purchase order deletion
try:
await audit_logger.log_deletion(
db_session=db,
tenant_id=tenant_id,
user_id=current_user["user_id"],
resource_type="purchase_order",
resource_id=str(po_id),
resource_data=po_data,
description=f"Admin {current_user.get('email', 'unknown')} deleted purchase order {po_data['po_number']}",
endpoint=f"/purchase-orders/{po_id}",
method="DELETE"
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Deleted purchase order",
po_id=str(po_id),
tenant_id=tenant_id,
user_id=current_user["user_id"])
return {"message": "Purchase order deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error("Error deleting purchase order", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to delete purchase order")

View File

@@ -14,12 +14,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.services.supplier_service import SupplierService
from app.services.delivery_service import DeliveryService
from app.services.purchase_order_service import PurchaseOrderService
from app.schemas.suppliers import (
SupplierApproval, SupplierResponse, SupplierSummary, SupplierStatistics,
DeliveryStatusUpdate, DeliveryReceiptConfirmation, DeliveryResponse, DeliverySummary,
PurchaseOrderStatusUpdate, PurchaseOrderApproval, PurchaseOrderResponse, PurchaseOrderSummary
SupplierApproval, SupplierResponse, SupplierSummary, SupplierStatistics
)
from app.models.suppliers import SupplierType
from app.models import AuditLog
@@ -173,550 +169,6 @@ async def get_suppliers_by_type(
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers by type")
# ===== Delivery Operations =====
@router.get(route_builder.build_operations_route("deliveries/today"), response_model=List[DeliverySummary])
async def get_todays_deliveries(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get deliveries scheduled for today"""
try:
service = DeliveryService(db)
deliveries = await service.get_todays_deliveries(current_user["tenant_id"])
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
except Exception as e:
logger.error("Error getting today's deliveries", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve today's deliveries")
@router.get(route_builder.build_operations_route("deliveries/overdue"), response_model=List[DeliverySummary])
async def get_overdue_deliveries(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get overdue deliveries"""
try:
service = DeliveryService(db)
deliveries = await service.get_overdue_deliveries(current_user["tenant_id"])
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
except Exception as e:
logger.error("Error getting overdue deliveries", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve overdue deliveries")
@router.get(route_builder.build_operations_route("deliveries/scheduled"), response_model=List[DeliverySummary])
async def get_scheduled_deliveries(
tenant_id: str = Path(..., description="Tenant ID"),
date_from: Optional[str] = Query(None, description="From date (YYYY-MM-DD)"),
date_to: Optional[str] = Query(None, description="To date (YYYY-MM-DD)"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get scheduled deliveries for a date range"""
try:
date_from_parsed = None
date_to_parsed = None
if date_from:
try:
date_from_parsed = datetime.fromisoformat(date_from)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_from format")
if date_to:
try:
date_to_parsed = datetime.fromisoformat(date_to)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date_to format")
service = DeliveryService(db)
deliveries = await service.get_scheduled_deliveries(
tenant_id=current_user["tenant_id"],
date_from=date_from_parsed,
date_to=date_to_parsed
)
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
except HTTPException:
raise
except Exception as e:
logger.error("Error getting scheduled deliveries", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve scheduled deliveries")
@router.patch(route_builder.build_nested_resource_route("deliveries", "delivery_id", "status"), response_model=DeliveryResponse)
@require_user_role(['admin', 'owner', 'member'])
async def update_delivery_status(
status_data: DeliveryStatusUpdate,
delivery_id: UUID = Path(..., description="Delivery ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Update delivery status"""
try:
service = DeliveryService(db)
# Check delivery exists and belongs to tenant
existing_delivery = await service.get_delivery(delivery_id)
if not existing_delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
if existing_delivery.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
delivery = await service.update_delivery_status(
delivery_id=delivery_id,
status=status_data.status,
updated_by=current_user["user_id"],
notes=status_data.notes,
update_timestamps=status_data.update_timestamps
)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
return DeliveryResponse.from_orm(delivery)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error updating delivery status", delivery_id=str(delivery_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to update delivery status")
@router.post(route_builder.build_nested_resource_route("deliveries", "delivery_id", "receive"), response_model=DeliveryResponse)
@require_user_role(['admin', 'owner', 'member'])
async def receive_delivery(
receipt_data: DeliveryReceiptConfirmation,
delivery_id: UUID = Path(..., description="Delivery ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Mark delivery as received with inspection details"""
try:
service = DeliveryService(db)
# Check delivery exists and belongs to tenant
existing_delivery = await service.get_delivery(delivery_id)
if not existing_delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
if existing_delivery.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
delivery = await service.mark_as_received(
delivery_id=delivery_id,
received_by=current_user["user_id"],
inspection_passed=receipt_data.inspection_passed,
inspection_notes=receipt_data.inspection_notes,
quality_issues=receipt_data.quality_issues,
notes=receipt_data.notes
)
if not delivery:
raise HTTPException(status_code=404, detail="Delivery not found")
return DeliveryResponse.from_orm(delivery)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error receiving delivery", delivery_id=str(delivery_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to receive delivery")
@router.get(route_builder.build_resource_detail_route("deliveries/purchase-order", "po_id"), response_model=List[DeliverySummary])
async def get_deliveries_by_purchase_order(
po_id: UUID = Path(..., description="Purchase order ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get all deliveries for a purchase order"""
try:
service = DeliveryService(db)
deliveries = await service.get_deliveries_by_purchase_order(po_id)
# Check tenant access for first delivery (all should belong to same tenant)
if deliveries and deliveries[0].tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
return [DeliverySummary.from_orm(delivery) for delivery in deliveries]
except HTTPException:
raise
except Exception as e:
logger.error("Error getting deliveries by purchase order", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve deliveries for purchase order")
# ===== Purchase Order Operations =====
@router.get(route_builder.build_operations_route("purchase-orders/statistics"), response_model=dict)
async def get_purchase_order_statistics(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get purchase order statistics for dashboard"""
try:
service = PurchaseOrderService(db)
stats = await service.get_purchase_order_statistics(current_user["tenant_id"])
return stats
except Exception as e:
logger.error("Error getting purchase order statistics", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve statistics")
@router.get(route_builder.build_operations_route("purchase-orders/pending-approval"), response_model=List[PurchaseOrderSummary])
async def get_orders_requiring_approval(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get purchase orders requiring approval"""
try:
service = PurchaseOrderService(db)
orders = await service.get_orders_requiring_approval(current_user["tenant_id"])
return [PurchaseOrderSummary.from_orm(order) for order in orders]
except Exception as e:
logger.error("Error getting orders requiring approval", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve orders requiring approval")
@router.get(route_builder.build_operations_route("purchase-orders/overdue"), response_model=List[PurchaseOrderSummary])
async def get_overdue_orders(
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get overdue purchase orders"""
try:
service = PurchaseOrderService(db)
orders = await service.get_overdue_orders(current_user["tenant_id"])
return [PurchaseOrderSummary.from_orm(order) for order in orders]
except Exception as e:
logger.error("Error getting overdue orders", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve overdue orders")
@router.patch(route_builder.build_nested_resource_route("purchase-orders", "po_id", "status"), response_model=PurchaseOrderResponse)
@require_user_role(['admin', 'owner', 'member'])
async def update_purchase_order_status(
status_data: PurchaseOrderStatusUpdate,
po_id: UUID = Path(..., description="Purchase order ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Update purchase order status"""
try:
service = PurchaseOrderService(db)
# Check order exists and belongs to tenant
existing_order = await service.get_purchase_order(po_id)
if not existing_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
if existing_order.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
purchase_order = await service.update_order_status(
po_id=po_id,
status=status_data.status,
updated_by=current_user["user_id"],
notes=status_data.notes
)
if not purchase_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.from_orm(purchase_order)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error updating purchase order status", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to update purchase order status")
@router.post(route_builder.build_nested_resource_route("purchase-orders", "po_id", "approve"), response_model=PurchaseOrderResponse)
@require_user_role(['admin', 'owner'])
async def approve_purchase_order(
approval_data: PurchaseOrderApproval,
po_id: UUID = Path(..., description="Purchase order ID"),
tenant_id: str = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Approve or reject a purchase order (Admin+ only)"""
try:
service = PurchaseOrderService(db)
# Check order exists and belongs to tenant
existing_order = await service.get_purchase_order(po_id)
if not existing_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
if existing_order.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
# Capture PO details for audit
po_details = {
"po_number": existing_order.order_number,
"supplier_id": str(existing_order.supplier_id),
"total_amount": float(existing_order.total_amount) if existing_order.total_amount else 0.0,
"expected_delivery_date": existing_order.expected_delivery_date.isoformat() if existing_order.expected_delivery_date else None
}
if approval_data.action == "approve":
purchase_order = await service.approve_purchase_order(
po_id=po_id,
approved_by=current_user["user_id"],
approval_notes=approval_data.notes
)
action = "approve"
description = f"Admin {current_user.get('email', 'unknown')} approved purchase order {po_details['po_number']}"
elif approval_data.action == "reject":
if not approval_data.notes:
raise HTTPException(status_code=400, detail="Rejection reason is required")
purchase_order = await service.reject_purchase_order(
po_id=po_id,
rejection_reason=approval_data.notes,
rejected_by=current_user["user_id"]
)
action = "reject"
description = f"Admin {current_user.get('email', 'unknown')} rejected purchase order {po_details['po_number']}"
else:
raise HTTPException(status_code=400, detail="Invalid action")
if not purchase_order:
raise HTTPException(
status_code=400,
detail="Purchase order is not in pending approval status"
)
# Log HIGH severity audit event for purchase order approval/rejection
try:
await audit_logger.log_event(
db_session=db,
tenant_id=tenant_id,
user_id=current_user["user_id"],
action=action,
resource_type="purchase_order",
resource_id=str(po_id),
severity=AuditSeverity.HIGH.value,
description=description,
changes={
"action": approval_data.action,
"notes": approval_data.notes,
"po_details": po_details
},
endpoint=f"/purchase-orders/{po_id}/approve",
method="POST"
)
except Exception as audit_error:
logger.warning("Failed to log audit event", error=str(audit_error))
logger.info("Purchase order approval processed",
po_id=str(po_id),
action=approval_data.action,
tenant_id=tenant_id,
user_id=current_user["user_id"])
return PurchaseOrderResponse.from_orm(purchase_order)
except HTTPException:
raise
except Exception as e:
logger.error("Error processing purchase order approval", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to process purchase order approval")
@router.post(route_builder.build_nested_resource_route("purchase-orders", "po_id", "send-to-supplier"), response_model=PurchaseOrderResponse)
@require_user_role(['admin', 'owner', 'member'])
async def send_to_supplier(
po_id: UUID = Path(..., description="Purchase order ID"),
tenant_id: str = Path(..., description="Tenant ID"),
send_email: bool = Query(True, description="Send email notification to supplier"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Send purchase order to supplier"""
try:
service = PurchaseOrderService(db)
# Check order exists and belongs to tenant
existing_order = await service.get_purchase_order(po_id)
if not existing_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
if existing_order.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
purchase_order = await service.send_to_supplier(
po_id=po_id,
sent_by=current_user["user_id"],
send_email=send_email
)
if not purchase_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.from_orm(purchase_order)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error sending purchase order to supplier", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to send purchase order to supplier")
@router.post(route_builder.build_nested_resource_route("purchase-orders", "po_id", "confirm-supplier-receipt"), response_model=PurchaseOrderResponse)
@require_user_role(['admin', 'owner', 'member'])
async def confirm_supplier_receipt(
po_id: UUID = Path(..., description="Purchase order ID"),
tenant_id: str = Path(..., description="Tenant ID"),
supplier_reference: Optional[str] = Query(None, description="Supplier's order reference"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Confirm supplier has received and accepted the order"""
try:
service = PurchaseOrderService(db)
# Check order exists and belongs to tenant
existing_order = await service.get_purchase_order(po_id)
if not existing_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
if existing_order.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
purchase_order = await service.confirm_supplier_receipt(
po_id=po_id,
supplier_reference=supplier_reference,
confirmed_by=current_user["user_id"]
)
if not purchase_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.from_orm(purchase_order)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error confirming supplier receipt", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to confirm supplier receipt")
@router.post(route_builder.build_nested_resource_route("purchase-orders", "po_id", "cancel"), response_model=PurchaseOrderResponse)
@require_user_role(['admin', 'owner', 'member'])
async def cancel_purchase_order(
po_id: UUID = Path(..., description="Purchase order ID"),
tenant_id: str = Path(..., description="Tenant ID"),
cancellation_reason: str = Query(..., description="Reason for cancellation"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Cancel a purchase order"""
try:
service = PurchaseOrderService(db)
# Check order exists and belongs to tenant
existing_order = await service.get_purchase_order(po_id)
if not existing_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
if existing_order.tenant_id != current_user["tenant_id"]:
raise HTTPException(status_code=403, detail="Access denied")
purchase_order = await service.cancel_purchase_order(
po_id=po_id,
cancellation_reason=cancellation_reason,
cancelled_by=current_user["user_id"]
)
if not purchase_order:
raise HTTPException(status_code=404, detail="Purchase order not found")
return PurchaseOrderResponse.from_orm(purchase_order)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error cancelling purchase order", po_id=str(po_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to cancel purchase order")
@router.get(route_builder.build_resource_detail_route("purchase-orders/supplier", "supplier_id"), response_model=List[PurchaseOrderSummary])
async def get_orders_by_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),
tenant_id: str = Path(..., description="Tenant ID"),
limit: int = Query(20, ge=1, le=100, description="Number of orders to return"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get recent purchase orders for a specific supplier"""
try:
service = PurchaseOrderService(db)
orders = await service.get_orders_by_supplier(
tenant_id=current_user["tenant_id"],
supplier_id=supplier_id,
limit=limit
)
return [PurchaseOrderSummary.from_orm(order) for order in orders]
except Exception as e:
logger.error("Error getting orders by supplier", supplier_id=str(supplier_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve orders by supplier")
@router.get(route_builder.build_nested_resource_route("purchase-orders/inventory-products", "inventory_product_id", "history"))
async def get_inventory_product_purchase_history(
inventory_product_id: UUID = Path(..., description="Inventory Product ID"),
tenant_id: str = Path(..., description="Tenant ID"),
days_back: int = Query(90, ge=1, le=365, description="Number of days to look back"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get purchase history for a specific inventory product"""
try:
service = PurchaseOrderService(db)
history = await service.get_inventory_product_purchase_history(
tenant_id=current_user["tenant_id"],
inventory_product_id=inventory_product_id,
days_back=days_back
)
return history
except Exception as e:
logger.error("Error getting inventory product purchase history", inventory_product_id=str(inventory_product_id), error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve inventory product purchase history")
@router.get(route_builder.build_operations_route("purchase-orders/inventory-products/top-purchased"))
async def get_top_purchased_inventory_products(
tenant_id: str = Path(..., description="Tenant ID"),
days_back: int = Query(30, ge=1, le=365, description="Number of days to look back"),
limit: int = Query(10, ge=1, le=50, description="Number of top inventory products to return"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: Session = Depends(get_db)
):
"""Get most purchased inventory products by value"""
try:
service = PurchaseOrderService(db)
products = await service.get_top_purchased_inventory_products(
tenant_id=current_user["tenant_id"],
days_back=days_back,
limit=limit
)
return products
except Exception as e:
logger.error("Error getting top purchased inventory products", error=str(e))
raise HTTPException(status_code=500, detail="Failed to retrieve top purchased inventory products")
@router.get(route_builder.build_operations_route("count"))
async def get_supplier_count(
tenant_id: str = Path(..., description="Tenant ID"),

View File

@@ -91,6 +91,65 @@ async def list_suppliers(
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers")
@router.get(route_builder.build_base_route("batch"), response_model=List[SupplierSummary])
async def get_suppliers_batch(
tenant_id: str = Path(..., description="Tenant ID"),
ids: str = Query(..., description="Comma-separated supplier IDs"),
db: AsyncSession = Depends(get_db)
):
"""
Get multiple suppliers in a single call for performance optimization.
This endpoint is designed to eliminate N+1 query patterns when fetching
supplier data for multiple purchase orders or other entities.
Args:
tenant_id: Tenant ID
ids: Comma-separated supplier IDs (e.g., "abc123,def456,xyz789")
Returns:
List of supplier summaries for the requested IDs
"""
try:
service = SupplierService(db)
# Parse comma-separated IDs
supplier_ids = [id.strip() for id in ids.split(",") if id.strip()]
if not supplier_ids:
return []
if len(supplier_ids) > 100:
raise HTTPException(
status_code=400,
detail="Maximum 100 supplier IDs allowed per batch request"
)
# Convert to UUIDs
try:
uuid_ids = [UUID(id) for id in supplier_ids]
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid supplier ID format: {e}")
# Fetch suppliers
suppliers = await service.get_suppliers_batch(tenant_id=UUID(tenant_id), supplier_ids=uuid_ids)
logger.info(
"Batch retrieved suppliers",
tenant_id=tenant_id,
requested_count=len(supplier_ids),
found_count=len(suppliers)
)
return [SupplierSummary.from_orm(supplier) for supplier in suppliers]
except HTTPException:
raise
except Exception as e:
logger.error("Error batch retrieving suppliers", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to retrieve suppliers")
@router.get(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse)
async def get_supplier(
supplier_id: UUID = Path(..., description="Supplier ID"),