2025-10-06 15:27:01 +02:00
|
|
|
# services/inventory/app/api/food_safety_operations.py
|
|
|
|
|
"""
|
|
|
|
|
Food Safety Operations API - Business operations for food safety management
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Optional
|
|
|
|
|
from decimal import Decimal
|
|
|
|
|
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
|
|
|
|
|
from shared.auth.access_control import require_user_role, analytics_tier_required
|
|
|
|
|
from shared.routing import RouteBuilder
|
|
|
|
|
from app.core.database import get_db
|
|
|
|
|
from app.services.food_safety_service import FoodSafetyService
|
|
|
|
|
from app.schemas.food_safety import FoodSafetyMetrics
|
|
|
|
|
|
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
|
route_builder = RouteBuilder('inventory')
|
|
|
|
|
router = APIRouter(tags=["food-safety-operations"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_food_safety_service() -> FoodSafetyService:
|
|
|
|
|
"""Get food safety service instance"""
|
|
|
|
|
return FoodSafetyService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post(
|
|
|
|
|
route_builder.build_nested_resource_route("food-safety/alerts", "alert_id", "acknowledge"),
|
|
|
|
|
response_model=dict
|
|
|
|
|
)
|
|
|
|
|
@require_user_role(['admin', 'owner', 'member'])
|
|
|
|
|
async def acknowledge_alert(
|
|
|
|
|
tenant_id: UUID = Path(...),
|
|
|
|
|
alert_id: UUID = Path(...),
|
|
|
|
|
notes: Optional[str] = Query(None, description="Acknowledgment notes"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Acknowledge a food safety alert"""
|
|
|
|
|
try:
|
|
|
|
|
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,
|
2025-10-29 06:58:05 +01:00
|
|
|
"user_id": UUID(current_user["user_id"]),
|
2025-10-06 15:27:01 +02:00
|
|
|
"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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_analytics_route("food-safety-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_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Get food safety performance metrics"""
|
|
|
|
|
try:
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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)),
|
|
|
|
|
audit_score_avg=Decimal("85.0"),
|
|
|
|
|
risk_score=Decimal("3.2")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_operations_route("food-safety/status")
|
|
|
|
|
)
|
|
|
|
|
async def get_food_safety_status(
|
|
|
|
|
tenant_id: UUID = Path(...),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep)
|
|
|
|
|
):
|
|
|
|
|
"""Get food safety service status"""
|
|
|
|
|
try:
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_operations_route("food-safety/temperature/violations")
|
|
|
|
|
)
|
|
|
|
|
async def get_temperature_violations(
|
|
|
|
|
tenant_id: UUID = Path(...),
|
|
|
|
|
days_back: int = Query(7, ge=1, le=90, description="Days to analyze"),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Get temperature violations summary"""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
COUNT(*) as total_violations,
|
|
|
|
|
COUNT(DISTINCT storage_location) as affected_locations,
|
|
|
|
|
COUNT(DISTINCT equipment_id) as affected_equipment,
|
|
|
|
|
AVG(ABS(temperature_celsius - (min_temp_celsius + max_temp_celsius)/2)) as avg_deviation
|
|
|
|
|
FROM temperature_logs
|
|
|
|
|
WHERE tenant_id = :tenant_id
|
|
|
|
|
AND is_within_range = false
|
|
|
|
|
AND recorded_at > NOW() - INTERVAL '%s days'
|
|
|
|
|
""" % days_back
|
|
|
|
|
|
|
|
|
|
result = await db.execute(query, {"tenant_id": tenant_id})
|
|
|
|
|
stats = result.fetchone()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"period_days": days_back,
|
|
|
|
|
"total_violations": stats.total_violations or 0,
|
|
|
|
|
"affected_locations": stats.affected_locations or 0,
|
|
|
|
|
"affected_equipment": stats.affected_equipment or 0,
|
|
|
|
|
"average_deviation_celsius": float(stats.avg_deviation or 0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error getting temperature violations", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get temperature violations"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get(
|
|
|
|
|
route_builder.build_operations_route("food-safety/compliance/summary")
|
|
|
|
|
)
|
|
|
|
|
async def get_compliance_summary(
|
|
|
|
|
tenant_id: UUID = Path(...),
|
|
|
|
|
current_user: dict = Depends(get_current_user_dep),
|
|
|
|
|
db: AsyncSession = Depends(get_db)
|
|
|
|
|
):
|
|
|
|
|
"""Get compliance summary by standard"""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
standard,
|
|
|
|
|
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' THEN 1 END) as pending
|
|
|
|
|
FROM food_safety_compliance
|
|
|
|
|
WHERE tenant_id = :tenant_id AND is_active = true
|
|
|
|
|
GROUP BY standard
|
|
|
|
|
ORDER BY standard
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
result = await db.execute(query, {"tenant_id": tenant_id})
|
|
|
|
|
records = result.fetchall()
|
|
|
|
|
|
|
|
|
|
summary = []
|
|
|
|
|
for record in records:
|
|
|
|
|
compliance_rate = (record.compliant / record.total * 100) if record.total > 0 else 0
|
|
|
|
|
summary.append({
|
|
|
|
|
"standard": record.standard,
|
|
|
|
|
"total_items": record.total,
|
|
|
|
|
"compliant": record.compliant,
|
|
|
|
|
"non_compliant": record.non_compliant,
|
|
|
|
|
"pending": record.pending,
|
|
|
|
|
"compliance_rate": round(compliance_rate, 2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"tenant_id": str(tenant_id),
|
|
|
|
|
"standards": summary,
|
|
|
|
|
"total_standards": len(summary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("Error getting compliance summary", error=str(e))
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
|
|
|
detail="Failed to get compliance summary"
|
|
|
|
|
)
|