Files
bakery-ia/services/inventory/app/services/food_safety_service.py
2025-10-21 19:50:07 +02:00

637 lines
27 KiB
Python

# ================================================================
# 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.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):
pass
# ===== 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
from sqlalchemy import text
compliance_query = text("""
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 = text("""
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 = text("""
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 = text("""
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 = true AND resolved_at IS NULL 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
from sqlalchemy import text
ingredient_query = text("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))