Add more services
This commit is contained in:
494
services/inventory/app/api/dashboard.py
Normal file
494
services/inventory/app/api/dashboard.py
Normal file
@@ -0,0 +1,494 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/api/dashboard.py
|
||||
# ================================================================
|
||||
"""
|
||||
Dashboard API endpoints for Inventory Service
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||
from app.core.database import get_db
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.services.dashboard_service import DashboardService
|
||||
from app.schemas.dashboard import (
|
||||
InventoryDashboardSummary,
|
||||
FoodSafetyDashboard,
|
||||
BusinessModelInsights,
|
||||
InventoryAnalytics,
|
||||
DashboardFilter,
|
||||
AlertsFilter,
|
||||
StockStatusSummary,
|
||||
AlertSummary,
|
||||
RecentActivity
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
# ===== Dependency Injection =====
|
||||
|
||||
async def get_dashboard_service(db: AsyncSession = Depends(get_db)) -> DashboardService:
|
||||
"""Get dashboard service with dependencies"""
|
||||
return DashboardService(
|
||||
inventory_service=InventoryService(),
|
||||
food_safety_service=FoodSafetyService()
|
||||
)
|
||||
|
||||
|
||||
# ===== Main Dashboard Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/summary", response_model=InventoryDashboardSummary)
|
||||
async def get_inventory_dashboard_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
filters: Optional[DashboardFilter] = None,
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get comprehensive inventory dashboard summary"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
summary = await dashboard_service.get_inventory_dashboard_summary(db, tenant_id, filters)
|
||||
|
||||
logger.info("Dashboard summary retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
total_ingredients=summary.total_ingredients)
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting dashboard summary",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve dashboard summary"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/food-safety", response_model=FoodSafetyDashboard)
|
||||
async def get_food_safety_dashboard(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(lambda: FoodSafetyService()),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get food safety dashboard data"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
dashboard = await food_safety_service.get_food_safety_dashboard(db, tenant_id)
|
||||
|
||||
logger.info("Food safety dashboard retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
compliance_percentage=dashboard.compliance_percentage)
|
||||
|
||||
return dashboard
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety dashboard",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety dashboard"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/analytics", response_model=InventoryAnalytics)
|
||||
async def get_inventory_analytics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get advanced inventory analytics"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
analytics = await dashboard_service.get_inventory_analytics(db, tenant_id, days_back)
|
||||
|
||||
logger.info("Inventory analytics retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
days_analyzed=days_back)
|
||||
|
||||
return analytics
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting inventory analytics",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve inventory analytics"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/business-model", response_model=BusinessModelInsights)
|
||||
async def get_business_model_insights(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get business model insights based on inventory patterns"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
insights = await dashboard_service.get_business_model_insights(db, tenant_id)
|
||||
|
||||
logger.info("Business model insights retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
detected_model=insights.detected_model)
|
||||
|
||||
return insights
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting business model insights",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve business model insights"
|
||||
)
|
||||
|
||||
|
||||
# ===== Detailed Dashboard Data Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/stock-status", response_model=List[StockStatusSummary])
|
||||
async def get_stock_status_by_category(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get stock status breakdown by category"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
stock_status = await dashboard_service.get_stock_status_by_category(db, tenant_id)
|
||||
|
||||
return stock_status
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting stock status by category",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve stock status by category"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/alerts-summary", response_model=List[AlertSummary])
|
||||
async def get_alerts_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
filters: Optional[AlertsFilter] = None,
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get alerts summary by type and severity"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
alerts_summary = await dashboard_service.get_alerts_summary(db, tenant_id, filters)
|
||||
|
||||
return alerts_summary
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting alerts summary",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve alerts summary"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/recent-activity", response_model=List[RecentActivity])
|
||||
async def get_recent_activity(
|
||||
tenant_id: UUID = Path(...),
|
||||
limit: int = Query(20, ge=1, le=100, description="Number of activities to return"),
|
||||
activity_types: Optional[List[str]] = Query(None, description="Filter by activity types"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get recent inventory activity"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
activities = await dashboard_service.get_recent_activity(
|
||||
db, tenant_id, limit, activity_types
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting recent activity",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve recent activity"
|
||||
)
|
||||
|
||||
|
||||
# ===== Real-time Data Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/live-metrics")
|
||||
async def get_live_metrics(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get real-time inventory metrics"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
metrics = await dashboard_service.get_live_metrics(db, tenant_id)
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"metrics": metrics,
|
||||
"cache_ttl": 60 # Seconds
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting live metrics",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve live metrics"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/temperature-status")
|
||||
async def get_temperature_monitoring_status(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(lambda: FoodSafetyService()),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current temperature monitoring status"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
temp_status = await food_safety_service.get_temperature_monitoring_status(db, tenant_id)
|
||||
|
||||
return {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"temperature_monitoring": temp_status
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting temperature status",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve temperature monitoring status"
|
||||
)
|
||||
|
||||
|
||||
# ===== Dashboard Configuration Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/config")
|
||||
async def get_dashboard_config(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get dashboard configuration and settings"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
config = {
|
||||
"refresh_intervals": {
|
||||
"dashboard_cache_ttl": settings.DASHBOARD_CACHE_TTL,
|
||||
"alerts_refresh_interval": settings.ALERTS_REFRESH_INTERVAL,
|
||||
"temperature_log_interval": settings.TEMPERATURE_LOG_INTERVAL
|
||||
},
|
||||
"features": {
|
||||
"food_safety_enabled": settings.FOOD_SAFETY_ENABLED,
|
||||
"temperature_monitoring_enabled": settings.TEMPERATURE_MONITORING_ENABLED,
|
||||
"business_model_detection": settings.ENABLE_BUSINESS_MODEL_DETECTION
|
||||
},
|
||||
"thresholds": {
|
||||
"low_stock_default": settings.DEFAULT_LOW_STOCK_THRESHOLD,
|
||||
"reorder_point_default": settings.DEFAULT_REORDER_POINT,
|
||||
"expiration_warning_days": settings.EXPIRATION_WARNING_DAYS,
|
||||
"critical_expiration_hours": settings.CRITICAL_EXPIRATION_HOURS
|
||||
},
|
||||
"business_model_thresholds": {
|
||||
"central_bakery_ingredients": settings.CENTRAL_BAKERY_THRESHOLD_INGREDIENTS,
|
||||
"individual_bakery_ingredients": settings.INDIVIDUAL_BAKERY_THRESHOLD_INGREDIENTS
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting dashboard config",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve dashboard configuration"
|
||||
)
|
||||
|
||||
|
||||
# ===== Export and Reporting Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/export/summary")
|
||||
async def export_dashboard_summary(
|
||||
tenant_id: UUID = Path(...),
|
||||
format: str = Query("json", description="Export format: json, csv, excel"),
|
||||
date_from: Optional[datetime] = Query(None, description="Start date for data export"),
|
||||
date_to: Optional[datetime] = Query(None, description="End date for data export"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
dashboard_service: DashboardService = Depends(get_dashboard_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Export dashboard summary data"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
if format.lower() not in ["json", "csv", "excel"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Unsupported export format. Use: json, csv, excel"
|
||||
)
|
||||
|
||||
export_data = await dashboard_service.export_dashboard_data(
|
||||
db, tenant_id, format, date_from, date_to
|
||||
)
|
||||
|
||||
logger.info("Dashboard data exported",
|
||||
tenant_id=str(tenant_id),
|
||||
format=format)
|
||||
|
||||
return export_data
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error exporting dashboard data",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to export dashboard data"
|
||||
)
|
||||
|
||||
|
||||
# ===== Health and Status Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/health")
|
||||
async def get_dashboard_health(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get dashboard service health status"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "inventory-dashboard",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"features": {
|
||||
"food_safety": "enabled",
|
||||
"temperature_monitoring": "enabled",
|
||||
"business_model_detection": "enabled",
|
||||
"real_time_alerts": "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting dashboard health",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get dashboard health status"
|
||||
)
|
||||
718
services/inventory/app/api/food_safety.py
Normal file
718
services/inventory/app/api/food_safety.py
Normal file
@@ -0,0 +1,718 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/api/food_safety.py
|
||||
# ================================================================
|
||||
"""
|
||||
Food Safety API endpoints for Inventory Service
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep, get_current_tenant_id_dep
|
||||
from app.core.database import get_db
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.schemas.food_safety import (
|
||||
FoodSafetyComplianceCreate,
|
||||
FoodSafetyComplianceUpdate,
|
||||
FoodSafetyComplianceResponse,
|
||||
TemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
FoodSafetyAlertCreate,
|
||||
FoodSafetyAlertUpdate,
|
||||
FoodSafetyAlertResponse,
|
||||
BulkTemperatureLogCreate,
|
||||
FoodSafetyFilter,
|
||||
TemperatureMonitoringFilter,
|
||||
FoodSafetyMetrics,
|
||||
TemperatureAnalytics
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
router = APIRouter(prefix="/food-safety", tags=["food-safety"])
|
||||
|
||||
|
||||
# ===== Dependency Injection =====
|
||||
|
||||
async def get_food_safety_service() -> FoodSafetyService:
|
||||
"""Get food safety service instance"""
|
||||
return FoodSafetyService()
|
||||
|
||||
|
||||
# ===== Compliance Management Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/compliance", response_model=FoodSafetyComplianceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_compliance_record(
|
||||
compliance_data: FoodSafetyComplianceCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a new food safety compliance record"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Ensure tenant_id matches
|
||||
compliance_data.tenant_id = tenant_id
|
||||
|
||||
compliance = await food_safety_service.create_compliance_record(
|
||||
db,
|
||||
compliance_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Compliance record created",
|
||||
compliance_id=str(compliance.id),
|
||||
standard=compliance.standard)
|
||||
|
||||
return compliance
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid compliance data", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error creating compliance record", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create compliance record"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/compliance", response_model=List[FoodSafetyComplianceResponse])
|
||||
async def get_compliance_records(
|
||||
tenant_id: UUID = Path(...),
|
||||
ingredient_id: Optional[UUID] = Query(None, description="Filter by ingredient ID"),
|
||||
standard: Optional[str] = Query(None, description="Filter by compliance standard"),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by compliance status"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get compliance records with filtering"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Build query filters
|
||||
filters = {}
|
||||
if ingredient_id:
|
||||
filters["ingredient_id"] = ingredient_id
|
||||
if standard:
|
||||
filters["standard"] = standard
|
||||
if status_filter:
|
||||
filters["compliance_status"] = status_filter
|
||||
|
||||
# Query compliance records
|
||||
query = """
|
||||
SELECT * FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
"""
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if filters:
|
||||
for key, value in filters.items():
|
||||
query += f" AND {key} = :{key}"
|
||||
params[key] = value
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip"
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
records = result.fetchall()
|
||||
|
||||
return [
|
||||
FoodSafetyComplianceResponse(**dict(record))
|
||||
for record in records
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting compliance records", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve compliance records"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/compliance/{compliance_id}", response_model=FoodSafetyComplianceResponse)
|
||||
async def update_compliance_record(
|
||||
compliance_data: FoodSafetyComplianceUpdate,
|
||||
tenant_id: UUID = Path(...),
|
||||
compliance_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update an existing compliance record"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
compliance = await food_safety_service.update_compliance_record(
|
||||
db,
|
||||
compliance_id,
|
||||
tenant_id,
|
||||
compliance_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
if not compliance:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Compliance record not found"
|
||||
)
|
||||
|
||||
logger.info("Compliance record updated",
|
||||
compliance_id=str(compliance.id))
|
||||
|
||||
return compliance
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating compliance record",
|
||||
compliance_id=str(compliance_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update compliance record"
|
||||
)
|
||||
|
||||
|
||||
# ===== Temperature Monitoring Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/temperature", response_model=TemperatureLogResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def log_temperature(
|
||||
temp_data: TemperatureLogCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Log a temperature reading"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Ensure tenant_id matches
|
||||
temp_data.tenant_id = tenant_id
|
||||
|
||||
temp_log = await food_safety_service.log_temperature(
|
||||
db,
|
||||
temp_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Temperature logged",
|
||||
location=temp_data.storage_location,
|
||||
temperature=temp_data.temperature_celsius)
|
||||
|
||||
return temp_log
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error logging temperature", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to log temperature"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/temperature/bulk", response_model=List[TemperatureLogResponse])
|
||||
async def bulk_log_temperatures(
|
||||
bulk_data: BulkTemperatureLogCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Bulk log temperature readings"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Ensure tenant_id matches for all readings
|
||||
for reading in bulk_data.readings:
|
||||
reading.tenant_id = tenant_id
|
||||
|
||||
temp_logs = await food_safety_service.bulk_log_temperatures(
|
||||
db,
|
||||
bulk_data.readings,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Bulk temperature logging completed",
|
||||
count=len(bulk_data.readings))
|
||||
|
||||
return temp_logs
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error bulk logging temperatures", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to bulk log temperatures"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/temperature", response_model=List[TemperatureLogResponse])
|
||||
async def get_temperature_logs(
|
||||
tenant_id: UUID = Path(...),
|
||||
location: Optional[str] = Query(None, description="Filter by storage location"),
|
||||
equipment_id: Optional[str] = Query(None, description="Filter by equipment ID"),
|
||||
date_from: Optional[datetime] = Query(None, description="Start date for filtering"),
|
||||
date_to: Optional[datetime] = Query(None, description="End date for filtering"),
|
||||
violations_only: bool = Query(False, description="Show only temperature violations"),
|
||||
skip: int = Query(0, ge=0, description="Number of records to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of records to return"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get temperature logs with filtering"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Build query
|
||||
where_conditions = ["tenant_id = :tenant_id"]
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if location:
|
||||
where_conditions.append("storage_location ILIKE :location")
|
||||
params["location"] = f"%{location}%"
|
||||
|
||||
if equipment_id:
|
||||
where_conditions.append("equipment_id = :equipment_id")
|
||||
params["equipment_id"] = equipment_id
|
||||
|
||||
if date_from:
|
||||
where_conditions.append("recorded_at >= :date_from")
|
||||
params["date_from"] = date_from
|
||||
|
||||
if date_to:
|
||||
where_conditions.append("recorded_at <= :date_to")
|
||||
params["date_to"] = date_to
|
||||
|
||||
if violations_only:
|
||||
where_conditions.append("is_within_range = false")
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT * FROM temperature_logs
|
||||
WHERE {where_clause}
|
||||
ORDER BY recorded_at DESC
|
||||
LIMIT :limit OFFSET :skip
|
||||
"""
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
logs = result.fetchall()
|
||||
|
||||
return [
|
||||
TemperatureLogResponse(**dict(log))
|
||||
for log in logs
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting temperature logs", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve temperature logs"
|
||||
)
|
||||
|
||||
|
||||
# ===== Alert Management Endpoints =====
|
||||
|
||||
@router.post("/tenants/{tenant_id}/alerts", response_model=FoodSafetyAlertResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_food_safety_alert(
|
||||
alert_data: FoodSafetyAlertCreate,
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
food_safety_service: FoodSafetyService = Depends(get_food_safety_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create a food safety alert"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Ensure tenant_id matches
|
||||
alert_data.tenant_id = tenant_id
|
||||
|
||||
alert = await food_safety_service.create_food_safety_alert(
|
||||
db,
|
||||
alert_data,
|
||||
user_id=UUID(current_user["sub"])
|
||||
)
|
||||
|
||||
logger.info("Food safety alert created",
|
||||
alert_id=str(alert.id),
|
||||
alert_type=alert.alert_type)
|
||||
|
||||
return alert
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error creating food safety alert", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create food safety alert"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}/alerts", response_model=List[FoodSafetyAlertResponse])
|
||||
async def get_food_safety_alerts(
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_type: Optional[str] = Query(None, description="Filter by alert type"),
|
||||
severity: Optional[str] = Query(None, description="Filter by severity"),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status"),
|
||||
unresolved_only: bool = Query(True, description="Show only unresolved alerts"),
|
||||
skip: int = Query(0, ge=0, description="Number of alerts to skip"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Number of alerts to return"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get food safety alerts with filtering"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Build query filters
|
||||
where_conditions = ["tenant_id = :tenant_id"]
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if alert_type:
|
||||
where_conditions.append("alert_type = :alert_type")
|
||||
params["alert_type"] = alert_type
|
||||
|
||||
if severity:
|
||||
where_conditions.append("severity = :severity")
|
||||
params["severity"] = severity
|
||||
|
||||
if status_filter:
|
||||
where_conditions.append("status = :status")
|
||||
params["status"] = status_filter
|
||||
elif unresolved_only:
|
||||
where_conditions.append("status NOT IN ('resolved', 'dismissed')")
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT * FROM food_safety_alerts
|
||||
WHERE {where_clause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit OFFSET :skip
|
||||
"""
|
||||
params.update({"limit": limit, "skip": skip})
|
||||
|
||||
result = await db.execute(query, params)
|
||||
alerts = result.fetchall()
|
||||
|
||||
return [
|
||||
FoodSafetyAlertResponse(**dict(alert))
|
||||
for alert in alerts
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety alerts", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety alerts"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/tenants/{tenant_id}/alerts/{alert_id}", response_model=FoodSafetyAlertResponse)
|
||||
async def update_food_safety_alert(
|
||||
alert_data: FoodSafetyAlertUpdate,
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update a food safety alert"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Get existing alert
|
||||
alert_query = "SELECT * FROM food_safety_alerts WHERE id = :alert_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
alert_record = result.fetchone()
|
||||
|
||||
if not alert_record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
# Update alert fields
|
||||
update_fields = alert_data.dict(exclude_unset=True)
|
||||
if update_fields:
|
||||
set_clauses = []
|
||||
params = {"alert_id": alert_id, "tenant_id": tenant_id}
|
||||
|
||||
for field, value in update_fields.items():
|
||||
set_clauses.append(f"{field} = :{field}")
|
||||
params[field] = value
|
||||
|
||||
# Add updated timestamp and user
|
||||
set_clauses.append("updated_at = NOW()")
|
||||
set_clauses.append("updated_by = :updated_by")
|
||||
params["updated_by"] = UUID(current_user["sub"])
|
||||
|
||||
update_query = f"""
|
||||
UPDATE food_safety_alerts
|
||||
SET {', '.join(set_clauses)}
|
||||
WHERE id = :alert_id AND tenant_id = :tenant_id
|
||||
"""
|
||||
|
||||
await db.execute(update_query, params)
|
||||
await db.commit()
|
||||
|
||||
# Get updated alert
|
||||
result = await db.execute(alert_query, {"alert_id": alert_id, "tenant_id": tenant_id})
|
||||
updated_alert = result.fetchone()
|
||||
|
||||
logger.info("Food safety alert updated",
|
||||
alert_id=str(alert_id))
|
||||
|
||||
return FoodSafetyAlertResponse(**dict(updated_alert))
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating food safety alert",
|
||||
alert_id=str(alert_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update food safety alert"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/alerts/{alert_id}/acknowledge")
|
||||
async def acknowledge_alert(
|
||||
tenant_id: UUID = Path(...),
|
||||
alert_id: UUID = Path(...),
|
||||
notes: Optional[str] = Query(None, description="Acknowledgment notes"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Acknowledge a food safety alert"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Update alert to acknowledged status
|
||||
update_query = """
|
||||
UPDATE food_safety_alerts
|
||||
SET status = 'acknowledged',
|
||||
acknowledged_at = NOW(),
|
||||
acknowledged_by = :user_id,
|
||||
investigation_notes = COALESCE(investigation_notes, '') || :notes,
|
||||
updated_at = NOW(),
|
||||
updated_by = :user_id
|
||||
WHERE id = :alert_id AND tenant_id = :tenant_id
|
||||
"""
|
||||
|
||||
result = await db.execute(update_query, {
|
||||
"alert_id": alert_id,
|
||||
"tenant_id": tenant_id,
|
||||
"user_id": UUID(current_user["sub"]),
|
||||
"notes": f"\nAcknowledged: {notes}" if notes else "\nAcknowledged"
|
||||
})
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Food safety alert not found"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info("Food safety alert acknowledged",
|
||||
alert_id=str(alert_id))
|
||||
|
||||
return {"message": "Alert acknowledged successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error acknowledging alert",
|
||||
alert_id=str(alert_id),
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to acknowledge alert"
|
||||
)
|
||||
|
||||
|
||||
# ===== Analytics and Reporting Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/metrics", response_model=FoodSafetyMetrics)
|
||||
async def get_food_safety_metrics(
|
||||
tenant_id: UUID = Path(...),
|
||||
days_back: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get food safety performance metrics"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
# Calculate compliance rate
|
||||
compliance_query = """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN compliance_status = 'compliant' THEN 1 END) as compliant
|
||||
FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
"""
|
||||
|
||||
result = await db.execute(compliance_query, {"tenant_id": tenant_id})
|
||||
compliance_stats = result.fetchone()
|
||||
|
||||
compliance_rate = 0.0
|
||||
if compliance_stats.total > 0:
|
||||
compliance_rate = (compliance_stats.compliant / compliance_stats.total) * 100
|
||||
|
||||
# Calculate temperature compliance
|
||||
temp_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_readings,
|
||||
COUNT(CASE WHEN is_within_range THEN 1 END) as compliant_readings
|
||||
FROM temperature_logs
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND recorded_at > NOW() - INTERVAL '%s days'
|
||||
""" % days_back
|
||||
|
||||
result = await db.execute(temp_query, {"tenant_id": tenant_id})
|
||||
temp_stats = result.fetchone()
|
||||
|
||||
temp_compliance_rate = 0.0
|
||||
if temp_stats.total_readings > 0:
|
||||
temp_compliance_rate = (temp_stats.compliant_readings / temp_stats.total_readings) * 100
|
||||
|
||||
# Get alert metrics
|
||||
alert_query = """
|
||||
SELECT
|
||||
COUNT(*) as total_alerts,
|
||||
COUNT(CASE WHEN is_recurring THEN 1 END) as recurring_alerts,
|
||||
COUNT(CASE WHEN regulatory_action_required THEN 1 END) as regulatory_violations,
|
||||
AVG(CASE WHEN response_time_minutes IS NOT NULL THEN response_time_minutes END) as avg_response_time,
|
||||
AVG(CASE WHEN resolution_time_minutes IS NOT NULL THEN resolution_time_minutes END) as avg_resolution_time
|
||||
FROM food_safety_alerts
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND created_at > NOW() - INTERVAL '%s days'
|
||||
""" % days_back
|
||||
|
||||
result = await db.execute(alert_query, {"tenant_id": tenant_id})
|
||||
alert_stats = result.fetchone()
|
||||
|
||||
return FoodSafetyMetrics(
|
||||
compliance_rate=Decimal(str(compliance_rate)),
|
||||
temperature_compliance_rate=Decimal(str(temp_compliance_rate)),
|
||||
alert_response_time_avg=Decimal(str(alert_stats.avg_response_time or 0)),
|
||||
alert_resolution_time_avg=Decimal(str(alert_stats.avg_resolution_time or 0)),
|
||||
recurring_issues_count=alert_stats.recurring_alerts or 0,
|
||||
regulatory_violations=alert_stats.regulatory_violations or 0,
|
||||
certification_coverage=Decimal(str(compliance_rate)), # Same as compliance rate for now
|
||||
audit_score_avg=Decimal("85.0"), # Would calculate from actual audit data
|
||||
risk_score=Decimal("3.2") # Would calculate from risk assessments
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety metrics", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to retrieve food safety metrics"
|
||||
)
|
||||
|
||||
|
||||
# ===== Health and Status Endpoints =====
|
||||
|
||||
@router.get("/tenants/{tenant_id}/status")
|
||||
async def get_food_safety_status(
|
||||
tenant_id: UUID = Path(...),
|
||||
current_tenant: str = Depends(get_current_tenant_id_dep),
|
||||
current_user: dict = Depends(get_current_user_dep)
|
||||
):
|
||||
"""Get food safety service status"""
|
||||
try:
|
||||
if str(tenant_id) != current_tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to tenant data"
|
||||
)
|
||||
|
||||
return {
|
||||
"service": "food-safety",
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"features": {
|
||||
"compliance_tracking": "enabled",
|
||||
"temperature_monitoring": "enabled",
|
||||
"automated_alerts": "enabled",
|
||||
"regulatory_reporting": "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting food safety status", error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get food safety status"
|
||||
)
|
||||
@@ -61,6 +61,50 @@ class Settings(BaseServiceSettings):
|
||||
# Barcode/QR configuration
|
||||
BARCODE_FORMAT: str = "Code128"
|
||||
QR_CODE_VERSION: int = 1
|
||||
|
||||
# Food safety and compliance settings
|
||||
FOOD_SAFETY_ENABLED: bool = Field(default=True, env="FOOD_SAFETY_ENABLED")
|
||||
TEMPERATURE_MONITORING_ENABLED: bool = Field(default=True, env="TEMPERATURE_MONITORING_ENABLED")
|
||||
AUTOMATIC_COMPLIANCE_CHECKS: bool = Field(default=True, env="AUTOMATIC_COMPLIANCE_CHECKS")
|
||||
|
||||
# Temperature monitoring thresholds
|
||||
REFRIGERATION_TEMP_MIN: float = Field(default=1.0, env="REFRIGERATION_TEMP_MIN") # Celsius
|
||||
REFRIGERATION_TEMP_MAX: float = Field(default=4.0, env="REFRIGERATION_TEMP_MAX") # Celsius
|
||||
FREEZER_TEMP_MIN: float = Field(default=-20.0, env="FREEZER_TEMP_MIN") # Celsius
|
||||
FREEZER_TEMP_MAX: float = Field(default=-15.0, env="FREEZER_TEMP_MAX") # Celsius
|
||||
ROOM_TEMP_MIN: float = Field(default=18.0, env="ROOM_TEMP_MIN") # Celsius
|
||||
ROOM_TEMP_MAX: float = Field(default=25.0, env="ROOM_TEMP_MAX") # Celsius
|
||||
|
||||
# Temperature alert thresholds
|
||||
TEMP_DEVIATION_ALERT_MINUTES: int = Field(default=15, env="TEMP_DEVIATION_ALERT_MINUTES")
|
||||
CRITICAL_TEMP_DEVIATION_MINUTES: int = Field(default=5, env="CRITICAL_TEMP_DEVIATION_MINUTES")
|
||||
TEMP_SENSOR_OFFLINE_ALERT_MINUTES: int = Field(default=30, env="TEMP_SENSOR_OFFLINE_ALERT_MINUTES")
|
||||
|
||||
# Food safety alert thresholds
|
||||
EXPIRATION_WARNING_DAYS: int = Field(default=3, env="EXPIRATION_WARNING_DAYS")
|
||||
CRITICAL_EXPIRATION_HOURS: int = Field(default=24, env="CRITICAL_EXPIRATION_HOURS")
|
||||
QUALITY_SCORE_THRESHOLD: float = Field(default=8.0, env="QUALITY_SCORE_THRESHOLD")
|
||||
|
||||
# Compliance monitoring
|
||||
AUDIT_REMINDER_DAYS: int = Field(default=30, env="AUDIT_REMINDER_DAYS")
|
||||
CERTIFICATION_EXPIRY_WARNING_DAYS: int = Field(default=60, env="CERTIFICATION_EXPIRY_WARNING_DAYS")
|
||||
COMPLIANCE_CHECK_FREQUENCY_HOURS: int = Field(default=24, env="COMPLIANCE_CHECK_FREQUENCY_HOURS")
|
||||
|
||||
# Dashboard refresh intervals
|
||||
DASHBOARD_CACHE_TTL: int = Field(default=300, env="DASHBOARD_CACHE_TTL") # 5 minutes
|
||||
ALERTS_REFRESH_INTERVAL: int = Field(default=60, env="ALERTS_REFRESH_INTERVAL") # 1 minute
|
||||
TEMPERATURE_LOG_INTERVAL: int = Field(default=300, env="TEMPERATURE_LOG_INTERVAL") # 5 minutes
|
||||
|
||||
# Alert notification settings
|
||||
ENABLE_EMAIL_ALERTS: bool = Field(default=True, env="ENABLE_EMAIL_ALERTS")
|
||||
ENABLE_SMS_ALERTS: bool = Field(default=True, env="ENABLE_SMS_ALERTS")
|
||||
ENABLE_WHATSAPP_ALERTS: bool = Field(default=True, env="ENABLE_WHATSAPP_ALERTS")
|
||||
REGULATORY_NOTIFICATION_ENABLED: bool = Field(default=False, env="REGULATORY_NOTIFICATION_ENABLED")
|
||||
|
||||
# Business model detection for inventory
|
||||
ENABLE_BUSINESS_MODEL_DETECTION: bool = Field(default=True, env="ENABLE_BUSINESS_MODEL_DETECTION")
|
||||
CENTRAL_BAKERY_THRESHOLD_INGREDIENTS: int = Field(default=50, env="CENTRAL_BAKERY_THRESHOLD_INGREDIENTS")
|
||||
INDIVIDUAL_BAKERY_THRESHOLD_INGREDIENTS: int = Field(default=20, env="INDIVIDUAL_BAKERY_THRESHOLD_INGREDIENTS")
|
||||
|
||||
|
||||
# Global settings instance
|
||||
|
||||
@@ -118,6 +118,13 @@ app.include_router(ingredients.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(stock.router, prefix=settings.API_V1_STR)
|
||||
app.include_router(classification.router, prefix=settings.API_V1_STR)
|
||||
|
||||
# Include enhanced routers
|
||||
from app.api.dashboard import router as dashboard_router
|
||||
from app.api.food_safety import router as food_safety_router
|
||||
|
||||
app.include_router(dashboard_router, prefix=settings.API_V1_STR)
|
||||
app.include_router(food_safety_router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
@@ -150,7 +157,13 @@ async def service_info():
|
||||
"low_stock_alerts",
|
||||
"batch_tracking",
|
||||
"fifo_consumption",
|
||||
"barcode_support"
|
||||
"barcode_support",
|
||||
"food_safety_compliance",
|
||||
"temperature_monitoring",
|
||||
"dashboard_analytics",
|
||||
"business_model_detection",
|
||||
"real_time_alerts",
|
||||
"regulatory_reporting"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
369
services/inventory/app/models/food_safety.py
Normal file
369
services/inventory/app/models/food_safety.py
Normal file
@@ -0,0 +1,369 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/models/food_safety.py
|
||||
# ================================================================
|
||||
"""
|
||||
Food safety and compliance models for Inventory Service
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
from sqlalchemy import Column, String, DateTime, Float, Integer, Text, Boolean, Numeric, ForeignKey, Enum as SQLEnum
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from shared.database.base import Base
|
||||
|
||||
|
||||
class FoodSafetyStandard(enum.Enum):
|
||||
"""Food safety standards and certifications"""
|
||||
HACCP = "haccp"
|
||||
FDA = "fda"
|
||||
USDA = "usda"
|
||||
FSMA = "fsma"
|
||||
SQF = "sqf"
|
||||
BRC = "brc"
|
||||
IFS = "ifs"
|
||||
ISO22000 = "iso22000"
|
||||
ORGANIC = "organic"
|
||||
NON_GMO = "non_gmo"
|
||||
ALLERGEN_FREE = "allergen_free"
|
||||
KOSHER = "kosher"
|
||||
HALAL = "halal"
|
||||
|
||||
|
||||
class ComplianceStatus(enum.Enum):
|
||||
"""Compliance status for food safety requirements"""
|
||||
COMPLIANT = "compliant"
|
||||
NON_COMPLIANT = "non_compliant"
|
||||
PENDING_REVIEW = "pending_review"
|
||||
EXPIRED = "expired"
|
||||
WARNING = "warning"
|
||||
|
||||
|
||||
class FoodSafetyAlertType(enum.Enum):
|
||||
"""Types of food safety alerts"""
|
||||
TEMPERATURE_VIOLATION = "temperature_violation"
|
||||
EXPIRATION_WARNING = "expiration_warning"
|
||||
EXPIRED_PRODUCT = "expired_product"
|
||||
CONTAMINATION_RISK = "contamination_risk"
|
||||
ALLERGEN_CROSS_CONTAMINATION = "allergen_cross_contamination"
|
||||
STORAGE_VIOLATION = "storage_violation"
|
||||
QUALITY_DEGRADATION = "quality_degradation"
|
||||
RECALL_NOTICE = "recall_notice"
|
||||
CERTIFICATION_EXPIRY = "certification_expiry"
|
||||
SUPPLIER_COMPLIANCE_ISSUE = "supplier_compliance_issue"
|
||||
|
||||
|
||||
class FoodSafetyCompliance(Base):
|
||||
"""Food safety compliance tracking for ingredients and products"""
|
||||
__tablename__ = "food_safety_compliance"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredients.id"), nullable=False, index=True)
|
||||
|
||||
# Compliance standard
|
||||
standard = Column(SQLEnum(FoodSafetyStandard), nullable=False, index=True)
|
||||
compliance_status = Column(SQLEnum(ComplianceStatus), nullable=False, default=ComplianceStatus.PENDING_REVIEW)
|
||||
|
||||
# Certification details
|
||||
certification_number = Column(String(100), nullable=True)
|
||||
certifying_body = Column(String(200), nullable=True)
|
||||
certification_date = Column(DateTime(timezone=True), nullable=True)
|
||||
expiration_date = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
# Compliance requirements
|
||||
requirements = Column(JSONB, nullable=True) # Specific requirements for this standard
|
||||
compliance_notes = Column(Text, nullable=True)
|
||||
documentation_url = Column(String(500), nullable=True)
|
||||
|
||||
# Audit information
|
||||
last_audit_date = Column(DateTime(timezone=True), nullable=True)
|
||||
next_audit_date = Column(DateTime(timezone=True), nullable=True, index=True)
|
||||
auditor_name = Column(String(200), nullable=True)
|
||||
audit_score = Column(Float, nullable=True) # 0-100 score
|
||||
|
||||
# Risk assessment
|
||||
risk_level = Column(String(20), nullable=False, default="medium") # low, medium, high, critical
|
||||
risk_factors = Column(JSONB, nullable=True) # List of identified risk factors
|
||||
mitigation_measures = Column(JSONB, nullable=True) # Implemented mitigation measures
|
||||
|
||||
# Status tracking
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
requires_monitoring = Column(Boolean, nullable=False, default=True)
|
||||
monitoring_frequency_days = Column(Integer, nullable=True) # How often to check compliance
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
updated_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'ingredient_id': str(self.ingredient_id),
|
||||
'standard': self.standard.value if self.standard else None,
|
||||
'compliance_status': self.compliance_status.value if self.compliance_status else None,
|
||||
'certification_number': self.certification_number,
|
||||
'certifying_body': self.certifying_body,
|
||||
'certification_date': self.certification_date.isoformat() if self.certification_date else None,
|
||||
'expiration_date': self.expiration_date.isoformat() if self.expiration_date else None,
|
||||
'requirements': self.requirements,
|
||||
'compliance_notes': self.compliance_notes,
|
||||
'documentation_url': self.documentation_url,
|
||||
'last_audit_date': self.last_audit_date.isoformat() if self.last_audit_date else None,
|
||||
'next_audit_date': self.next_audit_date.isoformat() if self.next_audit_date else None,
|
||||
'auditor_name': self.auditor_name,
|
||||
'audit_score': self.audit_score,
|
||||
'risk_level': self.risk_level,
|
||||
'risk_factors': self.risk_factors,
|
||||
'mitigation_measures': self.mitigation_measures,
|
||||
'is_active': self.is_active,
|
||||
'requires_monitoring': self.requires_monitoring,
|
||||
'monitoring_frequency_days': self.monitoring_frequency_days,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': str(self.created_by) if self.created_by else None,
|
||||
'updated_by': str(self.updated_by) if self.updated_by else None,
|
||||
}
|
||||
|
||||
|
||||
class TemperatureLog(Base):
|
||||
"""Temperature monitoring logs for storage areas"""
|
||||
__tablename__ = "temperature_logs"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
|
||||
# Location information
|
||||
storage_location = Column(String(100), nullable=False, index=True)
|
||||
warehouse_zone = Column(String(50), nullable=True)
|
||||
equipment_id = Column(String(100), nullable=True) # Freezer/refrigerator ID
|
||||
|
||||
# Temperature readings
|
||||
temperature_celsius = Column(Float, nullable=False)
|
||||
humidity_percentage = Column(Float, nullable=True)
|
||||
target_temperature_min = Column(Float, nullable=True)
|
||||
target_temperature_max = Column(Float, nullable=True)
|
||||
|
||||
# Status and alerts
|
||||
is_within_range = Column(Boolean, nullable=False, default=True)
|
||||
alert_triggered = Column(Boolean, nullable=False, default=False)
|
||||
deviation_minutes = Column(Integer, nullable=True) # How long outside range
|
||||
|
||||
# Measurement details
|
||||
measurement_method = Column(String(50), nullable=False, default="manual") # manual, automatic, sensor
|
||||
device_id = Column(String(100), nullable=True)
|
||||
calibration_date = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Timestamp
|
||||
recorded_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
recorded_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'storage_location': self.storage_location,
|
||||
'warehouse_zone': self.warehouse_zone,
|
||||
'equipment_id': self.equipment_id,
|
||||
'temperature_celsius': self.temperature_celsius,
|
||||
'humidity_percentage': self.humidity_percentage,
|
||||
'target_temperature_min': self.target_temperature_min,
|
||||
'target_temperature_max': self.target_temperature_max,
|
||||
'is_within_range': self.is_within_range,
|
||||
'alert_triggered': self.alert_triggered,
|
||||
'deviation_minutes': self.deviation_minutes,
|
||||
'measurement_method': self.measurement_method,
|
||||
'device_id': self.device_id,
|
||||
'calibration_date': self.calibration_date.isoformat() if self.calibration_date else None,
|
||||
'recorded_at': self.recorded_at.isoformat() if self.recorded_at else None,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'recorded_by': str(self.recorded_by) if self.recorded_by else None,
|
||||
}
|
||||
|
||||
|
||||
class FoodSafetyAlert(Base):
|
||||
"""Food safety alerts and notifications"""
|
||||
__tablename__ = "food_safety_alerts"
|
||||
|
||||
# Primary identification
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
alert_code = Column(String(50), nullable=False, index=True)
|
||||
|
||||
# Alert classification
|
||||
alert_type = Column(SQLEnum(FoodSafetyAlertType), nullable=False, index=True)
|
||||
severity = Column(String(20), nullable=False, default="medium", index=True) # low, medium, high, critical
|
||||
risk_level = Column(String(20), nullable=False, default="medium")
|
||||
|
||||
# Source information
|
||||
source_entity_type = Column(String(50), nullable=False) # ingredient, stock, temperature_log, compliance
|
||||
source_entity_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||
ingredient_id = Column(UUID(as_uuid=True), ForeignKey("ingredients.id"), nullable=True, index=True)
|
||||
stock_id = Column(UUID(as_uuid=True), ForeignKey("stock.id"), nullable=True, index=True)
|
||||
|
||||
# Alert content
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
detailed_message = Column(Text, nullable=True)
|
||||
|
||||
# Regulatory and compliance context
|
||||
regulatory_requirement = Column(String(100), nullable=True)
|
||||
compliance_standard = Column(SQLEnum(FoodSafetyStandard), nullable=True)
|
||||
regulatory_action_required = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Alert conditions and triggers
|
||||
trigger_condition = Column(String(200), nullable=True)
|
||||
threshold_value = Column(Numeric(15, 4), nullable=True)
|
||||
actual_value = Column(Numeric(15, 4), nullable=True)
|
||||
|
||||
# Context data
|
||||
alert_data = Column(JSONB, nullable=True) # Additional context-specific data
|
||||
environmental_factors = Column(JSONB, nullable=True) # Temperature, humidity, etc.
|
||||
affected_products = Column(JSONB, nullable=True) # List of affected product IDs
|
||||
|
||||
# Risk assessment
|
||||
public_health_risk = Column(Boolean, nullable=False, default=False)
|
||||
business_impact = Column(Text, nullable=True)
|
||||
estimated_loss = Column(Numeric(12, 2), nullable=True)
|
||||
|
||||
# Alert status and lifecycle
|
||||
status = Column(String(50), nullable=False, default="active", index=True)
|
||||
# Status values: active, acknowledged, investigating, resolved, dismissed, escalated
|
||||
|
||||
alert_state = Column(String(50), nullable=False, default="new") # new, escalated, recurring
|
||||
|
||||
# Response and resolution
|
||||
immediate_actions_taken = Column(JSONB, nullable=True) # Actions taken immediately
|
||||
investigation_notes = Column(Text, nullable=True)
|
||||
resolution_action = Column(String(200), nullable=True)
|
||||
resolution_notes = Column(Text, nullable=True)
|
||||
corrective_actions = Column(JSONB, nullable=True) # List of corrective actions
|
||||
preventive_measures = Column(JSONB, nullable=True) # Preventive measures implemented
|
||||
|
||||
# Timing and escalation
|
||||
first_occurred_at = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
last_occurred_at = Column(DateTime(timezone=True), nullable=False)
|
||||
acknowledged_at = Column(DateTime(timezone=True), nullable=True)
|
||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
||||
escalation_deadline = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Occurrence tracking
|
||||
occurrence_count = Column(Integer, nullable=False, default=1)
|
||||
is_recurring = Column(Boolean, nullable=False, default=False)
|
||||
recurrence_pattern = Column(String(100), nullable=True)
|
||||
|
||||
# Responsibility and assignment
|
||||
assigned_to = Column(UUID(as_uuid=True), nullable=True)
|
||||
assigned_role = Column(String(50), nullable=True) # food_safety_manager, quality_assurance, etc.
|
||||
escalated_to = Column(UUID(as_uuid=True), nullable=True)
|
||||
escalation_level = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Notification tracking
|
||||
notification_sent = Column(Boolean, nullable=False, default=False)
|
||||
notification_methods = Column(JSONB, nullable=True) # [email, sms, whatsapp, dashboard]
|
||||
notification_recipients = Column(JSONB, nullable=True) # List of recipients
|
||||
regulatory_notification_required = Column(Boolean, nullable=False, default=False)
|
||||
regulatory_notification_sent = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Documentation and audit trail
|
||||
documentation = Column(JSONB, nullable=True) # Links to documentation, photos, etc.
|
||||
audit_trail = Column(JSONB, nullable=True) # Changes and actions taken
|
||||
external_reference = Column(String(100), nullable=True) # External system reference
|
||||
|
||||
# Performance tracking
|
||||
detection_time = Column(DateTime(timezone=True), nullable=True) # When issue was detected
|
||||
response_time_minutes = Column(Integer, nullable=True) # Time to acknowledge
|
||||
resolution_time_minutes = Column(Integer, nullable=True) # Time to resolve
|
||||
|
||||
# Quality and feedback
|
||||
alert_accuracy = Column(Boolean, nullable=True) # Was this a valid alert?
|
||||
false_positive = Column(Boolean, nullable=False, default=False)
|
||||
feedback_notes = Column(Text, nullable=True)
|
||||
|
||||
# Audit fields
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
created_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
updated_by = Column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for API responses"""
|
||||
return {
|
||||
'id': str(self.id),
|
||||
'tenant_id': str(self.tenant_id),
|
||||
'alert_code': self.alert_code,
|
||||
'alert_type': self.alert_type.value if self.alert_type else None,
|
||||
'severity': self.severity,
|
||||
'risk_level': self.risk_level,
|
||||
'source_entity_type': self.source_entity_type,
|
||||
'source_entity_id': str(self.source_entity_id),
|
||||
'ingredient_id': str(self.ingredient_id) if self.ingredient_id else None,
|
||||
'stock_id': str(self.stock_id) if self.stock_id else None,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'detailed_message': self.detailed_message,
|
||||
'regulatory_requirement': self.regulatory_requirement,
|
||||
'compliance_standard': self.compliance_standard.value if self.compliance_standard else None,
|
||||
'regulatory_action_required': self.regulatory_action_required,
|
||||
'trigger_condition': self.trigger_condition,
|
||||
'threshold_value': float(self.threshold_value) if self.threshold_value else None,
|
||||
'actual_value': float(self.actual_value) if self.actual_value else None,
|
||||
'alert_data': self.alert_data,
|
||||
'environmental_factors': self.environmental_factors,
|
||||
'affected_products': self.affected_products,
|
||||
'public_health_risk': self.public_health_risk,
|
||||
'business_impact': self.business_impact,
|
||||
'estimated_loss': float(self.estimated_loss) if self.estimated_loss else None,
|
||||
'status': self.status,
|
||||
'alert_state': self.alert_state,
|
||||
'immediate_actions_taken': self.immediate_actions_taken,
|
||||
'investigation_notes': self.investigation_notes,
|
||||
'resolution_action': self.resolution_action,
|
||||
'resolution_notes': self.resolution_notes,
|
||||
'corrective_actions': self.corrective_actions,
|
||||
'preventive_measures': self.preventive_measures,
|
||||
'first_occurred_at': self.first_occurred_at.isoformat() if self.first_occurred_at else None,
|
||||
'last_occurred_at': self.last_occurred_at.isoformat() if self.last_occurred_at else None,
|
||||
'acknowledged_at': self.acknowledged_at.isoformat() if self.acknowledged_at else None,
|
||||
'resolved_at': self.resolved_at.isoformat() if self.resolved_at else None,
|
||||
'escalation_deadline': self.escalation_deadline.isoformat() if self.escalation_deadline else None,
|
||||
'occurrence_count': self.occurrence_count,
|
||||
'is_recurring': self.is_recurring,
|
||||
'recurrence_pattern': self.recurrence_pattern,
|
||||
'assigned_to': str(self.assigned_to) if self.assigned_to else None,
|
||||
'assigned_role': self.assigned_role,
|
||||
'escalated_to': str(self.escalated_to) if self.escalated_to else None,
|
||||
'escalation_level': self.escalation_level,
|
||||
'notification_sent': self.notification_sent,
|
||||
'notification_methods': self.notification_methods,
|
||||
'notification_recipients': self.notification_recipients,
|
||||
'regulatory_notification_required': self.regulatory_notification_required,
|
||||
'regulatory_notification_sent': self.regulatory_notification_sent,
|
||||
'documentation': self.documentation,
|
||||
'audit_trail': self.audit_trail,
|
||||
'external_reference': self.external_reference,
|
||||
'detection_time': self.detection_time.isoformat() if self.detection_time else None,
|
||||
'response_time_minutes': self.response_time_minutes,
|
||||
'resolution_time_minutes': self.resolution_time_minutes,
|
||||
'alert_accuracy': self.alert_accuracy,
|
||||
'false_positive': self.false_positive,
|
||||
'feedback_notes': self.feedback_notes,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'created_by': str(self.created_by) if self.created_by else None,
|
||||
'updated_by': str(self.updated_by) if self.updated_by else None,
|
||||
}
|
||||
250
services/inventory/app/schemas/dashboard.py
Normal file
250
services/inventory/app/schemas/dashboard.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/schemas/dashboard.py
|
||||
# ================================================================
|
||||
"""
|
||||
Dashboard and analytics schemas for Inventory Service
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ===== Dashboard Summary Schemas =====
|
||||
|
||||
class InventoryDashboardSummary(BaseModel):
|
||||
"""Comprehensive dashboard summary for inventory management"""
|
||||
|
||||
# Current inventory metrics
|
||||
total_ingredients: int
|
||||
active_ingredients: int
|
||||
total_stock_value: Decimal
|
||||
total_stock_items: int
|
||||
|
||||
# Stock status breakdown
|
||||
in_stock_items: int
|
||||
low_stock_items: int
|
||||
out_of_stock_items: int
|
||||
expired_items: int
|
||||
expiring_soon_items: int
|
||||
|
||||
# Food safety metrics
|
||||
food_safety_alerts_active: int
|
||||
temperature_violations_today: int
|
||||
compliance_issues: int
|
||||
certifications_expiring_soon: int
|
||||
|
||||
# Recent activity
|
||||
recent_stock_movements: int
|
||||
recent_purchases: int
|
||||
recent_waste: int
|
||||
recent_adjustments: int
|
||||
|
||||
# Business model context
|
||||
business_model: Optional[str] = None # individual_bakery, central_bakery
|
||||
business_model_confidence: Optional[Decimal] = None
|
||||
|
||||
# Category breakdown
|
||||
stock_by_category: Dict[str, Any]
|
||||
alerts_by_severity: Dict[str, int]
|
||||
movements_by_type: Dict[str, int]
|
||||
|
||||
# Performance indicators
|
||||
inventory_turnover_ratio: Optional[Decimal] = None
|
||||
waste_percentage: Optional[Decimal] = None
|
||||
compliance_score: Optional[Decimal] = None
|
||||
cost_per_unit_avg: Optional[Decimal] = None
|
||||
|
||||
# Trending data
|
||||
stock_value_trend: List[Dict[str, Any]] = []
|
||||
alert_trend: List[Dict[str, Any]] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StockStatusSummary(BaseModel):
|
||||
"""Summary of stock status by category"""
|
||||
category: str
|
||||
total_ingredients: int
|
||||
in_stock: int
|
||||
low_stock: int
|
||||
out_of_stock: int
|
||||
total_value: Decimal
|
||||
percentage_of_total: Decimal
|
||||
|
||||
|
||||
class AlertSummary(BaseModel):
|
||||
"""Summary of alerts by type and severity"""
|
||||
alert_type: str
|
||||
severity: str
|
||||
count: int
|
||||
oldest_alert_age_hours: Optional[int] = None
|
||||
average_resolution_time_hours: Optional[int] = None
|
||||
|
||||
|
||||
class RecentActivity(BaseModel):
|
||||
"""Recent activity item for dashboard"""
|
||||
activity_type: str # stock_added, stock_consumed, alert_created, etc.
|
||||
description: str
|
||||
timestamp: datetime
|
||||
user_name: Optional[str] = None
|
||||
impact_level: str = Field(default="low") # low, medium, high
|
||||
entity_id: Optional[UUID] = None
|
||||
entity_type: Optional[str] = None
|
||||
|
||||
|
||||
# ===== Food Safety Dashboard Schemas =====
|
||||
|
||||
class FoodSafetyDashboard(BaseModel):
|
||||
"""Food safety specific dashboard metrics"""
|
||||
|
||||
# Compliance overview
|
||||
total_compliance_items: int
|
||||
compliant_items: int
|
||||
non_compliant_items: int
|
||||
pending_review_items: int
|
||||
compliance_percentage: Decimal
|
||||
|
||||
# Temperature monitoring
|
||||
temperature_sensors_online: int
|
||||
temperature_sensors_total: int
|
||||
temperature_violations_24h: int
|
||||
current_temperature_status: str # all_good, warnings, violations
|
||||
|
||||
# Expiration tracking
|
||||
items_expiring_today: int
|
||||
items_expiring_this_week: int
|
||||
expired_items_requiring_action: int
|
||||
|
||||
# Audit and certification status
|
||||
upcoming_audits: int
|
||||
overdue_audits: int
|
||||
certifications_valid: int
|
||||
certifications_expiring_soon: int
|
||||
|
||||
# Risk assessment
|
||||
high_risk_items: int
|
||||
critical_alerts: int
|
||||
regulatory_notifications_pending: int
|
||||
|
||||
# Recent safety events
|
||||
recent_safety_incidents: List[RecentActivity] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TemperatureMonitoringStatus(BaseModel):
|
||||
"""Current temperature monitoring status"""
|
||||
location: str
|
||||
equipment_id: Optional[str] = None
|
||||
current_temperature: Decimal
|
||||
target_min: Decimal
|
||||
target_max: Decimal
|
||||
status: str # normal, warning, critical
|
||||
last_reading: datetime
|
||||
hours_since_last_reading: Decimal
|
||||
alert_active: bool = False
|
||||
|
||||
|
||||
class ComplianceStatusSummary(BaseModel):
|
||||
"""Compliance status summary by standard"""
|
||||
standard: str
|
||||
standard_name: str
|
||||
total_items: int
|
||||
compliant: int
|
||||
non_compliant: int
|
||||
pending_review: int
|
||||
expired: int
|
||||
compliance_rate: Decimal
|
||||
next_audit_date: Optional[datetime] = None
|
||||
|
||||
|
||||
# ===== Analytics and Reporting Schemas =====
|
||||
|
||||
class InventoryAnalytics(BaseModel):
|
||||
"""Advanced analytics for inventory management"""
|
||||
|
||||
# Turnover analysis
|
||||
inventory_turnover_rate: Decimal
|
||||
fast_moving_items: List[Dict[str, Any]]
|
||||
slow_moving_items: List[Dict[str, Any]]
|
||||
dead_stock_items: List[Dict[str, Any]]
|
||||
|
||||
# Cost analysis
|
||||
total_inventory_cost: Decimal
|
||||
cost_by_category: Dict[str, Decimal]
|
||||
average_unit_cost_trend: List[Dict[str, Any]]
|
||||
waste_cost_analysis: Dict[str, Any]
|
||||
|
||||
# Efficiency metrics
|
||||
stockout_frequency: Dict[str, int]
|
||||
overstock_frequency: Dict[str, int]
|
||||
reorder_accuracy: Decimal
|
||||
forecast_accuracy: Decimal
|
||||
|
||||
# Quality and safety metrics
|
||||
quality_incidents_rate: Decimal
|
||||
food_safety_score: Decimal
|
||||
compliance_score_by_standard: Dict[str, Decimal]
|
||||
temperature_compliance_rate: Decimal
|
||||
|
||||
# Supplier performance
|
||||
supplier_performance: List[Dict[str, Any]]
|
||||
delivery_reliability: Decimal
|
||||
quality_consistency: Decimal
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BusinessModelInsights(BaseModel):
|
||||
"""Business model insights based on inventory patterns"""
|
||||
detected_model: str # individual_bakery, central_bakery, mixed
|
||||
confidence_score: Decimal
|
||||
|
||||
# Model characteristics
|
||||
total_ingredient_types: int
|
||||
average_stock_per_ingredient: Decimal
|
||||
finished_product_ratio: Decimal
|
||||
supplier_diversity: int
|
||||
|
||||
# Operational patterns
|
||||
order_frequency_pattern: str
|
||||
seasonal_variation: bool
|
||||
bulk_purchasing_indicator: Decimal
|
||||
production_scale_indicator: str
|
||||
|
||||
# Recommendations
|
||||
model_specific_recommendations: List[str]
|
||||
optimization_opportunities: List[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===== Request/Filter Schemas =====
|
||||
|
||||
class DashboardFilter(BaseModel):
|
||||
"""Filtering options for dashboard data"""
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
categories: Optional[List[str]] = None
|
||||
severity_levels: Optional[List[str]] = None
|
||||
alert_types: Optional[List[str]] = None
|
||||
business_model: Optional[str] = None
|
||||
include_inactive: bool = False
|
||||
|
||||
|
||||
class AlertsFilter(BaseModel):
|
||||
"""Filtering options for alerts dashboard"""
|
||||
alert_types: Optional[List[str]] = None
|
||||
severities: Optional[List[str]] = None
|
||||
statuses: Optional[List[str]] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
unresolved_only: bool = True
|
||||
283
services/inventory/app/schemas/food_safety.py
Normal file
283
services/inventory/app/schemas/food_safety.py
Normal file
@@ -0,0 +1,283 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/schemas/food_safety.py
|
||||
# ================================================================
|
||||
"""
|
||||
Food safety schemas for Inventory Service
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
# ===== Food Safety Compliance Schemas =====
|
||||
|
||||
class FoodSafetyComplianceBase(BaseModel):
|
||||
ingredient_id: UUID
|
||||
standard: str
|
||||
compliance_status: str = Field(default="pending_review")
|
||||
certification_number: Optional[str] = None
|
||||
certifying_body: Optional[str] = None
|
||||
certification_date: Optional[datetime] = None
|
||||
expiration_date: Optional[datetime] = None
|
||||
requirements: Optional[Dict[str, Any]] = None
|
||||
compliance_notes: Optional[str] = None
|
||||
documentation_url: Optional[str] = None
|
||||
last_audit_date: Optional[datetime] = None
|
||||
next_audit_date: Optional[datetime] = None
|
||||
auditor_name: Optional[str] = None
|
||||
audit_score: Optional[float] = Field(None, ge=0, le=100)
|
||||
risk_level: str = Field(default="medium")
|
||||
risk_factors: Optional[List[str]] = None
|
||||
mitigation_measures: Optional[List[str]] = None
|
||||
requires_monitoring: bool = Field(default=True)
|
||||
monitoring_frequency_days: Optional[int] = Field(None, gt=0)
|
||||
|
||||
|
||||
class FoodSafetyComplianceCreate(FoodSafetyComplianceBase):
|
||||
tenant_id: UUID
|
||||
|
||||
|
||||
class FoodSafetyComplianceUpdate(BaseModel):
|
||||
compliance_status: Optional[str] = None
|
||||
certification_number: Optional[str] = None
|
||||
certifying_body: Optional[str] = None
|
||||
certification_date: Optional[datetime] = None
|
||||
expiration_date: Optional[datetime] = None
|
||||
requirements: Optional[Dict[str, Any]] = None
|
||||
compliance_notes: Optional[str] = None
|
||||
documentation_url: Optional[str] = None
|
||||
last_audit_date: Optional[datetime] = None
|
||||
next_audit_date: Optional[datetime] = None
|
||||
auditor_name: Optional[str] = None
|
||||
audit_score: Optional[float] = Field(None, ge=0, le=100)
|
||||
risk_level: Optional[str] = None
|
||||
risk_factors: Optional[List[str]] = None
|
||||
mitigation_measures: Optional[List[str]] = None
|
||||
requires_monitoring: Optional[bool] = None
|
||||
monitoring_frequency_days: Optional[int] = Field(None, gt=0)
|
||||
|
||||
|
||||
class FoodSafetyComplianceResponse(FoodSafetyComplianceBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[UUID] = None
|
||||
updated_by: Optional[UUID] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===== Temperature Monitoring Schemas =====
|
||||
|
||||
class TemperatureLogBase(BaseModel):
|
||||
storage_location: str = Field(..., min_length=1, max_length=100)
|
||||
warehouse_zone: Optional[str] = Field(None, max_length=50)
|
||||
equipment_id: Optional[str] = Field(None, max_length=100)
|
||||
temperature_celsius: float
|
||||
humidity_percentage: Optional[float] = Field(None, ge=0, le=100)
|
||||
target_temperature_min: Optional[float] = None
|
||||
target_temperature_max: Optional[float] = None
|
||||
measurement_method: str = Field(default="manual")
|
||||
device_id: Optional[str] = Field(None, max_length=100)
|
||||
calibration_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class TemperatureLogCreate(TemperatureLogBase):
|
||||
tenant_id: UUID
|
||||
|
||||
|
||||
class TemperatureLogResponse(TemperatureLogBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
is_within_range: bool
|
||||
alert_triggered: bool
|
||||
deviation_minutes: Optional[int] = None
|
||||
recorded_at: datetime
|
||||
created_at: datetime
|
||||
recorded_by: Optional[UUID] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===== Food Safety Alert Schemas =====
|
||||
|
||||
class FoodSafetyAlertBase(BaseModel):
|
||||
alert_type: str
|
||||
severity: str = Field(default="medium")
|
||||
risk_level: str = Field(default="medium")
|
||||
source_entity_type: str
|
||||
source_entity_id: UUID
|
||||
ingredient_id: Optional[UUID] = None
|
||||
stock_id: Optional[UUID] = None
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: str = Field(..., min_length=1)
|
||||
detailed_message: Optional[str] = None
|
||||
regulatory_requirement: Optional[str] = Field(None, max_length=100)
|
||||
compliance_standard: Optional[str] = None
|
||||
regulatory_action_required: bool = Field(default=False)
|
||||
trigger_condition: Optional[str] = Field(None, max_length=200)
|
||||
threshold_value: Optional[Decimal] = None
|
||||
actual_value: Optional[Decimal] = None
|
||||
alert_data: Optional[Dict[str, Any]] = None
|
||||
environmental_factors: Optional[Dict[str, Any]] = None
|
||||
affected_products: Optional[List[UUID]] = None
|
||||
public_health_risk: bool = Field(default=False)
|
||||
business_impact: Optional[str] = None
|
||||
estimated_loss: Optional[Decimal] = Field(None, ge=0)
|
||||
|
||||
|
||||
class FoodSafetyAlertCreate(FoodSafetyAlertBase):
|
||||
tenant_id: UUID
|
||||
alert_code: str = Field(..., min_length=1, max_length=50)
|
||||
|
||||
|
||||
class FoodSafetyAlertUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
alert_state: Optional[str] = None
|
||||
immediate_actions_taken: Optional[List[str]] = None
|
||||
investigation_notes: Optional[str] = None
|
||||
resolution_action: Optional[str] = Field(None, max_length=200)
|
||||
resolution_notes: Optional[str] = None
|
||||
corrective_actions: Optional[List[str]] = None
|
||||
preventive_measures: Optional[List[str]] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
assigned_role: Optional[str] = Field(None, max_length=50)
|
||||
escalated_to: Optional[UUID] = None
|
||||
escalation_deadline: Optional[datetime] = None
|
||||
documentation: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class FoodSafetyAlertResponse(FoodSafetyAlertBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
alert_code: str
|
||||
status: str
|
||||
alert_state: str
|
||||
immediate_actions_taken: Optional[List[str]] = None
|
||||
investigation_notes: Optional[str] = None
|
||||
resolution_action: Optional[str] = None
|
||||
resolution_notes: Optional[str] = None
|
||||
corrective_actions: Optional[List[str]] = None
|
||||
preventive_measures: Optional[List[str]] = None
|
||||
first_occurred_at: datetime
|
||||
last_occurred_at: datetime
|
||||
acknowledged_at: Optional[datetime] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
escalation_deadline: Optional[datetime] = None
|
||||
occurrence_count: int
|
||||
is_recurring: bool
|
||||
recurrence_pattern: Optional[str] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
assigned_role: Optional[str] = None
|
||||
escalated_to: Optional[UUID] = None
|
||||
escalation_level: int
|
||||
notification_sent: bool
|
||||
notification_methods: Optional[List[str]] = None
|
||||
notification_recipients: Optional[List[str]] = None
|
||||
regulatory_notification_required: bool
|
||||
regulatory_notification_sent: bool
|
||||
documentation: Optional[Dict[str, Any]] = None
|
||||
audit_trail: Optional[List[Dict[str, Any]]] = None
|
||||
external_reference: Optional[str] = None
|
||||
detection_time: Optional[datetime] = None
|
||||
response_time_minutes: Optional[int] = None
|
||||
resolution_time_minutes: Optional[int] = None
|
||||
alert_accuracy: Optional[bool] = None
|
||||
false_positive: bool
|
||||
feedback_notes: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
created_by: Optional[UUID] = None
|
||||
updated_by: Optional[UUID] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ===== Bulk Operations Schemas =====
|
||||
|
||||
class BulkTemperatureLogCreate(BaseModel):
|
||||
"""Schema for bulk temperature logging"""
|
||||
tenant_id: UUID
|
||||
readings: List[TemperatureLogBase] = Field(..., min_items=1, max_items=100)
|
||||
|
||||
|
||||
class BulkComplianceUpdate(BaseModel):
|
||||
"""Schema for bulk compliance updates"""
|
||||
tenant_id: UUID
|
||||
updates: List[Dict[str, Any]] = Field(..., min_items=1, max_items=50)
|
||||
|
||||
|
||||
# ===== Filter and Query Schemas =====
|
||||
|
||||
class FoodSafetyFilter(BaseModel):
|
||||
"""Filtering options for food safety data"""
|
||||
compliance_standards: Optional[List[str]] = None
|
||||
compliance_statuses: Optional[List[str]] = None
|
||||
risk_levels: Optional[List[str]] = None
|
||||
alert_types: Optional[List[str]] = None
|
||||
severities: Optional[List[str]] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
assigned_to: Optional[UUID] = None
|
||||
include_resolved: bool = False
|
||||
regulatory_action_required: Optional[bool] = None
|
||||
|
||||
|
||||
class TemperatureMonitoringFilter(BaseModel):
|
||||
"""Filtering options for temperature monitoring"""
|
||||
storage_locations: Optional[List[str]] = None
|
||||
equipment_ids: Optional[List[str]] = None
|
||||
date_from: Optional[datetime] = None
|
||||
date_to: Optional[datetime] = None
|
||||
violations_only: bool = False
|
||||
alerts_only: bool = False
|
||||
|
||||
|
||||
# ===== Analytics Schemas =====
|
||||
|
||||
class FoodSafetyMetrics(BaseModel):
|
||||
"""Food safety performance metrics"""
|
||||
compliance_rate: Decimal = Field(..., ge=0, le=100)
|
||||
temperature_compliance_rate: Decimal = Field(..., ge=0, le=100)
|
||||
alert_response_time_avg: Optional[Decimal] = None
|
||||
alert_resolution_time_avg: Optional[Decimal] = None
|
||||
recurring_issues_count: int
|
||||
regulatory_violations: int
|
||||
certification_coverage: Decimal = Field(..., ge=0, le=100)
|
||||
audit_score_avg: Optional[Decimal] = Field(None, ge=0, le=100)
|
||||
risk_score: Decimal = Field(..., ge=0, le=10)
|
||||
|
||||
|
||||
class TemperatureAnalytics(BaseModel):
|
||||
"""Temperature monitoring analytics"""
|
||||
total_readings: int
|
||||
violations_count: int
|
||||
violation_rate: Decimal = Field(..., ge=0, le=100)
|
||||
average_temperature: Decimal
|
||||
temperature_range: Dict[str, Decimal]
|
||||
longest_violation_hours: Optional[int] = None
|
||||
equipment_performance: List[Dict[str, Any]]
|
||||
location_performance: List[Dict[str, Any]]
|
||||
|
||||
|
||||
# ===== Notification Schemas =====
|
||||
|
||||
class AlertNotificationPreferences(BaseModel):
|
||||
"""User preferences for alert notifications"""
|
||||
email_enabled: bool = True
|
||||
sms_enabled: bool = False
|
||||
whatsapp_enabled: bool = False
|
||||
dashboard_enabled: bool = True
|
||||
severity_threshold: str = Field(default="medium") # Only notify for this severity and above
|
||||
alert_types: Optional[List[str]] = None # Specific alert types to receive
|
||||
quiet_hours_start: Optional[str] = Field(None, pattern=r"^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$")
|
||||
quiet_hours_end: Optional[str] = Field(None, pattern=r"^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$")
|
||||
weekend_notifications: bool = True
|
||||
715
services/inventory/app/services/dashboard_service.py
Normal file
715
services/inventory/app/services/dashboard_service.py
Normal file
@@ -0,0 +1,715 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/services/dashboard_service.py
|
||||
# ================================================================
|
||||
"""
|
||||
Dashboard Service - Orchestrates data from multiple sources for dashboard views
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from shared.database.transactions import transactional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.inventory_service import InventoryService
|
||||
from app.services.food_safety_service import FoodSafetyService
|
||||
from app.schemas.dashboard import (
|
||||
InventoryDashboardSummary,
|
||||
BusinessModelInsights,
|
||||
InventoryAnalytics,
|
||||
DashboardFilter,
|
||||
AlertsFilter,
|
||||
StockStatusSummary,
|
||||
AlertSummary,
|
||||
RecentActivity
|
||||
)
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Service for dashboard data aggregation and analytics"""
|
||||
|
||||
def __init__(self, inventory_service: InventoryService, food_safety_service: FoodSafetyService):
|
||||
self.inventory_service = inventory_service
|
||||
self.food_safety_service = food_safety_service
|
||||
|
||||
@transactional
|
||||
async def get_inventory_dashboard_summary(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID,
|
||||
filters: Optional[DashboardFilter] = None
|
||||
) -> InventoryDashboardSummary:
|
||||
"""Get comprehensive inventory dashboard summary"""
|
||||
try:
|
||||
logger.info("Building dashboard summary", tenant_id=str(tenant_id))
|
||||
|
||||
# Get basic inventory metrics
|
||||
inventory_summary = await self.inventory_service.get_inventory_summary(tenant_id)
|
||||
|
||||
# Get food safety metrics
|
||||
food_safety_dashboard = await self.food_safety_service.get_food_safety_dashboard(db, tenant_id)
|
||||
|
||||
# Get business model insights
|
||||
business_model = await self._detect_business_model(db, tenant_id)
|
||||
|
||||
# Get category breakdown
|
||||
stock_by_category = await self._get_stock_by_category(db, tenant_id)
|
||||
|
||||
# Get alerts breakdown
|
||||
alerts_by_severity = await self._get_alerts_by_severity(db, tenant_id)
|
||||
|
||||
# Get movements breakdown
|
||||
movements_by_type = await self._get_movements_by_type(db, tenant_id)
|
||||
|
||||
# Get performance indicators
|
||||
performance_metrics = await self._calculate_performance_indicators(db, tenant_id)
|
||||
|
||||
# Get trending data
|
||||
stock_value_trend = await self._get_stock_value_trend(db, tenant_id, days=30)
|
||||
alert_trend = await self._get_alert_trend(db, tenant_id, days=30)
|
||||
|
||||
# Recent activity
|
||||
recent_activity = await self.get_recent_activity(db, tenant_id, limit=10)
|
||||
|
||||
return InventoryDashboardSummary(
|
||||
# Current inventory metrics
|
||||
total_ingredients=inventory_summary.total_ingredients,
|
||||
active_ingredients=inventory_summary.total_ingredients, # Assuming all are active
|
||||
total_stock_value=inventory_summary.total_stock_value,
|
||||
total_stock_items=await self._get_total_stock_items(db, tenant_id),
|
||||
|
||||
# Stock status breakdown
|
||||
in_stock_items=await self._get_in_stock_count(db, tenant_id),
|
||||
low_stock_items=inventory_summary.low_stock_alerts,
|
||||
out_of_stock_items=inventory_summary.out_of_stock_items,
|
||||
expired_items=inventory_summary.expired_items,
|
||||
expiring_soon_items=inventory_summary.expiring_soon_items,
|
||||
|
||||
# Food safety metrics
|
||||
food_safety_alerts_active=food_safety_dashboard.critical_alerts + food_safety_dashboard.high_risk_items,
|
||||
temperature_violations_today=food_safety_dashboard.temperature_violations_24h,
|
||||
compliance_issues=food_safety_dashboard.non_compliant_items + food_safety_dashboard.pending_review_items,
|
||||
certifications_expiring_soon=food_safety_dashboard.certifications_expiring_soon,
|
||||
|
||||
# Recent activity
|
||||
recent_stock_movements=inventory_summary.recent_movements,
|
||||
recent_purchases=inventory_summary.recent_purchases,
|
||||
recent_waste=inventory_summary.recent_waste,
|
||||
recent_adjustments=0, # Would need to calculate
|
||||
|
||||
# Business model context
|
||||
business_model=business_model.get("model"),
|
||||
business_model_confidence=business_model.get("confidence"),
|
||||
|
||||
# Category breakdown
|
||||
stock_by_category=stock_by_category,
|
||||
alerts_by_severity=alerts_by_severity,
|
||||
movements_by_type=movements_by_type,
|
||||
|
||||
# Performance indicators
|
||||
inventory_turnover_ratio=performance_metrics.get("turnover_ratio"),
|
||||
waste_percentage=performance_metrics.get("waste_percentage"),
|
||||
compliance_score=performance_metrics.get("compliance_score"),
|
||||
cost_per_unit_avg=performance_metrics.get("avg_cost_per_unit"),
|
||||
|
||||
# Trending data
|
||||
stock_value_trend=stock_value_trend,
|
||||
alert_trend=alert_trend
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to build dashboard summary", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_business_model_insights(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID
|
||||
) -> BusinessModelInsights:
|
||||
"""Get business model insights based on inventory patterns"""
|
||||
try:
|
||||
# Get ingredient metrics
|
||||
ingredient_metrics = await self._get_ingredient_metrics(db, tenant_id)
|
||||
|
||||
# Get operational patterns
|
||||
operational_patterns = await self._analyze_operational_patterns(db, tenant_id)
|
||||
|
||||
# Detect business model
|
||||
model_detection = await self._detect_business_model(db, tenant_id)
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = await self._generate_model_recommendations(
|
||||
model_detection["model"],
|
||||
ingredient_metrics,
|
||||
operational_patterns
|
||||
)
|
||||
|
||||
return BusinessModelInsights(
|
||||
detected_model=model_detection["model"],
|
||||
confidence_score=model_detection["confidence"],
|
||||
total_ingredient_types=ingredient_metrics["total_types"],
|
||||
average_stock_per_ingredient=ingredient_metrics["avg_stock"],
|
||||
finished_product_ratio=ingredient_metrics["finished_product_ratio"],
|
||||
supplier_diversity=ingredient_metrics["supplier_count"],
|
||||
order_frequency_pattern=operational_patterns["order_frequency"],
|
||||
seasonal_variation=operational_patterns["seasonal_variation"],
|
||||
bulk_purchasing_indicator=operational_patterns["bulk_indicator"],
|
||||
production_scale_indicator=operational_patterns["scale_indicator"],
|
||||
model_specific_recommendations=recommendations["specific"],
|
||||
optimization_opportunities=recommendations["optimization"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get business model insights", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_inventory_analytics(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID,
|
||||
days_back: int = 30
|
||||
) -> InventoryAnalytics:
|
||||
"""Get advanced inventory analytics"""
|
||||
try:
|
||||
# Get turnover analysis
|
||||
turnover_data = await self._analyze_inventory_turnover(db, tenant_id, days_back)
|
||||
|
||||
# Get cost analysis
|
||||
cost_analysis = await self._analyze_costs(db, tenant_id, days_back)
|
||||
|
||||
# Get efficiency metrics
|
||||
efficiency_metrics = await self._calculate_efficiency_metrics(db, tenant_id, days_back)
|
||||
|
||||
# Get quality and safety metrics
|
||||
quality_metrics = await self._calculate_quality_metrics(db, tenant_id, days_back)
|
||||
|
||||
# Get supplier performance
|
||||
supplier_performance = await self._analyze_supplier_performance(db, tenant_id, days_back)
|
||||
|
||||
return InventoryAnalytics(
|
||||
inventory_turnover_rate=turnover_data["turnover_rate"],
|
||||
fast_moving_items=turnover_data["fast_moving"],
|
||||
slow_moving_items=turnover_data["slow_moving"],
|
||||
dead_stock_items=turnover_data["dead_stock"],
|
||||
total_inventory_cost=cost_analysis["total_cost"],
|
||||
cost_by_category=cost_analysis["by_category"],
|
||||
average_unit_cost_trend=cost_analysis["cost_trend"],
|
||||
waste_cost_analysis=cost_analysis["waste_analysis"],
|
||||
stockout_frequency=efficiency_metrics["stockouts"],
|
||||
overstock_frequency=efficiency_metrics["overstocks"],
|
||||
reorder_accuracy=efficiency_metrics["reorder_accuracy"],
|
||||
forecast_accuracy=efficiency_metrics["forecast_accuracy"],
|
||||
quality_incidents_rate=quality_metrics["incidents_rate"],
|
||||
food_safety_score=quality_metrics["safety_score"],
|
||||
compliance_score_by_standard=quality_metrics["compliance_scores"],
|
||||
temperature_compliance_rate=quality_metrics["temperature_compliance"],
|
||||
supplier_performance=supplier_performance["performance"],
|
||||
delivery_reliability=supplier_performance["delivery_reliability"],
|
||||
quality_consistency=supplier_performance["quality_consistency"]
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get inventory analytics", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_stock_status_by_category(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID
|
||||
) -> List[StockStatusSummary]:
|
||||
"""Get stock status breakdown by category"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
COALESCE(i.ingredient_category::text, i.product_category::text, 'other') as category,
|
||||
COUNT(DISTINCT i.id) as total_ingredients,
|
||||
COUNT(CASE WHEN s.available_quantity > i.low_stock_threshold THEN 1 END) as in_stock,
|
||||
COUNT(CASE WHEN s.available_quantity <= i.low_stock_threshold AND s.available_quantity > 0 THEN 1 END) as low_stock,
|
||||
COUNT(CASE WHEN COALESCE(s.available_quantity, 0) = 0 THEN 1 END) as out_of_stock,
|
||||
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value
|
||||
FROM ingredients i
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ingredient_id,
|
||||
SUM(available_quantity) as available_quantity,
|
||||
AVG(unit_cost) as unit_cost
|
||||
FROM stock
|
||||
WHERE tenant_id = :tenant_id AND is_available = true
|
||||
GROUP BY ingredient_id
|
||||
) s ON i.id = s.ingredient_id
|
||||
WHERE i.tenant_id = :tenant_id AND i.is_active = true
|
||||
GROUP BY category
|
||||
ORDER BY total_value DESC
|
||||
"""
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
rows = result.fetchall()
|
||||
|
||||
summaries = []
|
||||
total_value = sum(row.total_value for row in rows)
|
||||
|
||||
for row in rows:
|
||||
percentage = (row.total_value / total_value * 100) if total_value > 0 else 0
|
||||
|
||||
summaries.append(StockStatusSummary(
|
||||
category=row.category,
|
||||
total_ingredients=row.total_ingredients,
|
||||
in_stock=row.in_stock,
|
||||
low_stock=row.low_stock,
|
||||
out_of_stock=row.out_of_stock,
|
||||
total_value=Decimal(str(row.total_value)),
|
||||
percentage_of_total=Decimal(str(percentage))
|
||||
))
|
||||
|
||||
return summaries
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock status by category", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_alerts_summary(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID,
|
||||
filters: Optional[AlertsFilter] = None
|
||||
) -> List[AlertSummary]:
|
||||
"""Get alerts summary by type and severity"""
|
||||
try:
|
||||
# Build query with filters
|
||||
where_conditions = ["tenant_id = :tenant_id", "status = 'active'"]
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
if filters:
|
||||
if filters.alert_types:
|
||||
where_conditions.append("alert_type = ANY(:alert_types)")
|
||||
params["alert_types"] = filters.alert_types
|
||||
|
||||
if filters.severities:
|
||||
where_conditions.append("severity = ANY(:severities)")
|
||||
params["severities"] = filters.severities
|
||||
|
||||
if filters.date_from:
|
||||
where_conditions.append("created_at >= :date_from")
|
||||
params["date_from"] = filters.date_from
|
||||
|
||||
if filters.date_to:
|
||||
where_conditions.append("created_at <= :date_to")
|
||||
params["date_to"] = filters.date_to
|
||||
|
||||
where_clause = " AND ".join(where_conditions)
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
alert_type,
|
||||
severity,
|
||||
COUNT(*) as count,
|
||||
MIN(EXTRACT(EPOCH FROM (NOW() - created_at))/3600)::int as oldest_alert_age_hours,
|
||||
AVG(CASE WHEN resolved_at IS NOT NULL
|
||||
THEN EXTRACT(EPOCH FROM (resolved_at - created_at))/3600
|
||||
ELSE NULL END)::int as avg_resolution_hours
|
||||
FROM food_safety_alerts
|
||||
WHERE {where_clause}
|
||||
GROUP BY alert_type, severity
|
||||
ORDER BY severity DESC, count DESC
|
||||
"""
|
||||
|
||||
result = await db.execute(query, params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
AlertSummary(
|
||||
alert_type=row.alert_type,
|
||||
severity=row.severity,
|
||||
count=row.count,
|
||||
oldest_alert_age_hours=row.oldest_alert_age_hours,
|
||||
average_resolution_time_hours=row.avg_resolution_hours
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get alerts summary", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_recent_activity(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID,
|
||||
limit: int = 20,
|
||||
activity_types: Optional[List[str]] = None
|
||||
) -> List[RecentActivity]:
|
||||
"""Get recent inventory activity"""
|
||||
try:
|
||||
activities = []
|
||||
|
||||
# Get recent stock movements
|
||||
stock_query = """
|
||||
SELECT
|
||||
'stock_movement' as activity_type,
|
||||
CASE
|
||||
WHEN movement_type = 'purchase' THEN 'Stock added: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
|
||||
WHEN movement_type = 'production_use' THEN 'Stock consumed: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
|
||||
WHEN movement_type = 'waste' THEN 'Stock wasted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
|
||||
WHEN movement_type = 'adjustment' THEN 'Stock adjusted: ' || i.name || ' (' || sm.quantity || ' ' || i.unit_of_measure::text || ')'
|
||||
ELSE 'Stock movement: ' || i.name
|
||||
END as description,
|
||||
sm.movement_date as timestamp,
|
||||
sm.created_by as user_id,
|
||||
CASE
|
||||
WHEN movement_type = 'waste' THEN 'high'
|
||||
WHEN movement_type = 'adjustment' THEN 'medium'
|
||||
ELSE 'low'
|
||||
END as impact_level,
|
||||
sm.id as entity_id,
|
||||
'stock_movement' as entity_type
|
||||
FROM stock_movements sm
|
||||
JOIN ingredients i ON sm.ingredient_id = i.id
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
ORDER BY sm.movement_date DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
result = await db.execute(stock_query, {"tenant_id": tenant_id, "limit": limit // 2})
|
||||
for row in result.fetchall():
|
||||
activities.append(RecentActivity(
|
||||
activity_type=row.activity_type,
|
||||
description=row.description,
|
||||
timestamp=row.timestamp,
|
||||
impact_level=row.impact_level,
|
||||
entity_id=row.entity_id,
|
||||
entity_type=row.entity_type
|
||||
))
|
||||
|
||||
# Get recent food safety alerts
|
||||
alert_query = """
|
||||
SELECT
|
||||
'food_safety_alert' as activity_type,
|
||||
title as description,
|
||||
created_at as timestamp,
|
||||
created_by as user_id,
|
||||
CASE
|
||||
WHEN severity = 'critical' THEN 'high'
|
||||
WHEN severity = 'high' THEN 'medium'
|
||||
ELSE 'low'
|
||||
END as impact_level,
|
||||
id as entity_id,
|
||||
'food_safety_alert' as entity_type
|
||||
FROM food_safety_alerts
|
||||
WHERE tenant_id = :tenant_id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
|
||||
result = await db.execute(alert_query, {"tenant_id": tenant_id, "limit": limit // 2})
|
||||
for row in result.fetchall():
|
||||
activities.append(RecentActivity(
|
||||
activity_type=row.activity_type,
|
||||
description=row.description,
|
||||
timestamp=row.timestamp,
|
||||
impact_level=row.impact_level,
|
||||
entity_id=row.entity_id,
|
||||
entity_type=row.entity_type
|
||||
))
|
||||
|
||||
# Sort by timestamp and limit
|
||||
activities.sort(key=lambda x: x.timestamp, reverse=True)
|
||||
return activities[:limit]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get recent activity", error=str(e))
|
||||
raise
|
||||
|
||||
async def get_live_metrics(self, db, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get real-time inventory metrics"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(DISTINCT i.id) as total_ingredients,
|
||||
COUNT(CASE WHEN s.available_quantity > i.low_stock_threshold THEN 1 END) as in_stock,
|
||||
COUNT(CASE WHEN s.available_quantity <= i.low_stock_threshold THEN 1 END) as low_stock,
|
||||
COUNT(CASE WHEN s.available_quantity = 0 THEN 1 END) as out_of_stock,
|
||||
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value,
|
||||
COUNT(CASE WHEN s.expiration_date < NOW() THEN 1 END) as expired_items,
|
||||
COUNT(CASE WHEN s.expiration_date BETWEEN NOW() AND NOW() + INTERVAL '7 days' THEN 1 END) as expiring_soon
|
||||
FROM ingredients i
|
||||
LEFT JOIN stock s ON i.id = s.ingredient_id AND s.is_available = true
|
||||
WHERE i.tenant_id = :tenant_id AND i.is_active = true
|
||||
"""
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
metrics = result.fetchone()
|
||||
|
||||
return {
|
||||
"total_ingredients": metrics.total_ingredients,
|
||||
"in_stock": metrics.in_stock,
|
||||
"low_stock": metrics.low_stock,
|
||||
"out_of_stock": metrics.out_of_stock,
|
||||
"total_value": float(metrics.total_value),
|
||||
"expired_items": metrics.expired_items,
|
||||
"expiring_soon": metrics.expiring_soon,
|
||||
"last_updated": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get live metrics", error=str(e))
|
||||
raise
|
||||
|
||||
async def export_dashboard_data(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID,
|
||||
format: str,
|
||||
date_from: Optional[datetime] = None,
|
||||
date_to: Optional[datetime] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Export dashboard data in specified format"""
|
||||
try:
|
||||
# Get dashboard summary
|
||||
summary = await self.get_inventory_dashboard_summary(db, tenant_id)
|
||||
|
||||
# Get analytics
|
||||
analytics = await self.get_inventory_analytics(db, tenant_id)
|
||||
|
||||
export_data = {
|
||||
"export_info": {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"tenant_id": str(tenant_id),
|
||||
"format": format,
|
||||
"date_range": {
|
||||
"from": date_from.isoformat() if date_from else None,
|
||||
"to": date_to.isoformat() if date_to else None
|
||||
}
|
||||
},
|
||||
"dashboard_summary": summary.dict(),
|
||||
"analytics": analytics.dict()
|
||||
}
|
||||
|
||||
if format.lower() == "json":
|
||||
return export_data
|
||||
elif format.lower() in ["csv", "excel"]:
|
||||
# For CSV/Excel, flatten the data structure
|
||||
return {
|
||||
"message": f"Export in {format} format would be generated here",
|
||||
"data_preview": export_data
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unsupported export format: {format}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to export dashboard data", error=str(e))
|
||||
raise
|
||||
|
||||
# ===== PRIVATE HELPER METHODS =====
|
||||
|
||||
async def _detect_business_model(self, db, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Detect business model based on inventory patterns"""
|
||||
try:
|
||||
if not settings.ENABLE_BUSINESS_MODEL_DETECTION:
|
||||
return {"model": "unknown", "confidence": Decimal("0")}
|
||||
|
||||
# Get ingredient metrics
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_ingredients,
|
||||
COUNT(CASE WHEN product_type = 'finished_product' THEN 1 END) as finished_products,
|
||||
COUNT(CASE WHEN product_type = 'ingredient' THEN 1 END) as raw_ingredients,
|
||||
COUNT(DISTINCT supplier_name) as supplier_count,
|
||||
AVG(CASE WHEN s.available_quantity IS NOT NULL THEN s.available_quantity ELSE 0 END) as avg_stock_level
|
||||
FROM ingredients i
|
||||
LEFT JOIN (
|
||||
SELECT ingredient_id, SUM(available_quantity) as available_quantity
|
||||
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
|
||||
) s ON i.id = s.ingredient_id
|
||||
WHERE i.tenant_id = :tenant_id AND i.is_active = true
|
||||
"""
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
metrics = result.fetchone()
|
||||
|
||||
# Business model detection logic
|
||||
total_ingredients = metrics.total_ingredients
|
||||
finished_ratio = metrics.finished_products / total_ingredients if total_ingredients > 0 else 0
|
||||
|
||||
if total_ingredients >= settings.CENTRAL_BAKERY_THRESHOLD_INGREDIENTS:
|
||||
if finished_ratio > 0.3: # More than 30% finished products
|
||||
model = "central_bakery"
|
||||
confidence = Decimal("85")
|
||||
else:
|
||||
model = "central_bakery"
|
||||
confidence = Decimal("70")
|
||||
elif total_ingredients <= settings.INDIVIDUAL_BAKERY_THRESHOLD_INGREDIENTS:
|
||||
model = "individual_bakery"
|
||||
confidence = Decimal("80")
|
||||
else:
|
||||
model = "mixed"
|
||||
confidence = Decimal("60")
|
||||
|
||||
return {"model": model, "confidence": confidence}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to detect business model", error=str(e))
|
||||
return {"model": "unknown", "confidence": Decimal("0")}
|
||||
|
||||
async def _get_stock_by_category(self, db, tenant_id: UUID) -> Dict[str, Any]:
|
||||
"""Get stock breakdown by category"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
COALESCE(i.ingredient_category::text, i.product_category::text, 'other') as category,
|
||||
COUNT(*) as count,
|
||||
COALESCE(SUM(s.available_quantity * s.unit_cost), 0) as total_value
|
||||
FROM ingredients i
|
||||
LEFT JOIN (
|
||||
SELECT ingredient_id, SUM(available_quantity) as available_quantity, AVG(unit_cost) as unit_cost
|
||||
FROM stock WHERE tenant_id = :tenant_id GROUP BY ingredient_id
|
||||
) s ON i.id = s.ingredient_id
|
||||
WHERE i.tenant_id = :tenant_id AND i.is_active = true
|
||||
GROUP BY category
|
||||
"""
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
categories = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
categories[row.category] = {
|
||||
"count": row.count,
|
||||
"total_value": float(row.total_value)
|
||||
}
|
||||
|
||||
return categories
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock by category", error=str(e))
|
||||
return {}
|
||||
|
||||
async def _get_alerts_by_severity(self, db, tenant_id: UUID) -> Dict[str, int]:
|
||||
"""Get alerts breakdown by severity"""
|
||||
try:
|
||||
query = """
|
||||
SELECT severity, COUNT(*) as count
|
||||
FROM food_safety_alerts
|
||||
WHERE tenant_id = :tenant_id AND status = 'active'
|
||||
GROUP BY severity
|
||||
"""
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
alerts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
|
||||
for row in result.fetchall():
|
||||
alerts[row.severity] = row.count
|
||||
|
||||
return alerts
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get alerts by severity", error=str(e))
|
||||
return {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
|
||||
async def _get_movements_by_type(self, db, tenant_id: UUID) -> Dict[str, int]:
|
||||
"""Get movements breakdown by type"""
|
||||
try:
|
||||
query = """
|
||||
SELECT sm.movement_type, COUNT(*) as count
|
||||
FROM stock_movements sm
|
||||
JOIN ingredients i ON sm.ingredient_id = i.id
|
||||
WHERE i.tenant_id = :tenant_id
|
||||
AND sm.movement_date > NOW() - INTERVAL '7 days'
|
||||
GROUP BY sm.movement_type
|
||||
"""
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
movements = {}
|
||||
|
||||
for row in result.fetchall():
|
||||
movements[row.movement_type] = row.count
|
||||
|
||||
return movements
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get movements by type", error=str(e))
|
||||
return {}
|
||||
|
||||
async def _calculate_performance_indicators(self, db, tenant_id: UUID) -> Dict[str, Decimal]:
|
||||
"""Calculate performance indicators"""
|
||||
try:
|
||||
# This would involve complex calculations
|
||||
# For now, return placeholder values
|
||||
return {
|
||||
"turnover_ratio": Decimal("4.2"),
|
||||
"waste_percentage": Decimal("2.1"),
|
||||
"compliance_score": Decimal("8.5"),
|
||||
"avg_cost_per_unit": Decimal("12.45")
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to calculate performance indicators", error=str(e))
|
||||
return {}
|
||||
|
||||
async def _get_stock_value_trend(self, db, tenant_id: UUID, days: int) -> List[Dict[str, Any]]:
|
||||
"""Get stock value trend over time"""
|
||||
try:
|
||||
# This would track stock value changes over time
|
||||
# For now, return sample data
|
||||
trend_data = []
|
||||
base_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
for i in range(0, days, 7): # Weekly data points
|
||||
trend_data.append({
|
||||
"date": (base_date + timedelta(days=i)).isoformat(),
|
||||
"value": float(Decimal("50000") + Decimal(str(i * 100)))
|
||||
})
|
||||
|
||||
return trend_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stock value trend", error=str(e))
|
||||
return []
|
||||
|
||||
async def _get_alert_trend(self, db, tenant_id: UUID, days: int) -> List[Dict[str, Any]]:
|
||||
"""Get alert trend over time"""
|
||||
try:
|
||||
query = """
|
||||
SELECT
|
||||
DATE(created_at) as alert_date,
|
||||
COUNT(*) as alert_count,
|
||||
COUNT(CASE WHEN severity IN ('high', 'critical') THEN 1 END) as high_severity_count
|
||||
FROM food_safety_alerts
|
||||
WHERE tenant_id = :tenant_id
|
||||
AND created_at > NOW() - INTERVAL '%s days'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY alert_date
|
||||
""" % days
|
||||
|
||||
result = await db.execute(query, {"tenant_id": tenant_id})
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.alert_date.isoformat(),
|
||||
"total_alerts": row.alert_count,
|
||||
"high_severity_alerts": row.high_severity_count
|
||||
}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get alert trend", error=str(e))
|
||||
return []
|
||||
|
||||
# Additional helper methods would be implemented here for:
|
||||
# - _get_total_stock_items
|
||||
# - _get_in_stock_count
|
||||
# - _get_ingredient_metrics
|
||||
# - _analyze_operational_patterns
|
||||
# - _generate_model_recommendations
|
||||
# - _analyze_inventory_turnover
|
||||
# - _analyze_costs
|
||||
# - _calculate_efficiency_metrics
|
||||
# - _calculate_quality_metrics
|
||||
# - _analyze_supplier_performance
|
||||
|
||||
# These are complex analytical methods that would require detailed implementation
|
||||
# based on specific business requirements and data structures
|
||||
633
services/inventory/app/services/food_safety_service.py
Normal file
633
services/inventory/app/services/food_safety_service.py
Normal file
@@ -0,0 +1,633 @@
|
||||
# ================================================================
|
||||
# services/inventory/app/services/food_safety_service.py
|
||||
# ================================================================
|
||||
"""
|
||||
Food Safety Service - Business logic for food safety and compliance
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import UUID
|
||||
import structlog
|
||||
|
||||
from shared.notifications.alert_integration import AlertIntegration
|
||||
from shared.database.transactions import transactional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.food_safety import (
|
||||
FoodSafetyCompliance,
|
||||
TemperatureLog,
|
||||
FoodSafetyAlert,
|
||||
FoodSafetyStandard,
|
||||
ComplianceStatus,
|
||||
FoodSafetyAlertType
|
||||
)
|
||||
from app.schemas.food_safety import (
|
||||
FoodSafetyComplianceCreate,
|
||||
FoodSafetyComplianceUpdate,
|
||||
FoodSafetyComplianceResponse,
|
||||
TemperatureLogCreate,
|
||||
TemperatureLogResponse,
|
||||
FoodSafetyAlertCreate,
|
||||
FoodSafetyAlertUpdate,
|
||||
FoodSafetyAlertResponse,
|
||||
FoodSafetyMetrics,
|
||||
TemperatureAnalytics
|
||||
)
|
||||
from app.schemas.dashboard import FoodSafetyDashboard, TemperatureMonitoringStatus
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class FoodSafetyService:
|
||||
"""Service for food safety and compliance operations"""
|
||||
|
||||
def __init__(self):
|
||||
self.alert_integration = AlertIntegration()
|
||||
|
||||
# ===== COMPLIANCE MANAGEMENT =====
|
||||
|
||||
@transactional
|
||||
async def create_compliance_record(
|
||||
self,
|
||||
db,
|
||||
compliance_data: FoodSafetyComplianceCreate,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> FoodSafetyComplianceResponse:
|
||||
"""Create a new food safety compliance record"""
|
||||
try:
|
||||
logger.info("Creating compliance record",
|
||||
ingredient_id=str(compliance_data.ingredient_id),
|
||||
standard=compliance_data.standard)
|
||||
|
||||
# Validate compliance data
|
||||
await self._validate_compliance_data(db, compliance_data)
|
||||
|
||||
# Create compliance record
|
||||
compliance = FoodSafetyCompliance(
|
||||
tenant_id=compliance_data.tenant_id,
|
||||
ingredient_id=compliance_data.ingredient_id,
|
||||
standard=FoodSafetyStandard(compliance_data.standard),
|
||||
compliance_status=ComplianceStatus(compliance_data.compliance_status),
|
||||
certification_number=compliance_data.certification_number,
|
||||
certifying_body=compliance_data.certifying_body,
|
||||
certification_date=compliance_data.certification_date,
|
||||
expiration_date=compliance_data.expiration_date,
|
||||
requirements=compliance_data.requirements,
|
||||
compliance_notes=compliance_data.compliance_notes,
|
||||
documentation_url=compliance_data.documentation_url,
|
||||
last_audit_date=compliance_data.last_audit_date,
|
||||
next_audit_date=compliance_data.next_audit_date,
|
||||
auditor_name=compliance_data.auditor_name,
|
||||
audit_score=compliance_data.audit_score,
|
||||
risk_level=compliance_data.risk_level,
|
||||
risk_factors=compliance_data.risk_factors,
|
||||
mitigation_measures=compliance_data.mitigation_measures,
|
||||
requires_monitoring=compliance_data.requires_monitoring,
|
||||
monitoring_frequency_days=compliance_data.monitoring_frequency_days,
|
||||
created_by=user_id,
|
||||
updated_by=user_id
|
||||
)
|
||||
|
||||
db.add(compliance)
|
||||
await db.flush()
|
||||
await db.refresh(compliance)
|
||||
|
||||
# Check for compliance alerts
|
||||
await self._check_compliance_alerts(db, compliance)
|
||||
|
||||
logger.info("Compliance record created",
|
||||
compliance_id=str(compliance.id))
|
||||
|
||||
return FoodSafetyComplianceResponse(**compliance.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create compliance record", error=str(e))
|
||||
raise
|
||||
|
||||
@transactional
|
||||
async def update_compliance_record(
|
||||
self,
|
||||
db,
|
||||
compliance_id: UUID,
|
||||
tenant_id: UUID,
|
||||
compliance_data: FoodSafetyComplianceUpdate,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> Optional[FoodSafetyComplianceResponse]:
|
||||
"""Update an existing compliance record"""
|
||||
try:
|
||||
# Get existing compliance record
|
||||
compliance = await db.get(FoodSafetyCompliance, compliance_id)
|
||||
if not compliance or compliance.tenant_id != tenant_id:
|
||||
return None
|
||||
|
||||
# Update fields
|
||||
update_fields = compliance_data.dict(exclude_unset=True)
|
||||
for field, value in update_fields.items():
|
||||
if hasattr(compliance, field):
|
||||
if field in ['compliance_status'] and value:
|
||||
setattr(compliance, field, ComplianceStatus(value))
|
||||
else:
|
||||
setattr(compliance, field, value)
|
||||
|
||||
compliance.updated_by = user_id
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(compliance)
|
||||
|
||||
# Check for compliance alerts after update
|
||||
await self._check_compliance_alerts(db, compliance)
|
||||
|
||||
logger.info("Compliance record updated",
|
||||
compliance_id=str(compliance.id))
|
||||
|
||||
return FoodSafetyComplianceResponse(**compliance.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update compliance record",
|
||||
compliance_id=str(compliance_id),
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
# ===== TEMPERATURE MONITORING =====
|
||||
|
||||
@transactional
|
||||
async def log_temperature(
|
||||
self,
|
||||
db,
|
||||
temp_data: TemperatureLogCreate,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> TemperatureLogResponse:
|
||||
"""Log a temperature reading"""
|
||||
try:
|
||||
# Determine if temperature is within range
|
||||
is_within_range = self._is_temperature_within_range(
|
||||
temp_data.temperature_celsius,
|
||||
temp_data.target_temperature_min,
|
||||
temp_data.target_temperature_max,
|
||||
temp_data.storage_location
|
||||
)
|
||||
|
||||
# Create temperature log
|
||||
temp_log = TemperatureLog(
|
||||
tenant_id=temp_data.tenant_id,
|
||||
storage_location=temp_data.storage_location,
|
||||
warehouse_zone=temp_data.warehouse_zone,
|
||||
equipment_id=temp_data.equipment_id,
|
||||
temperature_celsius=temp_data.temperature_celsius,
|
||||
humidity_percentage=temp_data.humidity_percentage,
|
||||
target_temperature_min=temp_data.target_temperature_min,
|
||||
target_temperature_max=temp_data.target_temperature_max,
|
||||
is_within_range=is_within_range,
|
||||
alert_triggered=not is_within_range,
|
||||
measurement_method=temp_data.measurement_method,
|
||||
device_id=temp_data.device_id,
|
||||
calibration_date=temp_data.calibration_date,
|
||||
recorded_by=user_id
|
||||
)
|
||||
|
||||
db.add(temp_log)
|
||||
await db.flush()
|
||||
await db.refresh(temp_log)
|
||||
|
||||
# Create alert if temperature is out of range
|
||||
if not is_within_range:
|
||||
await self._create_temperature_alert(db, temp_log)
|
||||
|
||||
logger.info("Temperature logged",
|
||||
location=temp_data.storage_location,
|
||||
temperature=temp_data.temperature_celsius,
|
||||
within_range=is_within_range)
|
||||
|
||||
return TemperatureLogResponse(**temp_log.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to log temperature", error=str(e))
|
||||
raise
|
||||
|
||||
@transactional
|
||||
async def bulk_log_temperatures(
|
||||
self,
|
||||
db,
|
||||
temp_readings: List[TemperatureLogCreate],
|
||||
user_id: Optional[UUID] = None
|
||||
) -> List[TemperatureLogResponse]:
|
||||
"""Bulk log temperature readings"""
|
||||
try:
|
||||
results = []
|
||||
alerts_to_create = []
|
||||
|
||||
for temp_data in temp_readings:
|
||||
# Determine if temperature is within range
|
||||
is_within_range = self._is_temperature_within_range(
|
||||
temp_data.temperature_celsius,
|
||||
temp_data.target_temperature_min,
|
||||
temp_data.target_temperature_max,
|
||||
temp_data.storage_location
|
||||
)
|
||||
|
||||
# Create temperature log
|
||||
temp_log = TemperatureLog(
|
||||
tenant_id=temp_data.tenant_id,
|
||||
storage_location=temp_data.storage_location,
|
||||
warehouse_zone=temp_data.warehouse_zone,
|
||||
equipment_id=temp_data.equipment_id,
|
||||
temperature_celsius=temp_data.temperature_celsius,
|
||||
humidity_percentage=temp_data.humidity_percentage,
|
||||
target_temperature_min=temp_data.target_temperature_min,
|
||||
target_temperature_max=temp_data.target_temperature_max,
|
||||
is_within_range=is_within_range,
|
||||
alert_triggered=not is_within_range,
|
||||
measurement_method=temp_data.measurement_method,
|
||||
device_id=temp_data.device_id,
|
||||
calibration_date=temp_data.calibration_date,
|
||||
recorded_by=user_id
|
||||
)
|
||||
|
||||
db.add(temp_log)
|
||||
|
||||
if not is_within_range:
|
||||
alerts_to_create.append(temp_log)
|
||||
|
||||
results.append(TemperatureLogResponse(**temp_log.to_dict()))
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Create alerts for out-of-range temperatures
|
||||
for temp_log in alerts_to_create:
|
||||
await self._create_temperature_alert(db, temp_log)
|
||||
|
||||
logger.info("Bulk temperature logging completed",
|
||||
count=len(temp_readings),
|
||||
violations=len(alerts_to_create))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to bulk log temperatures", error=str(e))
|
||||
raise
|
||||
|
||||
# ===== ALERT MANAGEMENT =====
|
||||
|
||||
@transactional
|
||||
async def create_food_safety_alert(
|
||||
self,
|
||||
db,
|
||||
alert_data: FoodSafetyAlertCreate,
|
||||
user_id: Optional[UUID] = None
|
||||
) -> FoodSafetyAlertResponse:
|
||||
"""Create a food safety alert"""
|
||||
try:
|
||||
alert = FoodSafetyAlert(
|
||||
tenant_id=alert_data.tenant_id,
|
||||
alert_code=alert_data.alert_code,
|
||||
alert_type=FoodSafetyAlertType(alert_data.alert_type),
|
||||
severity=alert_data.severity,
|
||||
risk_level=alert_data.risk_level,
|
||||
source_entity_type=alert_data.source_entity_type,
|
||||
source_entity_id=alert_data.source_entity_id,
|
||||
ingredient_id=alert_data.ingredient_id,
|
||||
stock_id=alert_data.stock_id,
|
||||
title=alert_data.title,
|
||||
description=alert_data.description,
|
||||
detailed_message=alert_data.detailed_message,
|
||||
regulatory_requirement=alert_data.regulatory_requirement,
|
||||
compliance_standard=FoodSafetyStandard(alert_data.compliance_standard) if alert_data.compliance_standard else None,
|
||||
regulatory_action_required=alert_data.regulatory_action_required,
|
||||
trigger_condition=alert_data.trigger_condition,
|
||||
threshold_value=alert_data.threshold_value,
|
||||
actual_value=alert_data.actual_value,
|
||||
alert_data=alert_data.alert_data,
|
||||
environmental_factors=alert_data.environmental_factors,
|
||||
affected_products=alert_data.affected_products,
|
||||
public_health_risk=alert_data.public_health_risk,
|
||||
business_impact=alert_data.business_impact,
|
||||
estimated_loss=alert_data.estimated_loss,
|
||||
first_occurred_at=datetime.now(),
|
||||
last_occurred_at=datetime.now(),
|
||||
created_by=user_id
|
||||
)
|
||||
|
||||
db.add(alert)
|
||||
await db.flush()
|
||||
await db.refresh(alert)
|
||||
|
||||
# Send notifications
|
||||
await self._send_alert_notifications(alert)
|
||||
|
||||
logger.info("Food safety alert created",
|
||||
alert_id=str(alert.id),
|
||||
alert_type=alert_data.alert_type,
|
||||
severity=alert_data.severity)
|
||||
|
||||
return FoodSafetyAlertResponse(**alert.to_dict())
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create food safety alert", error=str(e))
|
||||
raise
|
||||
|
||||
# ===== DASHBOARD AND ANALYTICS =====
|
||||
|
||||
async def get_food_safety_dashboard(
|
||||
self,
|
||||
db,
|
||||
tenant_id: UUID
|
||||
) -> FoodSafetyDashboard:
|
||||
"""Get food safety dashboard data"""
|
||||
try:
|
||||
# Get compliance overview
|
||||
compliance_query = """
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN compliance_status = 'compliant' THEN 1 END) as compliant,
|
||||
COUNT(CASE WHEN compliance_status = 'non_compliant' THEN 1 END) as non_compliant,
|
||||
COUNT(CASE WHEN compliance_status = 'pending_review' THEN 1 END) as pending_review
|
||||
FROM food_safety_compliance
|
||||
WHERE tenant_id = :tenant_id AND is_active = true
|
||||
"""
|
||||
|
||||
compliance_result = await db.execute(compliance_query, {"tenant_id": tenant_id})
|
||||
compliance_stats = compliance_result.fetchone()
|
||||
|
||||
total_compliance = compliance_stats.total or 0
|
||||
compliant_items = compliance_stats.compliant or 0
|
||||
compliance_percentage = (compliant_items / total_compliance * 100) if total_compliance > 0 else 0
|
||||
|
||||
# Get temperature monitoring status
|
||||
temp_query = """
|
||||
SELECT
|
||||
COUNT(DISTINCT equipment_id) as sensors_online,
|
||||
COUNT(CASE WHEN NOT is_within_range AND recorded_at > NOW() - INTERVAL '24 hours' THEN 1 END) as violations_24h
|
||||
FROM temperature_logs
|
||||
WHERE tenant_id = :tenant_id AND recorded_at > NOW() - INTERVAL '1 hour'
|
||||
"""
|
||||
|
||||
temp_result = await db.execute(temp_query, {"tenant_id": tenant_id})
|
||||
temp_stats = temp_result.fetchone()
|
||||
|
||||
# Get expiration tracking
|
||||
expiration_query = """
|
||||
SELECT
|
||||
COUNT(CASE WHEN expiration_date::date = CURRENT_DATE THEN 1 END) as expiring_today,
|
||||
COUNT(CASE WHEN expiration_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days' THEN 1 END) as expiring_week,
|
||||
COUNT(CASE WHEN expiration_date < CURRENT_DATE AND is_available THEN 1 END) as expired_requiring_action
|
||||
FROM stock s
|
||||
JOIN ingredients i ON s.ingredient_id = i.id
|
||||
WHERE i.tenant_id = :tenant_id AND s.is_available = true
|
||||
"""
|
||||
|
||||
expiration_result = await db.execute(expiration_query, {"tenant_id": tenant_id})
|
||||
expiration_stats = expiration_result.fetchone()
|
||||
|
||||
# Get alert counts
|
||||
alert_query = """
|
||||
SELECT
|
||||
COUNT(CASE WHEN severity = 'high' OR severity = 'critical' THEN 1 END) as high_risk,
|
||||
COUNT(CASE WHEN severity = 'critical' THEN 1 END) as critical,
|
||||
COUNT(CASE WHEN regulatory_action_required AND NOT resolved_at THEN 1 END) as regulatory_pending
|
||||
FROM food_safety_alerts
|
||||
WHERE tenant_id = :tenant_id AND status = 'active'
|
||||
"""
|
||||
|
||||
alert_result = await db.execute(alert_query, {"tenant_id": tenant_id})
|
||||
alert_stats = alert_result.fetchone()
|
||||
|
||||
return FoodSafetyDashboard(
|
||||
total_compliance_items=total_compliance,
|
||||
compliant_items=compliant_items,
|
||||
non_compliant_items=compliance_stats.non_compliant or 0,
|
||||
pending_review_items=compliance_stats.pending_review or 0,
|
||||
compliance_percentage=Decimal(str(compliance_percentage)),
|
||||
temperature_sensors_online=temp_stats.sensors_online or 0,
|
||||
temperature_sensors_total=temp_stats.sensors_online or 0, # Would need actual count
|
||||
temperature_violations_24h=temp_stats.violations_24h or 0,
|
||||
current_temperature_status="normal", # Would need to calculate
|
||||
items_expiring_today=expiration_stats.expiring_today or 0,
|
||||
items_expiring_this_week=expiration_stats.expiring_week or 0,
|
||||
expired_items_requiring_action=expiration_stats.expired_requiring_action or 0,
|
||||
upcoming_audits=0, # Would need to calculate
|
||||
overdue_audits=0, # Would need to calculate
|
||||
certifications_valid=compliant_items,
|
||||
certifications_expiring_soon=0, # Would need to calculate
|
||||
high_risk_items=alert_stats.high_risk or 0,
|
||||
critical_alerts=alert_stats.critical or 0,
|
||||
regulatory_notifications_pending=alert_stats.regulatory_pending or 0,
|
||||
recent_safety_incidents=[] # Would need to get recent incidents
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get food safety dashboard", error=str(e))
|
||||
raise
|
||||
|
||||
# ===== PRIVATE HELPER METHODS =====
|
||||
|
||||
async def _validate_compliance_data(self, db, compliance_data: FoodSafetyComplianceCreate):
|
||||
"""Validate compliance data for business rules"""
|
||||
# Check if ingredient exists
|
||||
ingredient_query = "SELECT id FROM ingredients WHERE id = :ingredient_id AND tenant_id = :tenant_id"
|
||||
result = await db.execute(ingredient_query, {
|
||||
"ingredient_id": compliance_data.ingredient_id,
|
||||
"tenant_id": compliance_data.tenant_id
|
||||
})
|
||||
|
||||
if not result.fetchone():
|
||||
raise ValueError("Ingredient not found")
|
||||
|
||||
# Validate standard
|
||||
try:
|
||||
FoodSafetyStandard(compliance_data.standard)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid food safety standard: {compliance_data.standard}")
|
||||
|
||||
# Validate compliance status
|
||||
try:
|
||||
ComplianceStatus(compliance_data.compliance_status)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid compliance status: {compliance_data.compliance_status}")
|
||||
|
||||
def _is_temperature_within_range(
|
||||
self,
|
||||
temperature: float,
|
||||
target_min: Optional[float],
|
||||
target_max: Optional[float],
|
||||
location: str
|
||||
) -> bool:
|
||||
"""Check if temperature is within acceptable range"""
|
||||
# Use target ranges if provided, otherwise use default ranges
|
||||
if target_min is not None and target_max is not None:
|
||||
return target_min <= temperature <= target_max
|
||||
|
||||
# Default ranges based on location type
|
||||
if "freezer" in location.lower():
|
||||
return settings.FREEZER_TEMP_MIN <= temperature <= settings.FREEZER_TEMP_MAX
|
||||
elif "refrigerat" in location.lower() or "fridge" in location.lower():
|
||||
return settings.REFRIGERATION_TEMP_MIN <= temperature <= settings.REFRIGERATION_TEMP_MAX
|
||||
else:
|
||||
return settings.ROOM_TEMP_MIN <= temperature <= settings.ROOM_TEMP_MAX
|
||||
|
||||
async def _create_temperature_alert(self, db, temp_log: TemperatureLog):
|
||||
"""Create an alert for temperature violation"""
|
||||
try:
|
||||
alert_code = f"TEMP-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
# Determine severity based on deviation
|
||||
target_min = temp_log.target_temperature_min or 0
|
||||
target_max = temp_log.target_temperature_max or 25
|
||||
deviation = max(
|
||||
abs(temp_log.temperature_celsius - target_min),
|
||||
abs(temp_log.temperature_celsius - target_max)
|
||||
)
|
||||
|
||||
if deviation > 10:
|
||||
severity = "critical"
|
||||
elif deviation > 5:
|
||||
severity = "high"
|
||||
else:
|
||||
severity = "medium"
|
||||
|
||||
alert = FoodSafetyAlert(
|
||||
tenant_id=temp_log.tenant_id,
|
||||
alert_code=alert_code,
|
||||
alert_type=FoodSafetyAlertType.TEMPERATURE_VIOLATION,
|
||||
severity=severity,
|
||||
risk_level="high" if severity == "critical" else "medium",
|
||||
source_entity_type="temperature_log",
|
||||
source_entity_id=temp_log.id,
|
||||
title=f"Temperature violation in {temp_log.storage_location}",
|
||||
description=f"Temperature reading of {temp_log.temperature_celsius}°C is outside acceptable range",
|
||||
regulatory_action_required=severity == "critical",
|
||||
trigger_condition="temperature_out_of_range",
|
||||
threshold_value=target_max,
|
||||
actual_value=temp_log.temperature_celsius,
|
||||
alert_data={
|
||||
"location": temp_log.storage_location,
|
||||
"equipment_id": temp_log.equipment_id,
|
||||
"target_range": f"{target_min}°C - {target_max}°C"
|
||||
},
|
||||
environmental_factors={
|
||||
"temperature": temp_log.temperature_celsius,
|
||||
"humidity": temp_log.humidity_percentage
|
||||
},
|
||||
first_occurred_at=datetime.now(),
|
||||
last_occurred_at=datetime.now()
|
||||
)
|
||||
|
||||
db.add(alert)
|
||||
await db.flush()
|
||||
|
||||
# Send notifications
|
||||
await self._send_alert_notifications(alert)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create temperature alert", error=str(e))
|
||||
|
||||
async def _check_compliance_alerts(self, db, compliance: FoodSafetyCompliance):
|
||||
"""Check for compliance-related alerts"""
|
||||
try:
|
||||
alerts_to_create = []
|
||||
|
||||
# Check for expiring certifications
|
||||
if compliance.expiration_date:
|
||||
days_to_expiry = (compliance.expiration_date - datetime.now()).days
|
||||
if days_to_expiry <= settings.CERTIFICATION_EXPIRY_WARNING_DAYS:
|
||||
alert_code = f"CERT-{uuid.uuid4().hex[:8].upper()}"
|
||||
severity = "critical" if days_to_expiry <= 7 else "high"
|
||||
|
||||
alert = FoodSafetyAlert(
|
||||
tenant_id=compliance.tenant_id,
|
||||
alert_code=alert_code,
|
||||
alert_type=FoodSafetyAlertType.CERTIFICATION_EXPIRY,
|
||||
severity=severity,
|
||||
risk_level="high",
|
||||
source_entity_type="compliance",
|
||||
source_entity_id=compliance.id,
|
||||
ingredient_id=compliance.ingredient_id,
|
||||
title=f"Certification expiring soon - {compliance.standard.value}",
|
||||
description=f"Certification expires in {days_to_expiry} days",
|
||||
regulatory_action_required=True,
|
||||
compliance_standard=compliance.standard,
|
||||
first_occurred_at=datetime.now(),
|
||||
last_occurred_at=datetime.now()
|
||||
)
|
||||
alerts_to_create.append(alert)
|
||||
|
||||
# Check for overdue audits
|
||||
if compliance.next_audit_date and compliance.next_audit_date < datetime.now():
|
||||
alert_code = f"AUDIT-{uuid.uuid4().hex[:8].upper()}"
|
||||
|
||||
alert = FoodSafetyAlert(
|
||||
tenant_id=compliance.tenant_id,
|
||||
alert_code=alert_code,
|
||||
alert_type=FoodSafetyAlertType.CERTIFICATION_EXPIRY,
|
||||
severity="high",
|
||||
risk_level="medium",
|
||||
source_entity_type="compliance",
|
||||
source_entity_id=compliance.id,
|
||||
ingredient_id=compliance.ingredient_id,
|
||||
title=f"Audit overdue - {compliance.standard.value}",
|
||||
description="Scheduled audit is overdue",
|
||||
regulatory_action_required=True,
|
||||
compliance_standard=compliance.standard,
|
||||
first_occurred_at=datetime.now(),
|
||||
last_occurred_at=datetime.now()
|
||||
)
|
||||
alerts_to_create.append(alert)
|
||||
|
||||
# Add alerts to database
|
||||
for alert in alerts_to_create:
|
||||
db.add(alert)
|
||||
|
||||
if alerts_to_create:
|
||||
await db.flush()
|
||||
|
||||
# Send notifications
|
||||
for alert in alerts_to_create:
|
||||
await self._send_alert_notifications(alert)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to check compliance alerts", error=str(e))
|
||||
|
||||
async def _send_alert_notifications(self, alert: FoodSafetyAlert):
|
||||
"""Send notifications for food safety alerts"""
|
||||
try:
|
||||
if not settings.ENABLE_EMAIL_ALERTS:
|
||||
return
|
||||
|
||||
# Determine notification methods based on severity
|
||||
notification_methods = ["dashboard"]
|
||||
|
||||
if alert.severity in ["high", "critical"]:
|
||||
notification_methods.extend(["email"])
|
||||
|
||||
if settings.ENABLE_SMS_ALERTS and alert.severity == "critical":
|
||||
notification_methods.append("sms")
|
||||
|
||||
if settings.ENABLE_WHATSAPP_ALERTS and alert.public_health_risk:
|
||||
notification_methods.append("whatsapp")
|
||||
|
||||
# Send notification via notification service
|
||||
if self.notification_client:
|
||||
await self.notification_client.send_alert(
|
||||
str(alert.tenant_id),
|
||||
{
|
||||
"alert_id": str(alert.id),
|
||||
"alert_type": alert.alert_type.value,
|
||||
"severity": alert.severity,
|
||||
"title": alert.title,
|
||||
"description": alert.description,
|
||||
"methods": notification_methods,
|
||||
"regulatory_action_required": alert.regulatory_action_required,
|
||||
"public_health_risk": alert.public_health_risk
|
||||
}
|
||||
)
|
||||
|
||||
# Update alert with notification status
|
||||
alert.notification_sent = True
|
||||
alert.notification_methods = notification_methods
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to send alert notifications",
|
||||
alert_id=str(alert.id),
|
||||
error=str(e))
|
||||
Reference in New Issue
Block a user