600 lines
25 KiB
Python
600 lines
25 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.repositories.food_safety_repository import FoodSafetyRepository
|
|
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
|
|
|
|
def _get_repository(self, db) -> FoodSafetyRepository:
|
|
"""Get repository instance for the current database session"""
|
|
return FoodSafetyRepository(db)
|
|
|
|
# ===== 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
|
|
)
|
|
|
|
# Create compliance record using repository
|
|
repo = self._get_repository(db)
|
|
compliance = await repo.create_compliance(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 using repository
|
|
repo = self._get_repository(db)
|
|
compliance = await repo.get_compliance_by_id(compliance_id, tenant_id)
|
|
if not compliance:
|
|
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
|
|
|
|
# Update compliance record using repository
|
|
compliance = await repo.update_compliance(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 repository instance
|
|
repo = self._get_repository(db)
|
|
|
|
# Get compliance overview using repository
|
|
compliance_stats = await repo.get_compliance_stats(tenant_id)
|
|
total_compliance = compliance_stats["total"]
|
|
compliant_items = compliance_stats["compliant"]
|
|
compliance_percentage = (compliant_items / total_compliance * 100) if total_compliance > 0 else 0
|
|
|
|
# Get temperature monitoring status using repository
|
|
temp_stats = await repo.get_temperature_stats(tenant_id)
|
|
|
|
# Get expiration tracking using repository
|
|
expiration_stats = await repo.get_expiration_stats(tenant_id)
|
|
|
|
# Get alert counts using repository
|
|
alert_stats = await repo.get_alert_stats(tenant_id)
|
|
|
|
return FoodSafetyDashboard(
|
|
total_compliance_items=total_compliance,
|
|
compliant_items=compliant_items,
|
|
non_compliant_items=compliance_stats["non_compliant"],
|
|
pending_review_items=compliance_stats["pending_review"],
|
|
compliance_percentage=Decimal(str(compliance_percentage)),
|
|
temperature_sensors_online=temp_stats["sensors_online"],
|
|
temperature_sensors_total=temp_stats["sensors_online"], # Would need actual count
|
|
temperature_violations_24h=temp_stats["violations_24h"],
|
|
current_temperature_status="normal", # Would need to calculate
|
|
items_expiring_today=expiration_stats["expiring_today"],
|
|
items_expiring_this_week=expiration_stats["expiring_week"],
|
|
expired_items_requiring_action=expiration_stats["expired_requiring_action"],
|
|
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"],
|
|
critical_alerts=alert_stats["critical"],
|
|
regulatory_notifications_pending=alert_stats["regulatory_pending"],
|
|
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 using repository
|
|
repo = self._get_repository(db)
|
|
ingredient_exists = await repo.validate_ingredient_exists(
|
|
compliance_data.ingredient_id,
|
|
compliance_data.tenant_id
|
|
)
|
|
|
|
if not ingredient_exists:
|
|
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))
|