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"),

View File

@@ -0,0 +1,789 @@
"""
Alert Event Consumer
Processes supplier alert events from RabbitMQ and sends notifications
Handles email and Slack notifications for critical alerts
"""
import json
import structlog
from typing import Dict, Any, Optional
from datetime import datetime
from uuid import UUID
from shared.messaging import RabbitMQClient
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
logger = structlog.get_logger()
class AlertEventConsumer:
"""
Consumes supplier alert events and sends notifications
Handles email and Slack notifications for critical alerts
"""
def __init__(self, db_session: AsyncSession):
self.db_session = db_session
self.notification_config = self._load_notification_config()
def _load_notification_config(self) -> Dict[str, Any]:
"""
Load notification configuration from environment
Returns:
Configuration dict with email/Slack settings
"""
import os
return {
'enabled': os.getenv('ALERT_NOTIFICATION_ENABLED', 'true').lower() == 'true',
'email': {
'enabled': os.getenv('ALERT_EMAIL_ENABLED', 'true').lower() == 'true',
'recipients': os.getenv('ALERT_EMAIL_RECIPIENTS', 'procurement@company.com').split(','),
'from_address': os.getenv('ALERT_EMAIL_FROM', 'noreply@bakery-ia.com'),
'smtp_host': os.getenv('SMTP_HOST', 'localhost'),
'smtp_port': int(os.getenv('SMTP_PORT', '587')),
'smtp_username': os.getenv('SMTP_USERNAME', ''),
'smtp_password': os.getenv('SMTP_PASSWORD', ''),
'use_tls': os.getenv('SMTP_USE_TLS', 'true').lower() == 'true'
},
'slack': {
'enabled': os.getenv('ALERT_SLACK_ENABLED', 'false').lower() == 'true',
'webhook_url': os.getenv('ALERT_SLACK_WEBHOOK_URL', ''),
'channel': os.getenv('ALERT_SLACK_CHANNEL', '#procurement'),
'username': os.getenv('ALERT_SLACK_USERNAME', 'Supplier Alert Bot')
},
'rate_limiting': {
'enabled': os.getenv('ALERT_RATE_LIMITING_ENABLED', 'true').lower() == 'true',
'max_per_hour': int(os.getenv('ALERT_MAX_PER_HOUR', '10')),
'max_per_day': int(os.getenv('ALERT_MAX_PER_DAY', '50'))
}
}
async def consume_alert_events(
self,
rabbitmq_client: RabbitMQClient
):
"""
Start consuming alert events from RabbitMQ
"""
async def process_message(message):
"""Process a single alert event message"""
try:
async with message.process():
# Parse event data
event_data = json.loads(message.body.decode())
logger.info(
"Received alert event",
event_id=event_data.get('event_id'),
event_type=event_data.get('event_type'),
tenant_id=event_data.get('tenant_id')
)
# Process the event
await self.process_alert_event(event_data)
except Exception as e:
logger.error(
"Error processing alert event",
error=str(e),
exc_info=True
)
# Start consuming events
await rabbitmq_client.consume_events(
exchange_name="suppliers.events",
queue_name="suppliers.alerts.notifications",
routing_key="suppliers.alert.*",
callback=process_message
)
logger.info("Started consuming alert events")
async def process_alert_event(self, event_data: Dict[str, Any]) -> bool:
"""
Process an alert event based on type
Args:
event_data: Full event payload from RabbitMQ
Returns:
bool: True if processed successfully
"""
try:
if not self.notification_config['enabled']:
logger.info("Alert notifications disabled, skipping")
return True
event_type = event_data.get('event_type')
data = event_data.get('data', {})
tenant_id = event_data.get('tenant_id')
if not tenant_id:
logger.warning("Alert event missing tenant_id", event_data=event_data)
return False
# Route to appropriate handler
if event_type == 'suppliers.alert.cost_variance':
success = await self._handle_cost_variance_alert(tenant_id, data)
elif event_type == 'suppliers.alert.quality':
success = await self._handle_quality_alert(tenant_id, data)
elif event_type == 'suppliers.alert.delivery':
success = await self._handle_delivery_alert(tenant_id, data)
else:
logger.warning("Unknown alert event type", event_type=event_type)
success = True # Mark as processed to avoid retry
if success:
logger.info(
"Alert event processed successfully",
event_type=event_type,
tenant_id=tenant_id
)
else:
logger.error(
"Alert event processing failed",
event_type=event_type,
tenant_id=tenant_id
)
return success
except Exception as e:
logger.error(
"Error in process_alert_event",
error=str(e),
event_id=event_data.get('event_id'),
exc_info=True
)
return False
async def _handle_cost_variance_alert(
self,
tenant_id: str,
data: Dict[str, Any]
) -> bool:
"""
Handle cost variance alert notification
Args:
tenant_id: Tenant ID
data: Alert data
Returns:
bool: True if handled successfully
"""
try:
alert_id = data.get('alert_id')
severity = data.get('severity', 'warning')
supplier_name = data.get('supplier_name', 'Unknown Supplier')
ingredient_name = data.get('ingredient_name', 'Unknown Ingredient')
variance_percentage = data.get('variance_percentage', 0)
old_price = data.get('old_price', 0)
new_price = data.get('new_price', 0)
recommendations = data.get('recommendations', [])
# Check rate limiting
if not await self._check_rate_limit(tenant_id, 'cost_variance'):
logger.warning(
"Rate limit exceeded for cost variance alerts",
tenant_id=tenant_id
)
return True # Don't fail, just skip
# Format notification message
notification_data = {
'alert_id': alert_id,
'severity': severity,
'supplier_name': supplier_name,
'ingredient_name': ingredient_name,
'variance_percentage': variance_percentage,
'old_price': old_price,
'new_price': new_price,
'price_change': new_price - old_price,
'recommendations': recommendations,
'alert_url': self._generate_alert_url(tenant_id, alert_id)
}
# Send notifications based on severity
notifications_sent = 0
if severity in ['critical', 'warning']:
# Send email for critical and warning alerts
if await self._send_email_notification(
tenant_id,
'cost_variance',
notification_data
):
notifications_sent += 1
if severity == 'critical':
# Send Slack for critical alerts only
if await self._send_slack_notification(
tenant_id,
'cost_variance',
notification_data
):
notifications_sent += 1
# Record notification sent
await self._record_notification(
tenant_id=tenant_id,
alert_id=alert_id,
notification_type='cost_variance',
channels_sent=notifications_sent
)
logger.info(
"Cost variance alert notification sent",
tenant_id=tenant_id,
alert_id=alert_id,
severity=severity,
notifications_sent=notifications_sent
)
return True
except Exception as e:
logger.error(
"Error handling cost variance alert",
error=str(e),
tenant_id=tenant_id,
alert_id=data.get('alert_id'),
exc_info=True
)
return False
async def _handle_quality_alert(
self,
tenant_id: str,
data: Dict[str, Any]
) -> bool:
"""
Handle quality alert notification
Args:
tenant_id: Tenant ID
data: Alert data
Returns:
bool: True if handled successfully
"""
try:
alert_id = data.get('alert_id')
severity = data.get('severity', 'warning')
supplier_name = data.get('supplier_name', 'Unknown Supplier')
logger.info(
"Processing quality alert",
tenant_id=tenant_id,
alert_id=alert_id,
severity=severity,
supplier=supplier_name
)
# Check rate limiting
if not await self._check_rate_limit(tenant_id, 'quality'):
return True
# For now, just log quality alerts
# In production, would implement email/Slack similar to cost variance
return True
except Exception as e:
logger.error(
"Error handling quality alert",
error=str(e),
tenant_id=tenant_id,
exc_info=True
)
return False
async def _handle_delivery_alert(
self,
tenant_id: str,
data: Dict[str, Any]
) -> bool:
"""
Handle delivery alert notification
Args:
tenant_id: Tenant ID
data: Alert data
Returns:
bool: True if handled successfully
"""
try:
alert_id = data.get('alert_id')
severity = data.get('severity', 'warning')
supplier_name = data.get('supplier_name', 'Unknown Supplier')
logger.info(
"Processing delivery alert",
tenant_id=tenant_id,
alert_id=alert_id,
severity=severity,
supplier=supplier_name
)
# Check rate limiting
if not await self._check_rate_limit(tenant_id, 'delivery'):
return True
# For now, just log delivery alerts
# In production, would implement email/Slack similar to cost variance
return True
except Exception as e:
logger.error(
"Error handling delivery alert",
error=str(e),
tenant_id=tenant_id,
exc_info=True
)
return False
async def _check_rate_limit(
self,
tenant_id: str,
alert_type: str
) -> bool:
"""
Check if notification rate limit has been exceeded using Redis
Args:
tenant_id: Tenant ID
alert_type: Type of alert
Returns:
bool: True if within rate limit, False if exceeded
"""
try:
if not self.notification_config['rate_limiting']['enabled']:
return True
# Redis-based rate limiting implementation
try:
import redis.asyncio as redis
import os
from datetime import datetime, timedelta
# Connect to Redis
redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0')
redis_client = await redis.from_url(redis_url, decode_responses=True)
# Rate limit keys
hour_key = f"alert_rate_limit:{tenant_id}:{alert_type}:hour:{datetime.utcnow().strftime('%Y%m%d%H')}"
day_key = f"alert_rate_limit:{tenant_id}:{alert_type}:day:{datetime.utcnow().strftime('%Y%m%d')}"
# Get current counts
hour_count = await redis_client.get(hour_key)
day_count = await redis_client.get(day_key)
hour_count = int(hour_count) if hour_count else 0
day_count = int(day_count) if day_count else 0
# Check limits
max_per_hour = self.notification_config['rate_limiting']['max_per_hour']
max_per_day = self.notification_config['rate_limiting']['max_per_day']
if hour_count >= max_per_hour:
logger.warning(
"Hourly rate limit exceeded",
tenant_id=tenant_id,
alert_type=alert_type,
count=hour_count,
limit=max_per_hour
)
await redis_client.close()
return False
if day_count >= max_per_day:
logger.warning(
"Daily rate limit exceeded",
tenant_id=tenant_id,
alert_type=alert_type,
count=day_count,
limit=max_per_day
)
await redis_client.close()
return False
# Increment counters
pipe = redis_client.pipeline()
pipe.incr(hour_key)
pipe.expire(hour_key, 3600) # 1 hour TTL
pipe.incr(day_key)
pipe.expire(day_key, 86400) # 24 hour TTL
await pipe.execute()
await redis_client.close()
logger.debug(
"Rate limit check passed",
tenant_id=tenant_id,
alert_type=alert_type,
hour_count=hour_count + 1,
day_count=day_count + 1
)
return True
except ImportError:
logger.warning("Redis not available, skipping rate limiting")
return True
except Exception as e:
logger.error(
"Error checking rate limit",
error=str(e),
tenant_id=tenant_id,
exc_info=True
)
# On error, allow notification
return True
async def _send_email_notification(
self,
tenant_id: str,
notification_type: str,
data: Dict[str, Any]
) -> bool:
"""
Send email notification
Args:
tenant_id: Tenant ID
notification_type: Type of notification
data: Notification data
Returns:
bool: True if sent successfully
"""
try:
if not self.notification_config['email']['enabled']:
logger.debug("Email notifications disabled")
return False
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
# Build email content
subject = self._format_email_subject(notification_type, data)
body = self._format_email_body(notification_type, data)
# Create message
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = self.notification_config['email']['from_address']
msg['To'] = ', '.join(self.notification_config['email']['recipients'])
# Attach HTML body
html_part = MIMEText(body, 'html')
msg.attach(html_part)
# Send email
smtp_config = self.notification_config['email']
with smtplib.SMTP(smtp_config['smtp_host'], smtp_config['smtp_port']) as server:
if smtp_config['use_tls']:
server.starttls()
if smtp_config['smtp_username'] and smtp_config['smtp_password']:
server.login(smtp_config['smtp_username'], smtp_config['smtp_password'])
server.send_message(msg)
logger.info(
"Email notification sent",
tenant_id=tenant_id,
notification_type=notification_type,
recipients=len(self.notification_config['email']['recipients'])
)
return True
except Exception as e:
logger.error(
"Error sending email notification",
error=str(e),
tenant_id=tenant_id,
notification_type=notification_type,
exc_info=True
)
return False
async def _send_slack_notification(
self,
tenant_id: str,
notification_type: str,
data: Dict[str, Any]
) -> bool:
"""
Send Slack notification
Args:
tenant_id: Tenant ID
notification_type: Type of notification
data: Notification data
Returns:
bool: True if sent successfully
"""
try:
if not self.notification_config['slack']['enabled']:
logger.debug("Slack notifications disabled")
return False
webhook_url = self.notification_config['slack']['webhook_url']
if not webhook_url:
logger.warning("Slack webhook URL not configured")
return False
import aiohttp
# Format Slack message
message = self._format_slack_message(notification_type, data)
# Send to Slack
async with aiohttp.ClientSession() as session:
async with session.post(webhook_url, json=message) as response:
if response.status == 200:
logger.info(
"Slack notification sent",
tenant_id=tenant_id,
notification_type=notification_type
)
return True
else:
logger.error(
"Slack notification failed",
status=response.status,
response=await response.text()
)
return False
except Exception as e:
logger.error(
"Error sending Slack notification",
error=str(e),
tenant_id=tenant_id,
notification_type=notification_type,
exc_info=True
)
return False
def _format_email_subject(
self,
notification_type: str,
data: Dict[str, Any]
) -> str:
"""Format email subject line"""
if notification_type == 'cost_variance':
severity = data.get('severity', 'warning').upper()
ingredient = data.get('ingredient_name', 'Unknown')
variance = data.get('variance_percentage', 0)
return f"[{severity}] Price Alert: {ingredient} (+{variance:.1f}%)"
return f"Supplier Alert: {notification_type}"
def _format_email_body(
self,
notification_type: str,
data: Dict[str, Any]
) -> str:
"""Format email body (HTML)"""
if notification_type == 'cost_variance':
severity = data.get('severity', 'warning')
severity_color = '#dc3545' if severity == 'critical' else '#ffc107'
html = f"""
<html>
<head>
<style>
body {{ font-family: Arial, sans-serif; }}
.alert-box {{
border-left: 4px solid {severity_color};
padding: 15px;
background-color: #f8f9fa;
margin: 20px 0;
}}
.metric {{
display: inline-block;
margin: 10px 20px 10px 0;
}}
.metric-label {{
color: #6c757d;
font-size: 12px;
text-transform: uppercase;
}}
.metric-value {{
font-size: 24px;
font-weight: bold;
color: #212529;
}}
.recommendations {{
background-color: #e7f3ff;
border: 1px solid #bee5eb;
padding: 15px;
margin: 20px 0;
}}
.btn {{
display: inline-block;
padding: 10px 20px;
background-color: #007bff;
color: white;
text-decoration: none;
border-radius: 4px;
margin-top: 15px;
}}
</style>
</head>
<body>
<h2>Cost Variance Alert</h2>
<div class="alert-box">
<strong>{data.get('supplier_name')}</strong> - {data.get('ingredient_name')}
<br><br>
<div class="metric">
<div class="metric-label">Previous Price</div>
<div class="metric-value">${data.get('old_price', 0):.2f}</div>
</div>
<div class="metric">
<div class="metric-label">New Price</div>
<div class="metric-value">${data.get('new_price', 0):.2f}</div>
</div>
<div class="metric">
<div class="metric-label">Change</div>
<div class="metric-value" style="color: {severity_color};">
+{data.get('variance_percentage', 0):.1f}%
</div>
</div>
</div>
<div class="recommendations">
<strong>Recommended Actions:</strong>
<ul>
{''.join(f'<li>{rec}</li>' for rec in data.get('recommendations', []))}
</ul>
</div>
<a href="{data.get('alert_url', '#')}" class="btn">View Alert Details</a>
<hr style="margin-top: 30px; border: none; border-top: 1px solid #dee2e6;">
<p style="color: #6c757d; font-size: 12px;">
This is an automated notification from the Bakery IA Supplier Management System.
</p>
</body>
</html>
"""
return html
return "<html><body><p>Alert notification</p></body></html>"
def _format_slack_message(
self,
notification_type: str,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""Format Slack message payload"""
if notification_type == 'cost_variance':
severity = data.get('severity', 'warning')
emoji = ':rotating_light:' if severity == 'critical' else ':warning:'
color = 'danger' if severity == 'critical' else 'warning'
message = {
"username": self.notification_config['slack']['username'],
"channel": self.notification_config['slack']['channel'],
"icon_emoji": emoji,
"attachments": [
{
"color": color,
"title": f"Cost Variance Alert - {data.get('supplier_name')}",
"fields": [
{
"title": "Ingredient",
"value": data.get('ingredient_name'),
"short": True
},
{
"title": "Price Change",
"value": f"+{data.get('variance_percentage', 0):.1f}%",
"short": True
},
{
"title": "Previous Price",
"value": f"${data.get('old_price', 0):.2f}",
"short": True
},
{
"title": "New Price",
"value": f"${data.get('new_price', 0):.2f}",
"short": True
}
],
"text": "*Recommendations:*\n" + "\n".join(
f"{rec}" for rec in data.get('recommendations', [])
),
"footer": "Bakery IA Supplier Management",
"ts": int(datetime.utcnow().timestamp())
}
]
}
return message
return {
"username": self.notification_config['slack']['username'],
"text": f"Alert: {notification_type}"
}
def _generate_alert_url(self, tenant_id: str, alert_id: str) -> str:
"""Generate URL to view alert in dashboard"""
import os
base_url = os.getenv('FRONTEND_BASE_URL', 'http://localhost:3000')
return f"{base_url}/app/suppliers/alerts/{alert_id}"
async def _record_notification(
self,
tenant_id: str,
alert_id: str,
notification_type: str,
channels_sent: int
):
"""
Record that notification was sent
Args:
tenant_id: Tenant ID
alert_id: Alert ID
notification_type: Type of notification
channels_sent: Number of channels sent to
"""
try:
# In production, would store in database:
# - notification_log table
# - Used for rate limiting and audit trail
logger.info(
"Notification recorded",
tenant_id=tenant_id,
alert_id=alert_id,
notification_type=notification_type,
channels_sent=channels_sent
)
except Exception as e:
logger.error(
"Error recording notification",
error=str(e),
alert_id=alert_id
)
# Factory function for creating consumer instance
def create_alert_event_consumer(db_session: AsyncSession) -> AlertEventConsumer:
"""Create alert event consumer instance"""
return AlertEventConsumer(db_session)

View File

@@ -1,414 +0,0 @@
# services/suppliers/app/repositories/delivery_repository.py
"""
Delivery repository for database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_, func, desc
from uuid import UUID
from datetime import datetime, timedelta
from app.models.suppliers import (
Delivery, DeliveryItem, DeliveryStatus,
PurchaseOrder, Supplier
)
from app.repositories.base import BaseRepository
class DeliveryRepository(BaseRepository[Delivery]):
"""Repository for delivery tracking operations"""
def __init__(self, db: Session):
super().__init__(Delivery, db)
def get_by_delivery_number(
self,
tenant_id: UUID,
delivery_number: str
) -> Optional[Delivery]:
"""Get delivery by delivery number within tenant"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.delivery_number == delivery_number
)
)
.first()
)
def get_with_items(self, delivery_id: UUID) -> Optional[Delivery]:
"""Get delivery with all items loaded"""
return (
self.db.query(self.model)
.options(
joinedload(self.model.items),
joinedload(self.model.purchase_order),
joinedload(self.model.supplier)
)
.filter(self.model.id == delivery_id)
.first()
)
def get_by_purchase_order(self, po_id: UUID) -> List[Delivery]:
"""Get all deliveries for a purchase order"""
return (
self.db.query(self.model)
.options(joinedload(self.model.items))
.filter(self.model.purchase_order_id == po_id)
.order_by(self.model.scheduled_date.desc())
.all()
)
def search_deliveries(
self,
tenant_id: UUID,
supplier_id: Optional[UUID] = None,
status: Optional[DeliveryStatus] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
search_term: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[Delivery]:
"""Search deliveries with comprehensive filters"""
query = (
self.db.query(self.model)
.options(
joinedload(self.model.supplier),
joinedload(self.model.purchase_order)
)
.filter(self.model.tenant_id == tenant_id)
)
# Supplier filter
if supplier_id:
query = query.filter(self.model.supplier_id == supplier_id)
# Status filter
if status:
query = query.filter(self.model.status == status)
# Date range filter (scheduled date)
if date_from:
query = query.filter(self.model.scheduled_date >= date_from)
if date_to:
query = query.filter(self.model.scheduled_date <= date_to)
# Search term filter
if search_term:
search_filter = or_(
self.model.delivery_number.ilike(f"%{search_term}%"),
self.model.supplier_delivery_note.ilike(f"%{search_term}%"),
self.model.tracking_number.ilike(f"%{search_term}%"),
self.model.purchase_order.has(PurchaseOrder.po_number.ilike(f"%{search_term}%"))
)
query = query.filter(search_filter)
return (
query.order_by(desc(self.model.scheduled_date))
.limit(limit)
.offset(offset)
.all()
)
def get_scheduled_deliveries(
self,
tenant_id: UUID,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> List[Delivery]:
"""Get scheduled deliveries for a date range"""
if not date_from:
date_from = datetime.utcnow()
if not date_to:
date_to = date_from + timedelta(days=7) # Next week
return (
self.db.query(self.model)
.options(
joinedload(self.model.supplier),
joinedload(self.model.purchase_order)
)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
DeliveryStatus.SCHEDULED,
DeliveryStatus.IN_TRANSIT,
DeliveryStatus.OUT_FOR_DELIVERY
]),
self.model.scheduled_date >= date_from,
self.model.scheduled_date <= date_to
)
)
.order_by(self.model.scheduled_date)
.all()
)
def get_todays_deliveries(self, tenant_id: UUID) -> List[Delivery]:
"""Get deliveries scheduled for today"""
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_end = today_start + timedelta(days=1)
return self.get_scheduled_deliveries(tenant_id, today_start, today_end)
def get_overdue_deliveries(self, tenant_id: UUID) -> List[Delivery]:
"""Get deliveries that are overdue (scheduled in the past but not completed)"""
now = datetime.utcnow()
return (
self.db.query(self.model)
.options(
joinedload(self.model.supplier),
joinedload(self.model.purchase_order)
)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
DeliveryStatus.SCHEDULED,
DeliveryStatus.IN_TRANSIT,
DeliveryStatus.OUT_FOR_DELIVERY
]),
self.model.scheduled_date < now
)
)
.order_by(self.model.scheduled_date)
.all()
)
def generate_delivery_number(self, tenant_id: UUID) -> str:
"""Generate next delivery number for tenant"""
# Get current date
today = datetime.utcnow()
date_prefix = f"DEL{today.strftime('%Y%m%d')}"
# Find highest delivery number for today
latest_delivery = (
self.db.query(self.model.delivery_number)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.delivery_number.like(f"{date_prefix}%")
)
)
.order_by(self.model.delivery_number.desc())
.first()
)
if latest_delivery:
try:
last_number = int(latest_delivery.delivery_number.replace(date_prefix, ""))
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
return f"{date_prefix}{new_number:03d}"
def update_delivery_status(
self,
delivery_id: UUID,
status: DeliveryStatus,
updated_by: UUID,
notes: Optional[str] = None,
update_timestamps: bool = True
) -> Optional[Delivery]:
"""Update delivery status with appropriate timestamps"""
delivery = self.get_by_id(delivery_id)
if not delivery:
return None
old_status = delivery.status
delivery.status = status
delivery.created_by = updated_by # Track who updated
if update_timestamps:
now = datetime.utcnow()
if status == DeliveryStatus.IN_TRANSIT and not delivery.estimated_arrival:
# Set estimated arrival if not already set
delivery.estimated_arrival = now + timedelta(hours=4) # Default 4 hours
elif status == DeliveryStatus.DELIVERED:
delivery.actual_arrival = now
delivery.completed_at = now
elif status == DeliveryStatus.PARTIALLY_DELIVERED:
delivery.actual_arrival = now
elif status == DeliveryStatus.FAILED_DELIVERY:
delivery.actual_arrival = now
# Add status change note
if notes:
existing_notes = delivery.notes or ""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
status_note = f"[{timestamp}] Status: {old_status.value}{status.value}: {notes}"
delivery.notes = f"{existing_notes}\n{status_note}".strip()
delivery.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(delivery)
return delivery
def mark_as_received(
self,
delivery_id: UUID,
received_by: UUID,
inspection_passed: bool = True,
inspection_notes: Optional[str] = None,
quality_issues: Optional[Dict[str, Any]] = None
) -> Optional[Delivery]:
"""Mark delivery as received with inspection details"""
delivery = self.get_by_id(delivery_id)
if not delivery:
return None
delivery.status = DeliveryStatus.DELIVERED
delivery.received_by = received_by
delivery.received_at = datetime.utcnow()
delivery.completed_at = datetime.utcnow()
delivery.inspection_passed = inspection_passed
if inspection_notes:
delivery.inspection_notes = inspection_notes
if quality_issues:
delivery.quality_issues = quality_issues
if not delivery.actual_arrival:
delivery.actual_arrival = datetime.utcnow()
delivery.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(delivery)
return delivery
def get_delivery_performance_stats(
self,
tenant_id: UUID,
days_back: int = 30,
supplier_id: Optional[UUID] = None
) -> Dict[str, Any]:
"""Get delivery performance statistics"""
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
query = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.created_at >= cutoff_date
)
)
)
if supplier_id:
query = query.filter(self.model.supplier_id == supplier_id)
deliveries = query.all()
if not deliveries:
return {
"total_deliveries": 0,
"on_time_deliveries": 0,
"late_deliveries": 0,
"failed_deliveries": 0,
"on_time_percentage": 0.0,
"avg_delay_hours": 0.0,
"quality_pass_rate": 0.0
}
total_count = len(deliveries)
on_time_count = 0
late_count = 0
failed_count = 0
total_delay_hours = 0
quality_pass_count = 0
quality_total = 0
for delivery in deliveries:
if delivery.status == DeliveryStatus.FAILED_DELIVERY:
failed_count += 1
continue
# Check if delivery was on time
if delivery.scheduled_date and delivery.actual_arrival:
if delivery.actual_arrival <= delivery.scheduled_date:
on_time_count += 1
else:
late_count += 1
delay = delivery.actual_arrival - delivery.scheduled_date
total_delay_hours += delay.total_seconds() / 3600
# Check quality inspection
if delivery.inspection_passed is not None:
quality_total += 1
if delivery.inspection_passed:
quality_pass_count += 1
on_time_percentage = (on_time_count / total_count * 100) if total_count > 0 else 0
avg_delay_hours = total_delay_hours / late_count if late_count > 0 else 0
quality_pass_rate = (quality_pass_count / quality_total * 100) if quality_total > 0 else 0
return {
"total_deliveries": total_count,
"on_time_deliveries": on_time_count,
"late_deliveries": late_count,
"failed_deliveries": failed_count,
"on_time_percentage": round(on_time_percentage, 1),
"avg_delay_hours": round(avg_delay_hours, 1),
"quality_pass_rate": round(quality_pass_rate, 1)
}
def get_upcoming_deliveries_summary(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get summary of upcoming deliveries"""
today = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
next_week = today + timedelta(days=7)
# Today's deliveries
todays_deliveries = len(self.get_todays_deliveries(tenant_id))
# This week's deliveries
this_week_deliveries = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.scheduled_date >= today,
self.model.scheduled_date <= next_week,
self.model.status.in_([
DeliveryStatus.SCHEDULED,
DeliveryStatus.IN_TRANSIT,
DeliveryStatus.OUT_FOR_DELIVERY
])
)
)
.count()
)
# Overdue deliveries
overdue_count = len(self.get_overdue_deliveries(tenant_id))
# In transit deliveries
in_transit_count = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == DeliveryStatus.IN_TRANSIT
)
)
.count()
)
return {
"todays_deliveries": todays_deliveries,
"this_week_deliveries": this_week_deliveries,
"overdue_deliveries": overdue_count,
"in_transit_deliveries": in_transit_count
}

