New alert service
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
789
services/suppliers/app/consumers/alert_event_consumer.py
Normal file
789
services/suppliers/app/consumers/alert_event_consumer.py
Normal 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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user