Add more services

This commit is contained in:
Urtzi Alfaro
2025-08-21 20:28:14 +02:00
parent d6fd53e461
commit c6dd6fd1de
85 changed files with 17842 additions and 1828 deletions

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

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

View File

@@ -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

View File

@@ -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"
]
}

View 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,
}

View 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

View 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

View 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

View 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))