View File

@@ -1,297 +0,0 @@
# services/suppliers/app/repositories/purchase_order_item_repository.py
"""
Purchase Order Item repository for database operations
"""
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, func
from uuid import UUID
from datetime import datetime
from app.models.suppliers import PurchaseOrderItem
from app.repositories.base import BaseRepository
class PurchaseOrderItemRepository(BaseRepository[PurchaseOrderItem]):
"""Repository for purchase order item operations"""
def __init__(self, db: Session):
super().__init__(PurchaseOrderItem, db)
def get_by_purchase_order(self, po_id: UUID) -> List[PurchaseOrderItem]:
"""Get all items for a purchase order"""
return (
self.db.query(self.model)
.filter(self.model.purchase_order_id == po_id)
.order_by(self.model.created_at)
.all()
)
def get_by_inventory_product(
self,
tenant_id: UUID,
inventory_product_id: UUID,
limit: int = 20
) -> List[PurchaseOrderItem]:
"""Get recent order items for a specific inventory product"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.inventory_product_id == inventory_product_id
)
)
.order_by(self.model.created_at.desc())
.limit(limit)
.all()
)
def update_received_quantity(
self,
item_id: UUID,
received_quantity: int,
update_remaining: bool = True
) -> Optional[PurchaseOrderItem]:
"""Update received quantity for an item"""
item = self.get_by_id(item_id)
if not item:
return None
item.received_quantity = max(0, received_quantity)
if update_remaining:
item.remaining_quantity = max(0, item.ordered_quantity - item.received_quantity)
item.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(item)
return item
def add_received_quantity(
self,
item_id: UUID,
quantity_to_add: int
) -> Optional[PurchaseOrderItem]:
"""Add to received quantity (for partial deliveries)"""
item = self.get_by_id(item_id)
if not item:
return None
new_received = item.received_quantity + quantity_to_add
new_received = min(new_received, item.ordered_quantity) # Cap at ordered quantity
return self.update_received_quantity(item_id, new_received)
def get_partially_received_items(self, tenant_id: UUID) -> List[PurchaseOrderItem]:
"""Get items that are partially received (have remaining quantity)"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.received_quantity > 0,
self.model.remaining_quantity > 0
)
)
.order_by(self.model.updated_at.desc())
.all()
)
def get_pending_receipt_items(
self,
tenant_id: UUID,
inventory_product_id: Optional[UUID] = None
) -> List[PurchaseOrderItem]:
"""Get items pending receipt (not yet delivered)"""
query = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.remaining_quantity > 0
)
)
)
if inventory_product_id:
query = query.filter(self.model.inventory_product_id == inventory_product_id)
return query.order_by(self.model.created_at).all()
def calculate_line_total(self, item_id: UUID) -> Optional[PurchaseOrderItem]:
"""Recalculate line total for an item"""
item = self.get_by_id(item_id)
if not item:
return None
item.line_total = item.ordered_quantity * item.unit_price
item.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(item)
return item
def get_inventory_product_purchase_history(
self,
tenant_id: UUID,
inventory_product_id: UUID,
days_back: int = 90
) -> Dict[str, Any]:
"""Get purchase history and analytics for an inventory product"""
from datetime import timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Get items within date range
items = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.inventory_product_id == inventory_product_id,
self.model.created_at >= cutoff_date
)
)
.all()
)
if not items:
return {
"total_quantity_ordered": 0,
"total_amount_spent": 0.0,
"average_unit_price": 0.0,
"order_count": 0,
"last_order_date": None,
"price_trend": "stable"
}
# Calculate statistics
total_quantity = sum(item.ordered_quantity for item in items)
total_amount = sum(float(item.line_total) for item in items)
order_count = len(items)
avg_unit_price = total_amount / total_quantity if total_quantity > 0 else 0
last_order_date = max(item.created_at for item in items)
# Price trend analysis (simple)
if order_count >= 2:
sorted_items = sorted(items, key=lambda x: x.created_at)
first_half = sorted_items[:order_count//2]
second_half = sorted_items[order_count//2:]
avg_price_first = sum(float(item.unit_price) for item in first_half) / len(first_half)
avg_price_second = sum(float(item.unit_price) for item in second_half) / len(second_half)
if avg_price_second > avg_price_first * 1.1:
price_trend = "increasing"
elif avg_price_second < avg_price_first * 0.9:
price_trend = "decreasing"
else:
price_trend = "stable"
else:
price_trend = "insufficient_data"
return {
"total_quantity_ordered": total_quantity,
"total_amount_spent": round(total_amount, 2),
"average_unit_price": round(avg_unit_price, 4),
"order_count": order_count,
"last_order_date": last_order_date,
"price_trend": price_trend
}
def get_top_purchased_inventory_products(
self,
tenant_id: UUID,
days_back: int = 30,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get most purchased inventory products by quantity or value"""
from datetime import timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days_back)
# Group by inventory product and calculate totals
results = (
self.db.query(
self.model.inventory_product_id,
self.model.unit_of_measure,
func.sum(self.model.ordered_quantity).label('total_quantity'),
func.sum(self.model.line_total).label('total_amount'),
func.count(self.model.id).label('order_count'),
func.avg(self.model.unit_price).label('avg_unit_price')
)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.created_at >= cutoff_date
)
)
.group_by(
self.model.inventory_product_id,
self.model.unit_of_measure
)
.order_by(func.sum(self.model.line_total).desc())
.limit(limit)
.all()
)
return [
{
"inventory_product_id": str(row.inventory_product_id),
"unit_of_measure": row.unit_of_measure,
"total_quantity": int(row.total_quantity),
"total_amount": round(float(row.total_amount), 2),
"order_count": int(row.order_count),
"avg_unit_price": round(float(row.avg_unit_price), 4)
}
for row in results
]
def bulk_update_items(
self,
po_id: UUID,
item_updates: List[Dict[str, Any]]
) -> List[PurchaseOrderItem]:
"""Bulk update multiple items in a purchase order"""
updated_items = []
for update_data in item_updates:
item_id = update_data.get('id')
if not item_id:
continue
item = (
self.db.query(self.model)
.filter(
and_(
self.model.id == item_id,
self.model.purchase_order_id == po_id
)
)
.first()
)
if item:
# Update allowed fields
for key, value in update_data.items():
if key != 'id' and hasattr(item, key):
setattr(item, key, value)
# Recalculate line total if quantity or price changed
if 'ordered_quantity' in update_data or 'unit_price' in update_data:
item.line_total = item.ordered_quantity * item.unit_price
item.remaining_quantity = item.ordered_quantity - item.received_quantity
item.updated_at = datetime.utcnow()
updated_items.append(item)
self.db.commit()
# Refresh all items
for item in updated_items:
self.db.refresh(item)
return updated_items

View File

@@ -1,255 +0,0 @@
# services/suppliers/app/repositories/purchase_order_repository.py
"""
Purchase Order repository for database operations (Async SQLAlchemy 2.0)
"""
from typing import List, Optional, Dict, Any, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlalchemy import select, and_, or_, func, desc
from uuid import UUID
from datetime import datetime, timedelta
from app.models.suppliers import (
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus,
Supplier, SupplierPriceList
)
from app.repositories.base import BaseRepository
class PurchaseOrderRepository(BaseRepository[PurchaseOrder]):
"""Repository for purchase order management operations"""
def __init__(self, db: AsyncSession):
super().__init__(PurchaseOrder, db)
async def get_by_po_number(self, tenant_id: UUID, po_number: str) -> Optional[PurchaseOrder]:
"""Get purchase order by PO number within tenant"""
stmt = select(self.model).filter(
and_(
self.model.tenant_id == tenant_id,
self.model.po_number == po_number
)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def get_with_items(self, po_id: UUID) -> Optional[PurchaseOrder]:
"""Get purchase order with all items and supplier loaded"""
stmt = (
select(self.model)
.options(
selectinload(self.model.items),
selectinload(self.model.supplier)
)
.filter(self.model.id == po_id)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def search_purchase_orders(
self,
tenant_id: UUID,
supplier_id: Optional[UUID] = None,
status: Optional[PurchaseOrderStatus] = None,
priority: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
search_term: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[PurchaseOrder]:
"""Search purchase orders with comprehensive filters"""
stmt = (
select(self.model)
.options(selectinload(self.model.supplier))
.filter(self.model.tenant_id == tenant_id)
)
# Supplier filter
if supplier_id:
stmt = stmt.filter(self.model.supplier_id == supplier_id)
# Status filter
if status:
stmt = stmt.filter(self.model.status == status)
# Priority filter
if priority:
stmt = stmt.filter(self.model.priority == priority)
# Date range filter
if date_from:
stmt = stmt.filter(self.model.order_date >= date_from)
if date_to:
stmt = stmt.filter(self.model.order_date <= date_to)
# Search term filter (PO number, reference)
if search_term:
search_filter = or_(
self.model.po_number.ilike(f"%{search_term}%"),
self.model.reference_number.ilike(f"%{search_term}%")
)
stmt = stmt.filter(search_filter)
stmt = stmt.order_by(desc(self.model.order_date)).limit(limit).offset(offset)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_orders_by_status(
self,
tenant_id: UUID,
status: PurchaseOrderStatus,
limit: int = 100
) -> List[PurchaseOrder]:
"""Get purchase orders by status"""
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == status
)
)
.order_by(desc(self.model.order_date))
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_pending_approval(
self,
tenant_id: UUID,
limit: int = 50
) -> List[PurchaseOrder]:
"""Get purchase orders pending approval"""
return await self.get_orders_by_status(
tenant_id,
PurchaseOrderStatus.pending_approval,
limit
)
async def get_by_supplier(
self,
tenant_id: UUID,
supplier_id: UUID,
limit: int = 100,
offset: int = 0
) -> List[PurchaseOrder]:
"""Get purchase orders for a specific supplier"""
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_id == supplier_id
)
)
.order_by(desc(self.model.order_date))
.limit(limit)
.offset(offset)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_orders_requiring_delivery(
self,
tenant_id: UUID,
days_ahead: int = 7
) -> List[PurchaseOrder]:
"""Get orders expecting delivery within specified days"""
cutoff_date = datetime.utcnow().date() + timedelta(days=days_ahead)
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
]),
self.model.required_delivery_date <= cutoff_date
)
)
.order_by(self.model.required_delivery_date)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_overdue_deliveries(self, tenant_id: UUID) -> List[PurchaseOrder]:
"""Get purchase orders with overdue deliveries"""
today = datetime.utcnow().date()
stmt = (
select(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed
]),
self.model.required_delivery_date < today
)
)
.order_by(self.model.required_delivery_date)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_total_spent_by_supplier(
self,
tenant_id: UUID,
supplier_id: UUID,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> float:
"""Calculate total amount spent with a supplier"""
stmt = (
select(func.sum(self.model.total_amount))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_id == supplier_id,
self.model.status.in_([
PurchaseOrderStatus.approved,
PurchaseOrderStatus.sent_to_supplier,
PurchaseOrderStatus.confirmed,
PurchaseOrderStatus.received,
PurchaseOrderStatus.completed
])
)
)
)
if date_from:
stmt = stmt.filter(self.model.order_date >= date_from)
if date_to:
stmt = stmt.filter(self.model.order_date <= date_to)
result = await self.db.execute(stmt)
total = result.scalar()
return float(total) if total else 0.0
async def count_by_status(
self,
tenant_id: UUID,
status: PurchaseOrderStatus
) -> int:
"""Count purchase orders by status"""
stmt = (
select(func.count())
.select_from(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == status
)
)
)
result = await self.db.execute(stmt)
return result.scalar() or 0

View File

@@ -1,376 +0,0 @@
# services/suppliers/app/repositories/purchase_order_repository.py
"""
Purchase Order repository for database operations
"""
from typing import List, Optional, Dict, Any, Tuple
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, or_, func, desc
from uuid import UUID
from datetime import datetime, timedelta
from app.models.suppliers import (
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus,
Supplier, SupplierPriceList
)
from app.repositories.base import BaseRepository
class PurchaseOrderRepository(BaseRepository[PurchaseOrder]):
"""Repository for purchase order management operations"""
def __init__(self, db: Session):
super().__init__(PurchaseOrder, db)
def get_by_po_number(self, tenant_id: UUID, po_number: str) -> Optional[PurchaseOrder]:
"""Get purchase order by PO number within tenant"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.po_number == po_number
)
)
.first()
)
def get_with_items(self, po_id: UUID) -> Optional[PurchaseOrder]:
"""Get purchase order with all items loaded"""
return (
self.db.query(self.model)
.options(joinedload(self.model.items))
.filter(self.model.id == po_id)
.first()
)
def search_purchase_orders(
self,
tenant_id: UUID,
supplier_id: Optional[UUID] = None,
status: Optional[PurchaseOrderStatus] = None,
priority: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
search_term: Optional[str] = None,
limit: int = 50,
offset: int = 0
) -> List[PurchaseOrder]:
"""Search purchase orders with comprehensive filters"""
query = (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(self.model.tenant_id == tenant_id)
)
# Supplier filter
if supplier_id:
query = query.filter(self.model.supplier_id == supplier_id)
# Status filter
if status:
query = query.filter(self.model.status == status)
# Priority filter
if priority:
query = query.filter(self.model.priority == priority)
# Date range filter
if date_from:
query = query.filter(self.model.order_date >= date_from)
if date_to:
query = query.filter(self.model.order_date <= date_to)
# Search term filter (PO number, reference, supplier name)
if search_term:
search_filter = or_(
self.model.po_number.ilike(f"%{search_term}%"),
self.model.reference_number.ilike(f"%{search_term}%"),
self.model.supplier.has(Supplier.name.ilike(f"%{search_term}%"))
)
query = query.filter(search_filter)
return (
query.order_by(desc(self.model.order_date))
.limit(limit)
.offset(offset)
.all()
)
def get_orders_by_status(
self,
tenant_id: UUID,
status: PurchaseOrderStatus
) -> List[PurchaseOrder]:
"""Get all orders with specific status"""
return (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == status
)
)
.order_by(self.model.order_date.desc())
.all()
)
def get_orders_requiring_approval(self, tenant_id: UUID) -> List[PurchaseOrder]:
"""Get orders pending approval"""
return (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status == PurchaseOrderStatus.PENDING_APPROVAL,
self.model.requires_approval == True
)
)
.order_by(self.model.order_date.asc())
.all()
)
def get_overdue_orders(self, tenant_id: UUID) -> List[PurchaseOrder]:
"""Get orders that are overdue for delivery"""
today = datetime.utcnow().date()
return (
self.db.query(self.model)
.options(joinedload(self.model.supplier))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.CONFIRMED,
PurchaseOrderStatus.SENT_TO_SUPPLIER,
PurchaseOrderStatus.PARTIALLY_RECEIVED
]),
self.model.required_delivery_date < today
)
)
.order_by(self.model.required_delivery_date.asc())
.all()
)
def get_orders_by_supplier(
self,
tenant_id: UUID,
supplier_id: UUID,
limit: int = 20
) -> List[PurchaseOrder]:
"""Get recent orders for a specific supplier"""
return (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.supplier_id == supplier_id
)
)
.order_by(self.model.order_date.desc())
.limit(limit)
.all()
)
def generate_po_number(self, tenant_id: UUID) -> str:
"""Generate next PO number for tenant"""
# Get current year
current_year = datetime.utcnow().year
# Find highest PO number for current year
year_prefix = f"PO{current_year}"
latest_po = (
self.db.query(self.model.po_number)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.po_number.like(f"{year_prefix}%")
)
)
.order_by(self.model.po_number.desc())
.first()
)
if latest_po:
# Extract number and increment
try:
last_number = int(latest_po.po_number.replace(year_prefix, ""))
new_number = last_number + 1
except ValueError:
new_number = 1
else:
new_number = 1
return f"{year_prefix}{new_number:04d}"
def update_order_status(
self,
po_id: UUID,
status: PurchaseOrderStatus,
updated_by: UUID,
notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Update purchase order status with audit trail"""
po = self.get_by_id(po_id)
if not po:
return None
po.status = status
po.updated_by = updated_by
po.updated_at = datetime.utcnow()
if notes:
existing_notes = po.internal_notes or ""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
po.internal_notes = f"{existing_notes}\n[{timestamp}] Status changed to {status.value}: {notes}".strip()
# Set specific timestamps based on status
if status == PurchaseOrderStatus.APPROVED:
po.approved_at = datetime.utcnow()
elif status == PurchaseOrderStatus.SENT_TO_SUPPLIER:
po.sent_to_supplier_at = datetime.utcnow()
elif status == PurchaseOrderStatus.CONFIRMED:
po.supplier_confirmation_date = datetime.utcnow()
self.db.commit()
self.db.refresh(po)
return po
def approve_order(
self,
po_id: UUID,
approved_by: UUID,
approval_notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Approve a purchase order"""
po = self.get_by_id(po_id)
if not po or po.status != PurchaseOrderStatus.PENDING_APPROVAL:
return None
po.status = PurchaseOrderStatus.APPROVED
po.approved_by = approved_by
po.approved_at = datetime.utcnow()
po.updated_by = approved_by
po.updated_at = datetime.utcnow()
if approval_notes:
po.internal_notes = (po.internal_notes or "") + f"\nApproval notes: {approval_notes}"
self.db.commit()
self.db.refresh(po)
return po
def calculate_order_totals(self, po_id: UUID) -> Optional[PurchaseOrder]:
"""Recalculate order totals based on line items"""
po = self.get_with_items(po_id)
if not po:
return None
# Calculate subtotal from items
subtotal = sum(item.line_total for item in po.items)
# Keep existing tax, shipping, and discount
tax_amount = po.tax_amount or 0
shipping_cost = po.shipping_cost or 0
discount_amount = po.discount_amount or 0
# Calculate total
total_amount = subtotal + tax_amount + shipping_cost - discount_amount
# Update PO
po.subtotal = subtotal
po.total_amount = max(0, total_amount) # Ensure non-negative
po.updated_at = datetime.utcnow()
self.db.commit()
self.db.refresh(po)
return po
def get_purchase_order_statistics(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get purchase order statistics for dashboard"""
# Total orders
total_orders = self.count_by_tenant(tenant_id)
# Orders by status
status_counts = (
self.db.query(
self.model.status,
func.count(self.model.id).label('count')
)
.filter(self.model.tenant_id == tenant_id)
.group_by(self.model.status)
.all()
)
status_dict = {status.value: 0 for status in PurchaseOrderStatus}
for status, count in status_counts:
status_dict[status.value] = count
# This month's orders
first_day_month = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
this_month_orders = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.order_date >= first_day_month
)
)
.count()
)
# Total spend this month
this_month_spend = (
self.db.query(func.sum(self.model.total_amount))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.order_date >= first_day_month,
self.model.status != PurchaseOrderStatus.CANCELLED
)
)
.scalar()
) or 0.0
# Average order value
avg_order_value = (
self.db.query(func.avg(self.model.total_amount))
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status != PurchaseOrderStatus.CANCELLED
)
)
.scalar()
) or 0.0
# Overdue orders count
today = datetime.utcnow().date()
overdue_count = (
self.db.query(self.model)
.filter(
and_(
self.model.tenant_id == tenant_id,
self.model.status.in_([
PurchaseOrderStatus.CONFIRMED,
PurchaseOrderStatus.SENT_TO_SUPPLIER,
PurchaseOrderStatus.PARTIALLY_RECEIVED
]),
self.model.required_delivery_date < today
)
)
.count()
)
return {
"total_orders": total_orders,
"status_counts": status_dict,
"this_month_orders": this_month_orders,
"this_month_spend": float(this_month_spend),
"avg_order_value": round(float(avg_order_value), 2),
"overdue_count": overdue_count,
"pending_approval": status_dict.get(PurchaseOrderStatus.PENDING_APPROVAL.value, 0)
}

View File

@@ -84,6 +84,20 @@ class SupplierRepository(BaseRepository[Supplier]):
).order_by(self.model.name)
result = await self.db.execute(stmt)
return result.scalars().all()
async def get_suppliers_by_ids(self, tenant_id: UUID, supplier_ids: List[UUID]) -> List[Supplier]:
"""Get multiple suppliers by IDs in a single query (batch fetch)"""
if not supplier_ids:
return []
stmt = select(self.model).filter(
and_(
self.model.tenant_id == tenant_id,
self.model.id.in_(supplier_ids)
)
).order_by(self.model.name)
result = await self.db.execute(stmt)
return result.scalars().all()
def get_suppliers_by_type(
self,

View File

@@ -15,8 +15,19 @@ from app.models.suppliers import (
)
# NOTE: PO, Delivery, and Invoice schemas remain for backward compatibility
# but the actual tables and functionality have moved to Procurement Service
# TODO: These schemas should be removed once all clients migrate to Procurement Service
# The primary implementation has moved to Procurement Service (services/procurement/)
# These schemas support legacy endpoints in suppliers service (app/api/purchase_orders.py)
#
# Migration Status:
# - ✅ Procurement Service fully operational with enhanced features
# - ⚠️ Supplier service endpoints still active for backward compatibility
# - 📋 Deprecation Timeline: Q2 2026 (after 6-month dual-operation period)
#
# Action Required:
# 1. All new integrations should use Procurement Service endpoints
# 2. Update client applications to use ProcurementServiceClient
# 3. Monitor usage of supplier service PO endpoints via logs
# 4. Plan migration of remaining clients by Q1 2026
# ============================================================================

View File

@@ -1,355 +0,0 @@
# services/suppliers/app/services/delivery_service.py
"""
Delivery service for business logic operations
"""
import structlog
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime
from sqlalchemy.orm import Session
from app.repositories.delivery_repository import DeliveryRepository
from app.repositories.purchase_order_repository import PurchaseOrderRepository
from app.repositories.supplier_repository import SupplierRepository
from app.models.suppliers import (
Delivery, DeliveryItem, DeliveryStatus,
PurchaseOrder, PurchaseOrderStatus, SupplierStatus
)
from app.schemas.suppliers import (
DeliveryCreate, DeliveryUpdate, DeliverySearchParams
)
from app.core.config import settings
logger = structlog.get_logger()
class DeliveryService:
"""Service for delivery management operations"""
def __init__(self, db: Session):
self.db = db
self.repository = DeliveryRepository(db)
self.po_repository = PurchaseOrderRepository(db)
self.supplier_repository = SupplierRepository(db)
async def create_delivery(
self,
tenant_id: UUID,
delivery_data: DeliveryCreate,
created_by: UUID
) -> Delivery:
"""Create a new delivery"""
logger.info(
"Creating delivery",
tenant_id=str(tenant_id),
po_id=str(delivery_data.purchase_order_id)
)
# Validate purchase order exists and belongs to tenant
po = self.po_repository.get_by_id(delivery_data.purchase_order_id)
if not po:
raise ValueError("Purchase order not found")
if po.tenant_id != tenant_id:
raise ValueError("Purchase order does not belong to this tenant")
if po.status not in [
PurchaseOrderStatus.CONFIRMED,
PurchaseOrderStatus.PARTIALLY_RECEIVED
]:
raise ValueError("Purchase order must be confirmed before creating deliveries")
# Validate supplier
supplier = self.supplier_repository.get_by_id(delivery_data.supplier_id)
if not supplier:
raise ValueError("Supplier not found")
if supplier.id != po.supplier_id:
raise ValueError("Supplier does not match purchase order supplier")
# Generate delivery number
delivery_number = self.repository.generate_delivery_number(tenant_id)
# Create delivery
delivery_create_data = delivery_data.model_dump(exclude={'items'})
delivery_create_data.update({
'tenant_id': tenant_id,
'delivery_number': delivery_number,
'status': DeliveryStatus.SCHEDULED,
'created_by': created_by
})
# Set default scheduled date if not provided
if not delivery_create_data.get('scheduled_date'):
delivery_create_data['scheduled_date'] = datetime.utcnow()
delivery = self.repository.create(delivery_create_data)
# Create delivery items
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
item_repo = PurchaseOrderItemRepository(self.db)
for item_data in delivery_data.items:
# Validate purchase order item
po_item = item_repo.get_by_id(item_data.purchase_order_item_id)
if not po_item or po_item.purchase_order_id != po.id:
raise ValueError("Invalid purchase order item")
# Create delivery item
from app.models.suppliers import DeliveryItem
item_create_data = item_data.model_dump()
item_create_data.update({
'tenant_id': tenant_id,
'delivery_id': delivery.id
})
delivery_item = DeliveryItem(**item_create_data)
self.db.add(delivery_item)
self.db.commit()
logger.info(
"Delivery created successfully",
tenant_id=str(tenant_id),
delivery_id=str(delivery.id),
delivery_number=delivery_number
)
return delivery
async def get_delivery(self, delivery_id: UUID) -> Optional[Delivery]:
"""Get delivery by ID with items"""
return self.repository.get_with_items(delivery_id)
async def update_delivery(
self,
delivery_id: UUID,
delivery_data: DeliveryUpdate,
updated_by: UUID
) -> Optional[Delivery]:
"""Update delivery information"""
logger.info("Updating delivery", delivery_id=str(delivery_id))
delivery = self.repository.get_by_id(delivery_id)
if not delivery:
return None
# Check if delivery can be modified
if delivery.status in [DeliveryStatus.DELIVERED, DeliveryStatus.FAILED_DELIVERY]:
raise ValueError("Cannot modify completed deliveries")
# Prepare update data
update_data = delivery_data.model_dump(exclude_unset=True)
update_data['created_by'] = updated_by # Track who updated
update_data['updated_at'] = datetime.utcnow()
delivery = self.repository.update(delivery_id, update_data)
logger.info("Delivery updated successfully", delivery_id=str(delivery_id))
return delivery
async def update_delivery_status(
self,
delivery_id: UUID,
status: DeliveryStatus,
updated_by: UUID,
notes: Optional[str] = None,
update_timestamps: bool = True
) -> Optional[Delivery]:
"""Update delivery status"""
logger.info("Updating delivery status", delivery_id=str(delivery_id), status=status.value)
return self.repository.update_delivery_status(
delivery_id=delivery_id,
status=status,
updated_by=updated_by,
notes=notes,
update_timestamps=update_timestamps
)
async def mark_as_received(
self,
delivery_id: UUID,
received_by: UUID,
inspection_passed: bool = True,
inspection_notes: Optional[str] = None,
quality_issues: Optional[Dict[str, Any]] = None,
notes: Optional[str] = None
) -> Optional[Delivery]:
"""Mark delivery as received with inspection details"""
logger.info("Marking delivery as received", delivery_id=str(delivery_id))
delivery = self.repository.mark_as_received(
delivery_id=delivery_id,
received_by=received_by,
inspection_passed=inspection_passed,
inspection_notes=inspection_notes,
quality_issues=quality_issues
)
if not delivery:
return None
# Add custom notes if provided
if notes:
existing_notes = delivery.notes or ""
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
delivery.notes = f"{existing_notes}\n[{timestamp}] Receipt notes: {notes}".strip()
self.repository.update(delivery_id, {'notes': delivery.notes})
# Update purchase order item received quantities
await self._update_purchase_order_received_quantities(delivery)
# Check if purchase order is fully received
await self._check_purchase_order_completion(delivery.purchase_order_id)
logger.info("Delivery marked as received", delivery_id=str(delivery_id))
return delivery
async def search_deliveries(
self,
tenant_id: UUID,
search_params: DeliverySearchParams
) -> List[Delivery]:
"""Search deliveries with filters"""
return self.repository.search_deliveries(
tenant_id=tenant_id,
supplier_id=search_params.supplier_id,
status=search_params.status,
date_from=search_params.date_from,
date_to=search_params.date_to,
search_term=search_params.search_term,
limit=search_params.limit,
offset=search_params.offset
)
async def get_deliveries_by_purchase_order(self, po_id: UUID) -> List[Delivery]:
"""Get all deliveries for a purchase order"""
return self.repository.get_by_purchase_order(po_id)
async def get_todays_deliveries(self, tenant_id: UUID) -> List[Delivery]:
"""Get deliveries scheduled for today"""
return self.repository.get_todays_deliveries(tenant_id)
async def get_overdue_deliveries(self, tenant_id: UUID) -> List[Delivery]:
"""Get overdue deliveries"""
return self.repository.get_overdue_deliveries(tenant_id)
async def get_scheduled_deliveries(
self,
tenant_id: UUID,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None
) -> List[Delivery]:
"""Get scheduled deliveries for date range"""
return self.repository.get_scheduled_deliveries(tenant_id, date_from, date_to)
async def get_delivery_performance_stats(
self,
tenant_id: UUID,
days_back: int = 30,
supplier_id: Optional[UUID] = None
) -> Dict[str, Any]:
"""Get delivery performance statistics"""
return self.repository.get_delivery_performance_stats(
tenant_id, days_back, supplier_id
)
async def get_upcoming_deliveries_summary(self, tenant_id: UUID) -> Dict[str, Any]:
"""Get summary of upcoming deliveries"""
return self.repository.get_upcoming_deliveries_summary(tenant_id)
async def _update_purchase_order_received_quantities(self, delivery: Delivery):
"""Update purchase order item received quantities based on delivery"""
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
item_repo = PurchaseOrderItemRepository(self.db)
# Get delivery items with accepted quantities
delivery_with_items = self.repository.get_with_items(delivery.id)
if not delivery_with_items or not delivery_with_items.items:
return
for delivery_item in delivery_with_items.items:
# Update purchase order item received quantity
item_repo.add_received_quantity(
delivery_item.purchase_order_item_id,
delivery_item.accepted_quantity
)
async def _check_purchase_order_completion(self, po_id: UUID):
"""Check if purchase order is fully received and update status"""
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
item_repo = PurchaseOrderItemRepository(self.db)
po_items = item_repo.get_by_purchase_order(po_id)
if not po_items:
return
# Check if all items are fully received
fully_received = all(item.remaining_quantity == 0 for item in po_items)
partially_received = any(item.received_quantity > 0 for item in po_items)
if fully_received:
# Mark purchase order as completed
self.po_repository.update_order_status(
po_id,
PurchaseOrderStatus.COMPLETED,
po_items[0].tenant_id, # Use tenant_id as updated_by placeholder
"All items received"
)
elif partially_received:
# Mark as partially received if not already
po = self.po_repository.get_by_id(po_id)
if po and po.status == PurchaseOrderStatus.CONFIRMED:
self.po_repository.update_order_status(
po_id,
PurchaseOrderStatus.PARTIALLY_RECEIVED,
po.tenant_id, # Use tenant_id as updated_by placeholder
"Partial delivery received"
)
async def generate_delivery_tracking_info(self, delivery_id: UUID) -> Dict[str, Any]:
"""Generate delivery tracking information"""
delivery = self.repository.get_with_items(delivery_id)
if not delivery:
return {}
# Calculate delivery metrics
total_items = len(delivery.items) if delivery.items else 0
delivered_items = sum(
1 for item in (delivery.items or [])
if item.delivered_quantity > 0
)
accepted_items = sum(
1 for item in (delivery.items or [])
if item.accepted_quantity > 0
)
rejected_items = sum(
1 for item in (delivery.items or [])
if item.rejected_quantity > 0
)
# Calculate timing metrics
on_time = False
delay_hours = 0
if delivery.scheduled_date and delivery.actual_arrival:
delay_seconds = (delivery.actual_arrival - delivery.scheduled_date).total_seconds()
delay_hours = delay_seconds / 3600
on_time = delay_hours <= 0
return {
"delivery_id": str(delivery.id),
"delivery_number": delivery.delivery_number,
"status": delivery.status.value,
"total_items": total_items,
"delivered_items": delivered_items,
"accepted_items": accepted_items,
"rejected_items": rejected_items,
"inspection_passed": delivery.inspection_passed,
"on_time": on_time,
"delay_hours": round(delay_hours, 1) if delay_hours > 0 else 0,
"quality_issues": delivery.quality_issues or {},
"scheduled_date": delivery.scheduled_date.isoformat() if delivery.scheduled_date else None,
"actual_arrival": delivery.actual_arrival.isoformat() if delivery.actual_arrival else None,
"completed_at": delivery.completed_at.isoformat() if delivery.completed_at else None
}

View File

@@ -648,15 +648,216 @@ class AlertService:
return alerts
async def _evaluate_cost_alerts(
self,
db: AsyncSession,
supplier: Supplier,
self,
db: AsyncSession,
supplier: Supplier,
metrics: Dict[PerformanceMetricType, SupplierPerformanceMetric]
) -> List[SupplierAlert]:
"""Evaluate cost variance alerts"""
"""Evaluate cost variance alerts based on historical pricing"""
alerts = []
# For now, return empty list - cost analysis requires market data
# TODO: Implement cost variance analysis when price benchmarks are available
try:
from shared.clients.procurement_client import ProcurementServiceClient
from shared.config.base import get_settings
from datetime import timedelta
from collections import defaultdict
from decimal import Decimal
# Configuration thresholds
WARNING_THRESHOLD = Decimal('0.10') # 10% variance
CRITICAL_THRESHOLD = Decimal('0.20') # 20% variance
SAVINGS_THRESHOLD = Decimal('0.10') # 10% decrease
MIN_SAMPLE_SIZE = 3
LOOKBACK_DAYS = 30
config = get_settings()
procurement_client = ProcurementServiceClient(config, "suppliers")
# Get purchase orders for this supplier from last 60 days (30 days lookback + 30 days current)
date_to = datetime.now(timezone.utc).date()
date_from = date_to - timedelta(days=LOOKBACK_DAYS * 2)
purchase_orders = await procurement_client.get_purchase_orders_by_supplier(
tenant_id=str(supplier.tenant_id),
supplier_id=str(supplier.id),
date_from=date_from,
date_to=date_to,
status=None # Get all statuses
)
if not purchase_orders or len(purchase_orders) < MIN_SAMPLE_SIZE:
self.logger.debug("Insufficient purchase order history for cost variance analysis",
supplier_id=str(supplier.id),
po_count=len(purchase_orders) if purchase_orders else 0)
return alerts
# Group items by ingredient/product and calculate price statistics
ingredient_prices = defaultdict(list)
cutoff_date = date_to - timedelta(days=LOOKBACK_DAYS)
for po in purchase_orders:
po_date = datetime.fromisoformat(po.get('created_at').replace('Z', '+00:00')).date() if po.get('created_at') else None
if not po_date:
continue
# Process items in the PO
for item in po.get('items', []):
ingredient_id = item.get('ingredient_id')
ingredient_name = item.get('ingredient_name') or item.get('product_name', 'Unknown')
unit_price = Decimal(str(item.get('unit_price', 0)))
if not ingredient_id or unit_price <= 0:
continue
# Categorize as historical (for baseline) or recent (for comparison)
is_recent = po_date >= cutoff_date
ingredient_prices[ingredient_id].append({
'price': unit_price,
'date': po_date,
'name': ingredient_name,
'is_recent': is_recent
})
# Analyze each ingredient for cost variance
for ingredient_id, price_history in ingredient_prices.items():
if len(price_history) < MIN_SAMPLE_SIZE:
continue
# Split into historical baseline and recent prices
historical_prices = [p['price'] for p in price_history if not p['is_recent']]
recent_prices = [p['price'] for p in price_history if p['is_recent']]
if not historical_prices or not recent_prices:
continue
# Calculate averages
avg_historical = sum(historical_prices) / len(historical_prices)
avg_recent = sum(recent_prices) / len(recent_prices)
if avg_historical == 0:
continue
# Calculate variance
variance = (avg_recent - avg_historical) / avg_historical
ingredient_name = price_history[0]['name']
# Generate alerts based on variance
if variance >= CRITICAL_THRESHOLD:
# Critical price increase alert
alert = SupplierAlert(
tenant_id=supplier.tenant_id,
supplier_id=supplier.id,
alert_type=AlertType.cost_variance,
severity=AlertSeverity.critical,
status=AlertStatus.active,
title=f"Critical Price Increase: {ingredient_name}",
description=(
f"Significant price increase detected for {ingredient_name}. "
f"Average price increased from ${avg_historical:.2f} to ${avg_recent:.2f} "
f"({variance * 100:.1f}% increase) over the last {LOOKBACK_DAYS} days."
),
affected_products=ingredient_name,
detection_date=datetime.now(timezone.utc),
metadata={
"ingredient_id": str(ingredient_id),
"ingredient_name": ingredient_name,
"avg_historical_price": float(avg_historical),
"avg_recent_price": float(avg_recent),
"variance_percent": float(variance * 100),
"historical_sample_size": len(historical_prices),
"recent_sample_size": len(recent_prices),
"lookback_days": LOOKBACK_DAYS
},
recommended_actions=[
{"action": "Contact supplier to negotiate pricing"},
{"action": "Request explanation for price increase"},
{"action": "Evaluate alternative suppliers for this ingredient"},
{"action": "Review contract terms and pricing agreements"}
]
)
db.add(alert)
alerts.append(alert)
elif variance >= WARNING_THRESHOLD:
# Warning price increase alert
alert = SupplierAlert(
tenant_id=supplier.tenant_id,
supplier_id=supplier.id,
alert_type=AlertType.cost_variance,
severity=AlertSeverity.warning,
status=AlertStatus.active,
title=f"Price Increase Detected: {ingredient_name}",
description=(
f"Moderate price increase detected for {ingredient_name}. "
f"Average price increased from ${avg_historical:.2f} to ${avg_recent:.2f} "
f"({variance * 100:.1f}% increase) over the last {LOOKBACK_DAYS} days."
),
affected_products=ingredient_name,
detection_date=datetime.now(timezone.utc),
metadata={
"ingredient_id": str(ingredient_id),
"ingredient_name": ingredient_name,
"avg_historical_price": float(avg_historical),
"avg_recent_price": float(avg_recent),
"variance_percent": float(variance * 100),
"historical_sample_size": len(historical_prices),
"recent_sample_size": len(recent_prices),
"lookback_days": LOOKBACK_DAYS
},
recommended_actions=[
{"action": "Monitor pricing trend over next few orders"},
{"action": "Contact supplier to discuss pricing"},
{"action": "Review market prices for this ingredient"}
]
)
db.add(alert)
alerts.append(alert)
elif variance <= -SAVINGS_THRESHOLD:
# Cost savings opportunity alert
alert = SupplierAlert(
tenant_id=supplier.tenant_id,
supplier_id=supplier.id,
alert_type=AlertType.cost_variance,
severity=AlertSeverity.info,
status=AlertStatus.active,
title=f"Cost Savings Opportunity: {ingredient_name}",
description=(
f"Favorable price decrease detected for {ingredient_name}. "
f"Average price decreased from ${avg_historical:.2f} to ${avg_recent:.2f} "
f"({abs(variance) * 100:.1f}% decrease) over the last {LOOKBACK_DAYS} days. "
f"Consider increasing order volumes to capitalize on lower pricing."
),
affected_products=ingredient_name,
detection_date=datetime.now(timezone.utc),
metadata={
"ingredient_id": str(ingredient_id),
"ingredient_name": ingredient_name,
"avg_historical_price": float(avg_historical),
"avg_recent_price": float(avg_recent),
"variance_percent": float(variance * 100),
"historical_sample_size": len(historical_prices),
"recent_sample_size": len(recent_prices),
"lookback_days": LOOKBACK_DAYS
},
recommended_actions=[
{"action": "Consider increasing order quantities"},
{"action": "Negotiate long-term pricing lock at current rates"},
{"action": "Update forecast to account for favorable pricing"}
]
)
db.add(alert)
alerts.append(alert)
if alerts:
self.logger.info("Cost variance alerts generated",
supplier_id=str(supplier.id),
alert_count=len(alerts))
except Exception as e:
self.logger.error("Error evaluating cost variance alerts",
supplier_id=str(supplier.id),
error=str(e),
exc_info=True)
return alerts

View File

@@ -1,540 +0,0 @@
# services/suppliers/app/services/purchase_order_service.py
"""
Purchase Order service for business logic operations
"""
import structlog
from typing import List, Optional, Dict, Any
from uuid import UUID
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from decimal import Decimal
from app.repositories.purchase_order_repository import PurchaseOrderRepository
from app.repositories.purchase_order_item_repository import PurchaseOrderItemRepository
from app.repositories.supplier_repository import SupplierRepository
from app.models.suppliers import (
PurchaseOrder, PurchaseOrderItem, PurchaseOrderStatus, SupplierStatus
)
from app.schemas.suppliers import (
PurchaseOrderCreate, PurchaseOrderUpdate, PurchaseOrderSearchParams,
PurchaseOrderItemCreate, PurchaseOrderItemUpdate
)
from app.core.config import settings
logger = structlog.get_logger()
class PurchaseOrderService:
"""Service for purchase order management operations"""
def __init__(self, db: Session):
self.db = db
self.repository = PurchaseOrderRepository(db)
self.item_repository = PurchaseOrderItemRepository(db)
self.supplier_repository = SupplierRepository(db)
async def create_purchase_order(
self,
tenant_id: UUID,
po_data: PurchaseOrderCreate,
created_by: UUID
) -> PurchaseOrder:
"""Create a new purchase order with items"""
logger.info(
"Creating purchase order",
tenant_id=str(tenant_id),
supplier_id=str(po_data.supplier_id)
)
# Validate supplier exists and is active
supplier = self.supplier_repository.get_by_id(po_data.supplier_id)
if not supplier:
raise ValueError("Supplier not found")
if supplier.status != SupplierStatus.ACTIVE:
raise ValueError("Cannot create orders for inactive suppliers")
if supplier.tenant_id != tenant_id:
raise ValueError("Supplier does not belong to this tenant")
# Generate PO number
po_number = self.repository.generate_po_number(tenant_id)
# Calculate totals from items
subtotal = sum(
item.ordered_quantity * item.unit_price
for item in po_data.items
)
total_amount = (
subtotal +
po_data.tax_amount +
po_data.shipping_cost -
po_data.discount_amount
)
# Determine if approval is required
requires_approval = (
total_amount >= settings.MANAGER_APPROVAL_THRESHOLD or
po_data.priority == "urgent"
)
# Set initial status
if requires_approval:
status = PurchaseOrderStatus.PENDING_APPROVAL
elif total_amount <= settings.AUTO_APPROVE_THRESHOLD:
status = PurchaseOrderStatus.APPROVED
else:
status = PurchaseOrderStatus.DRAFT
# Create purchase order
po_create_data = po_data.model_dump(exclude={'items'})
po_create_data.update({
'tenant_id': tenant_id,
'po_number': po_number,
'status': status,
'subtotal': subtotal,
'total_amount': total_amount,
'order_date': datetime.utcnow(),
'requires_approval': requires_approval,
'currency': supplier.currency,
'created_by': created_by,
'updated_by': created_by
})
# Set delivery date if not provided
if not po_create_data.get('required_delivery_date'):
po_create_data['required_delivery_date'] = (
datetime.utcnow() + timedelta(days=supplier.standard_lead_time)
)
purchase_order = self.repository.create(po_create_data)
# Create purchase order items
for item_data in po_data.items:
item_create_data = item_data.model_dump()
item_create_data.update({
'tenant_id': tenant_id,
'purchase_order_id': purchase_order.id,
'line_total': item_data.ordered_quantity * item_data.unit_price,
'remaining_quantity': item_data.ordered_quantity
})
self.item_repository.create(item_create_data)
logger.info(
"Purchase order created successfully",
tenant_id=str(tenant_id),
po_id=str(purchase_order.id),
po_number=po_number,
total_amount=float(total_amount)
)
return purchase_order
async def get_purchase_order(self, po_id: UUID) -> Optional[PurchaseOrder]:
"""Get purchase order by ID with items"""
return await self.repository.get_with_items(po_id)
async def update_purchase_order(
self,
po_id: UUID,
po_data: PurchaseOrderUpdate,
updated_by: UUID
) -> Optional[PurchaseOrder]:
"""Update purchase order information"""
logger.info("Updating purchase order", po_id=str(po_id))
po = self.repository.get_by_id(po_id)
if not po:
return None
# Check if order can be modified
if po.status in [
PurchaseOrderStatus.COMPLETED,
PurchaseOrderStatus.CANCELLED
]:
raise ValueError("Cannot modify completed or cancelled orders")
# Prepare update data
update_data = po_data.model_dump(exclude_unset=True)
update_data['updated_by'] = updated_by
update_data['updated_at'] = datetime.utcnow()
# Recalculate totals if financial fields changed
if any(key in update_data for key in ['tax_amount', 'shipping_cost', 'discount_amount']):
po = self.repository.calculate_order_totals(po_id)
po = self.repository.update(po_id, update_data)
logger.info("Purchase order updated successfully", po_id=str(po_id))
return po
async def update_order_status(
self,
po_id: UUID,
status: PurchaseOrderStatus,
updated_by: UUID,
notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Update purchase order status"""
logger.info("Updating PO status", po_id=str(po_id), status=status.value)
po = self.repository.get_by_id(po_id)
if not po:
return None
# Validate status transition
if not self._is_valid_status_transition(po.status, status):
raise ValueError(f"Invalid status transition from {po.status.value} to {status.value}")
return self.repository.update_order_status(po_id, status, updated_by, notes)
async def approve_purchase_order(
self,
po_id: UUID,
approved_by: UUID,
approval_notes: Optional[str] = None
) -> Optional[PurchaseOrder]:
"""Approve a purchase order"""
logger.info("Approving purchase order", po_id=str(po_id))
po = self.repository.approve_order(po_id, approved_by, approval_notes)
if not po:
logger.warning("Failed to approve PO - not found or not pending approval")
return None
logger.info("Purchase order approved successfully", po_id=str(po_id))
return po
async def reject_purchase_order(
self,
po_id: UUID,
rejection_reason: str,
rejected_by: UUID
) -> Optional[PurchaseOrder]:
"""Reject a purchase order"""
logger.info("Rejecting purchase order", po_id=str(po_id))
po = self.repository.get_by_id(po_id)
if not po or po.status != PurchaseOrderStatus.PENDING_APPROVAL:
return None
update_data = {
'status': PurchaseOrderStatus.CANCELLED,
'rejection_reason': rejection_reason,
'approved_by': rejected_by,
'approved_at': datetime.utcnow(),
'updated_by': rejected_by,
'updated_at': datetime.utcnow()
}
po = self.repository.update(po_id, update_data)
logger.info("Purchase order rejected successfully", po_id=str(po_id))
return po
async def send_to_supplier(
self,
po_id: UUID,
sent_by: UUID,
send_email: bool = True
) -> Optional[PurchaseOrder]:
"""Send purchase order to supplier"""
logger.info("Sending PO to supplier", po_id=str(po_id))
po = self.repository.get_by_id(po_id)
if not po:
return None
if po.status != PurchaseOrderStatus.APPROVED:
raise ValueError("Only approved orders can be sent to suppliers")
# Update status and timestamp
po = self.repository.update_order_status(
po_id,
PurchaseOrderStatus.SENT_TO_SUPPLIER,
sent_by,
"Order sent to supplier"
)
# Send email to supplier if requested
if send_email:
try:
supplier = self.supplier_repository.get_by_id(po.supplier_id)
if supplier and supplier.email:
from shared.clients.notification_client import create_notification_client
notification_client = create_notification_client(settings)
# Prepare email content
subject = f"Purchase Order {po.po_number} from {po.tenant_id}"
message = f"""
Dear {supplier.name},
We are sending you Purchase Order #{po.po_number}.
Order Details:
- PO Number: {po.po_number}
- Expected Delivery: {po.expected_delivery_date}
- Total Amount: €{po.total_amount}
Please confirm receipt of this purchase order.
Best regards
"""
await notification_client.send_email(
tenant_id=str(po.tenant_id),
to_email=supplier.email,
subject=subject,
message=message,
priority="normal"
)
logger.info("Email sent to supplier",
po_id=str(po_id),
supplier_email=supplier.email)
else:
logger.warning("Supplier email not available",
po_id=str(po_id),
supplier_id=str(po.supplier_id))
except Exception as e:
logger.error("Failed to send email to supplier",
error=str(e),
po_id=str(po_id))
# Don't fail the entire operation if email fails
logger.info("Purchase order sent to supplier", po_id=str(po_id))
return po
async def confirm_supplier_receipt(
self,
po_id: UUID,
supplier_reference: Optional[str] = None,
confirmed_by: UUID = None
) -> Optional[PurchaseOrder]:
"""Confirm supplier has received and accepted the order"""
logger.info("Confirming supplier receipt", po_id=str(po_id))
po = self.repository.get_by_id(po_id)
if not po:
return None
if po.status != PurchaseOrderStatus.SENT_TO_SUPPLIER:
raise ValueError("Order must be sent to supplier before confirmation")
update_data = {
'status': PurchaseOrderStatus.CONFIRMED,
'supplier_confirmation_date': datetime.utcnow(),
'supplier_reference': supplier_reference,
'updated_at': datetime.utcnow()
}
if confirmed_by:
update_data['updated_by'] = confirmed_by
po = self.repository.update(po_id, update_data)
logger.info("Supplier receipt confirmed", po_id=str(po_id))
return po
async def search_purchase_orders(
self,
tenant_id: UUID,
search_params: PurchaseOrderSearchParams
) -> List[PurchaseOrder]:
"""Search purchase orders with filters"""
return await self.repository.search_purchase_orders(
tenant_id=tenant_id,
supplier_id=search_params.supplier_id,
status=search_params.status,
priority=search_params.priority,
date_from=search_params.date_from,
date_to=search_params.date_to,
search_term=search_params.search_term,
limit=search_params.limit,
offset=search_params.offset
)
async def get_orders_by_supplier(
self,
tenant_id: UUID,
supplier_id: UUID,
limit: int = 20
) -> List[PurchaseOrder]:
"""Get recent orders for a supplier"""
return self.repository.get_orders_by_supplier(tenant_id, supplier_id, limit)
async def get_orders_requiring_approval(
self,
tenant_id: UUID
) -> List[PurchaseOrder]:
"""Get orders pending approval"""
return self.repository.get_orders_requiring_approval(tenant_id)
async def get_overdue_orders(self, tenant_id: UUID) -> List[PurchaseOrder]:
"""Get orders that are overdue for delivery"""
return self.repository.get_overdue_orders(tenant_id)
async def get_purchase_order_statistics(
self,
tenant_id: UUID
) -> Dict[str, Any]:
"""Get purchase order statistics"""
return self.repository.get_purchase_order_statistics(tenant_id)
async def update_order_items(
self,
po_id: UUID,
items_updates: List[Dict[str, Any]],
updated_by: UUID
) -> Optional[PurchaseOrder]:
"""Update multiple items in a purchase order"""
logger.info("Updating order items", po_id=str(po_id))
po = self.repository.get_by_id(po_id)
if not po:
return None
# Check if order can be modified
if po.status in [
PurchaseOrderStatus.COMPLETED,
PurchaseOrderStatus.CANCELLED,
PurchaseOrderStatus.SENT_TO_SUPPLIER,
PurchaseOrderStatus.CONFIRMED
]:
raise ValueError("Cannot modify items for orders in current status")
# Update items
self.item_repository.bulk_update_items(po_id, items_updates)
# Recalculate order totals
po = self.repository.calculate_order_totals(po_id)
# Update the order timestamp
self.repository.update(po_id, {
'updated_by': updated_by,
'updated_at': datetime.utcnow()
})
logger.info("Order items updated successfully", po_id=str(po_id))
return po
async def cancel_purchase_order(
self,
po_id: UUID,
cancellation_reason: str,
cancelled_by: UUID
) -> Optional[PurchaseOrder]:
"""Cancel a purchase order"""
logger.info("Cancelling purchase order", po_id=str(po_id))
po = self.repository.get_by_id(po_id)
if not po:
return None
if po.status in [PurchaseOrderStatus.COMPLETED, PurchaseOrderStatus.CANCELLED]:
raise ValueError("Cannot cancel completed or already cancelled orders")
update_data = {
'status': PurchaseOrderStatus.CANCELLED,
'rejection_reason': cancellation_reason,
'updated_by': cancelled_by,
'updated_at': datetime.utcnow()
}
po = self.repository.update(po_id, update_data)
logger.info("Purchase order cancelled successfully", po_id=str(po_id))
return po
def _is_valid_status_transition(
self,
from_status: PurchaseOrderStatus,
to_status: PurchaseOrderStatus
) -> bool:
"""Validate if status transition is allowed"""
# Define valid transitions
valid_transitions = {
PurchaseOrderStatus.DRAFT: [
PurchaseOrderStatus.PENDING_APPROVAL,
PurchaseOrderStatus.APPROVED,
PurchaseOrderStatus.CANCELLED
],
PurchaseOrderStatus.PENDING_APPROVAL: [
PurchaseOrderStatus.APPROVED,
PurchaseOrderStatus.CANCELLED
],
PurchaseOrderStatus.APPROVED: [
PurchaseOrderStatus.SENT_TO_SUPPLIER,
PurchaseOrderStatus.CANCELLED
],
PurchaseOrderStatus.SENT_TO_SUPPLIER: [
PurchaseOrderStatus.CONFIRMED,
PurchaseOrderStatus.CANCELLED
],
PurchaseOrderStatus.CONFIRMED: [
PurchaseOrderStatus.PARTIALLY_RECEIVED,
PurchaseOrderStatus.COMPLETED,
PurchaseOrderStatus.DISPUTED
],
PurchaseOrderStatus.PARTIALLY_RECEIVED: [
PurchaseOrderStatus.COMPLETED,
PurchaseOrderStatus.DISPUTED
],
PurchaseOrderStatus.DISPUTED: [
PurchaseOrderStatus.CONFIRMED,
PurchaseOrderStatus.CANCELLED
]
}
return to_status in valid_transitions.get(from_status, [])
async def get_inventory_product_purchase_history(
self,
tenant_id: UUID,
inventory_product_id: UUID,
days_back: int = 90
) -> Dict[str, Any]:
"""Get purchase history for an inventory product"""
return self.item_repository.get_inventory_product_purchase_history(
tenant_id, inventory_product_id, days_back
)
async def get_top_purchased_inventory_products(
self,
tenant_id: UUID,
days_back: int = 30,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get most purchased inventory products"""
return self.item_repository.get_top_purchased_inventory_products(
tenant_id, days_back, limit
)
async def delete_purchase_order(self, po_id: UUID) -> bool:
"""
Delete (soft delete) a purchase order
Only allows deletion of draft orders
"""
logger.info("Deleting purchase order", po_id=str(po_id))
po = self.repository.get_by_id(po_id)
if not po:
return False
# Only allow deletion of draft orders
if po.status not in [PurchaseOrderStatus.DRAFT, PurchaseOrderStatus.CANCELLED]:
raise ValueError(
f"Cannot delete purchase order with status {po.status.value}. "
"Only draft and cancelled orders can be deleted."
)
# Perform soft delete
try:
self.repository.delete(po_id)
self.db.commit()
logger.info("Purchase order deleted successfully", po_id=str(po_id))
return True
except Exception as e:
self.db.rollback()
logger.error("Failed to delete purchase order", po_id=str(po_id), error=str(e))
raise

View File

@@ -123,7 +123,24 @@ class SupplierService:
async def get_supplier(self, supplier_id: UUID) -> Optional[Supplier]:
"""Get supplier by ID"""
return await self.repository.get_by_id(supplier_id)
async def get_suppliers_batch(self, tenant_id: UUID, supplier_ids: List[UUID]) -> List[Supplier]:
"""
Get multiple suppliers by IDs in a single database query.
This method is optimized for batch fetching to eliminate N+1 query patterns.
Used when enriching multiple purchase orders or other entities with supplier data.
Args:
tenant_id: Tenant ID for security filtering
supplier_ids: List of supplier UUIDs to fetch
Returns:
List of Supplier objects (may be fewer than requested if some IDs don't exist)
"""
logger.info("Batch fetching suppliers", tenant_id=str(tenant_id), count=len(supplier_ids))
return await self.repository.get_suppliers_by_ids(tenant_id, supplier_ids)
async def update_supplier(
self,
supplier_id: UUID,
@@ -167,20 +184,61 @@ class SupplierService:
async def delete_supplier(self, supplier_id: UUID) -> bool:
"""Delete supplier (soft delete by changing status)"""
logger.info("Deleting supplier", supplier_id=str(supplier_id))
supplier = self.repository.get_by_id(supplier_id)
if not supplier:
return False
# Check if supplier has active purchase orders
# TODO: Add check for active purchase orders once PO service is implemented
# Check if supplier has active purchase orders via procurement service
try:
from shared.clients.procurement_client import ProcurementServiceClient
from app.core.config import settings
procurement_client = ProcurementServiceClient(settings)
# Check for active purchase orders (pending, approved, in-progress)
active_statuses = ['draft', 'pending_approval', 'approved', 'in_progress']
active_pos_found = False
for status in active_statuses:
pos = await procurement_client.get_purchase_orders_by_supplier(
tenant_id=str(supplier.tenant_id),
supplier_id=str(supplier_id),
status=status,
limit=1 # We only need to know if any exist
)
if pos and len(pos) > 0:
active_pos_found = True
break
if active_pos_found:
logger.warning(
"Cannot delete supplier with active purchase orders",
supplier_id=str(supplier_id),
supplier_name=supplier.name
)
raise ValueError(
f"Cannot delete supplier '{supplier.name}' as it has active purchase orders. "
"Please complete or cancel all purchase orders first."
)
except ImportError:
logger.warning("Procurement client not available, skipping active PO check")
except Exception as e:
logger.error(
"Error checking active purchase orders",
supplier_id=str(supplier_id),
error=str(e)
)
# Don't fail deletion if we can't check POs, just log warning
logger.warning("Proceeding with deletion despite PO check failure")
# Soft delete by changing status
self.repository.update(supplier_id, {
'status': SupplierStatus.inactive,
'updated_at': datetime.utcnow()
})
logger.info("Supplier deleted successfully", supplier_id=str(supplier_id))
return True