Files
bakery-ia/services/inventory/app/services/sustainability_service.py
2025-10-23 07:44:54 +02:00

584 lines
24 KiB
Python

# ================================================================
# services/inventory/app/services/sustainability_service.py
# ================================================================
"""
Sustainability Service - Environmental Impact & SDG Compliance Tracking
Aligned with UN SDG 12.3 and EU Farm to Fork Strategy
"""
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Dict, Any, Optional, List
from uuid import UUID
import structlog
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.repositories.stock_movement_repository import StockMovementRepository
from shared.clients.production_client import create_production_client
logger = structlog.get_logger()
# Environmental Impact Constants (Research-based averages for bakery products)
class EnvironmentalConstants:
"""Environmental impact factors for bakery production"""
# CO2 equivalent per kg of food waste (kg CO2e/kg)
# Source: EU Commission, average for baked goods
CO2_PER_KG_WASTE = 1.9
# Water footprint (liters per kg of ingredient)
WATER_FOOTPRINT = {
'flour': 1827, # Wheat flour
'dairy': 1020, # Average dairy products
'eggs': 3265, # Eggs
'sugar': 1782, # Sugar
'yeast': 500, # Estimated for yeast
'fats': 1600, # Butter/oils average
'default': 1500 # Conservative default
}
# Land use per kg (m² per kg)
LAND_USE_PER_KG = 3.4
# Average trees needed to offset 1 ton CO2
TREES_PER_TON_CO2 = 50
# EU bakery waste baseline (average industry waste %)
EU_BAKERY_BASELINE_WASTE = 0.25 # 25% average
# UN SDG 12.3 target: 50% reduction by 2030
SDG_TARGET_REDUCTION = 0.50
class SustainabilityService:
"""Service for calculating environmental impact and SDG compliance"""
def __init__(self):
pass
async def get_sustainability_metrics(
self,
db: AsyncSession,
tenant_id: UUID,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Get comprehensive sustainability metrics for a tenant
Returns metrics aligned with:
- UN SDG 12.3 (Food waste reduction)
- EU Farm to Fork Strategy
- Green Deal objectives
"""
try:
# Default to last 30 days if no date range provided
if not end_date:
end_date = datetime.now()
if not start_date:
start_date = end_date - timedelta(days=30)
# Get waste data from production and inventory
waste_data = await self._get_waste_data(db, tenant_id, start_date, end_date)
# Calculate environmental impact
environmental_impact = self._calculate_environmental_impact(waste_data)
# Calculate SDG compliance
sdg_compliance = await self._calculate_sdg_compliance(
db, tenant_id, waste_data, start_date, end_date
)
# Calculate avoided waste (through AI predictions)
avoided_waste = await self._calculate_avoided_waste(
db, tenant_id, start_date, end_date
)
# Calculate financial impact
financial_impact = self._calculate_financial_impact(waste_data)
return {
'period': {
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'days': (end_date - start_date).days
},
'waste_metrics': {
'total_waste_kg': waste_data['total_waste_kg'],
'production_waste_kg': waste_data['production_waste_kg'],
'expired_waste_kg': waste_data['expired_waste_kg'],
'waste_percentage': waste_data['waste_percentage'],
'waste_by_reason': waste_data['waste_by_reason']
},
'environmental_impact': environmental_impact,
'sdg_compliance': sdg_compliance,
'avoided_waste': avoided_waste,
'financial_impact': financial_impact,
'grant_readiness': self._assess_grant_readiness(sdg_compliance)
}
except Exception as e:
logger.error("Failed to calculate sustainability metrics",
tenant_id=str(tenant_id), error=str(e))
raise
async def _get_waste_data(
self,
db: AsyncSession,
tenant_id: UUID,
start_date: datetime,
end_date: datetime
) -> Dict[str, Any]:
"""Get waste data from production service and inventory"""
try:
# Get production waste data via HTTP call to production service
production_waste_data = await self._get_production_waste_data(
tenant_id, start_date, end_date
)
prod_data = production_waste_data if production_waste_data else {
'total_production_waste': 0,
'total_defects': 0,
'total_planned': 0,
'total_actual': 0
}
# Query inventory waste using repository
stock_movement_repo = StockMovementRepository(db)
inventory_waste = await stock_movement_repo.get_inventory_waste_total(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date
)
# Calculate totals
production_waste = float(prod_data.get('total_production_waste', 0) or 0)
defect_waste = float(prod_data.get('total_defects', 0) or 0)
total_waste = production_waste + defect_waste + inventory_waste
total_production = float(prod_data.get('total_planned', 0) or 0)
waste_percentage = (total_waste / total_production * 100) if total_production > 0 else 0
# Categorize waste by reason
waste_by_reason = {
'production_defects': defect_waste,
'production_waste': production_waste - defect_waste,
'expired_inventory': inventory_waste * 0.7, # Estimate: 70% expires
'damaged_inventory': inventory_waste * 0.3, # Estimate: 30% damaged
}
return {
'total_waste_kg': total_waste,
'production_waste_kg': production_waste + defect_waste,
'expired_waste_kg': inventory_waste,
'waste_percentage': waste_percentage,
'total_production_kg': total_production,
'waste_by_reason': waste_by_reason,
'waste_incidents': int(inv_data.waste_incidents or 0)
}
except Exception as e:
logger.error("Failed to get waste data", error=str(e))
raise
async def _get_production_waste_data(
self,
tenant_id: UUID,
start_date: datetime,
end_date: datetime
) -> Optional[Dict[str, Any]]:
"""Get production waste data from production service using shared client"""
try:
# Use the shared production client with proper authentication and resilience
production_client = create_production_client(settings)
data = await production_client.get_waste_analytics(
str(tenant_id),
start_date.isoformat(),
end_date.isoformat()
)
if data:
logger.info(
"Retrieved production waste data via production client",
tenant_id=str(tenant_id),
total_waste=data.get('total_production_waste', 0)
)
return data
else:
# Client returned None, return zeros as fallback
logger.warning(
"Production waste analytics returned None, using zeros",
tenant_id=str(tenant_id)
)
return {
'total_production_waste': 0,
'total_defects': 0,
'total_planned': 0,
'total_actual': 0
}
except Exception as e:
logger.error(
"Error calling production service for waste data via client",
error=str(e),
tenant_id=str(tenant_id)
)
# Return zeros on error to not break the flow
return {
'total_production_waste': 0,
'total_defects': 0,
'total_planned': 0,
'total_actual': 0
}
def _calculate_environmental_impact(self, waste_data: Dict[str, Any]) -> Dict[str, Any]:
"""Calculate environmental impact of food waste"""
try:
total_waste_kg = waste_data['total_waste_kg']
# CO2 emissions
co2_emissions_kg = total_waste_kg * EnvironmentalConstants.CO2_PER_KG_WASTE
co2_emissions_tons = co2_emissions_kg / 1000
# Equivalent trees to offset
trees_equivalent = co2_emissions_tons * EnvironmentalConstants.TREES_PER_TON_CO2
# Water footprint (using average for bakery products)
water_liters = total_waste_kg * EnvironmentalConstants.WATER_FOOTPRINT['default']
# Land use
land_use_m2 = total_waste_kg * EnvironmentalConstants.LAND_USE_PER_KG
# Human-readable equivalents for marketing
equivalents = {
'car_km': co2_emissions_kg / 0.12, # Average car emits 120g CO2/km
'smartphone_charges': (co2_emissions_kg * 1000) / 8, # 8g CO2 per charge
'showers': water_liters / 65, # Average shower uses 65L
'trees_year_growth': trees_equivalent
}
return {
'co2_emissions': {
'kg': round(co2_emissions_kg, 2),
'tons': round(co2_emissions_tons, 4),
'trees_to_offset': round(trees_equivalent, 1)
},
'water_footprint': {
'liters': round(water_liters, 2),
'cubic_meters': round(water_liters / 1000, 2)
},
'land_use': {
'square_meters': round(land_use_m2, 2),
'hectares': round(land_use_m2 / 10000, 4)
},
'human_equivalents': {
'car_km_equivalent': round(equivalents['car_km'], 0),
'smartphone_charges': round(equivalents['smartphone_charges'], 0),
'showers_equivalent': round(equivalents['showers'], 0),
'trees_planted': round(equivalents['trees_year_growth'], 1)
}
}
except Exception as e:
logger.error("Failed to calculate environmental impact", error=str(e))
raise
async def _calculate_sdg_compliance(
self,
db: AsyncSession,
tenant_id: UUID,
waste_data: Dict[str, Any],
start_date: datetime,
end_date: datetime
) -> Dict[str, Any]:
"""
Calculate compliance with UN SDG 12.3
Target: Halve per capita global food waste by 2030
"""
try:
# Get baseline (first 90 days of operation or industry average)
baseline = await self._get_baseline_waste(db, tenant_id)
current_waste_percentage = waste_data['waste_percentage']
baseline_percentage = baseline.get('waste_percentage', EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE * 100)
# Calculate reduction from baseline
if baseline_percentage > 0:
reduction_percentage = ((baseline_percentage - current_waste_percentage) / baseline_percentage) * 100
else:
reduction_percentage = 0
# SDG 12.3 target is 50% reduction
sdg_target = baseline_percentage * (1 - EnvironmentalConstants.SDG_TARGET_REDUCTION)
progress_to_target = (reduction_percentage / (EnvironmentalConstants.SDG_TARGET_REDUCTION * 100)) * 100
# Status assessment
if reduction_percentage >= 50:
status = 'sdg_compliant'
status_label = 'SDG 12.3 Compliant'
elif reduction_percentage >= 30:
status = 'on_track'
status_label = 'On Track to Compliance'
elif reduction_percentage >= 10:
status = 'progressing'
status_label = 'Making Progress'
else:
status = 'baseline'
status_label = 'Establishing Baseline'
return {
'sdg_12_3': {
'baseline_waste_percentage': round(baseline_percentage, 2),
'current_waste_percentage': round(current_waste_percentage, 2),
'reduction_achieved': round(reduction_percentage, 2),
'target_reduction': 50.0,
'progress_to_target': round(min(progress_to_target, 100), 1),
'status': status,
'status_label': status_label,
'target_waste_percentage': round(sdg_target, 2)
},
'baseline_period': baseline.get('period', 'industry_average'),
'certification_ready': reduction_percentage >= 50,
'improvement_areas': self._identify_improvement_areas(waste_data)
}
except Exception as e:
logger.error("Failed to calculate SDG compliance", error=str(e))
raise
async def _get_baseline_waste(
self,
db: AsyncSession,
tenant_id: UUID
) -> Dict[str, Any]:
"""Get baseline waste percentage from production service using shared client"""
try:
# Use the shared production client with proper authentication and resilience
production_client = create_production_client(settings)
baseline_data = await production_client.get_baseline(str(tenant_id))
if baseline_data and baseline_data.get('data_available', False):
# Production service has real baseline data
logger.info(
"Retrieved baseline from production service via client",
tenant_id=str(tenant_id),
baseline_percentage=baseline_data.get('waste_percentage', 0)
)
return {
'waste_percentage': baseline_data['waste_percentage'],
'period': baseline_data['period'].get('type', 'first_90_days'),
'total_production_kg': baseline_data.get('total_production_kg', 0),
'total_waste_kg': baseline_data.get('total_waste_kg', 0)
}
else:
# Production service doesn't have enough data yet
logger.info(
"Production service baseline not available, using industry average",
tenant_id=str(tenant_id)
)
return {
'waste_percentage': EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE * 100,
'period': 'industry_average',
'note': 'Using EU bakery industry average of 25% as baseline'
}
except Exception as e:
logger.warning(
"Error calling production service for baseline via client, using industry average",
error=str(e),
tenant_id=str(tenant_id)
)
# Fallback to industry average
return {
'waste_percentage': EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE * 100,
'period': 'industry_average',
'note': 'Using EU bakery industry average of 25% as baseline'
}
async def _calculate_avoided_waste(
self,
db: AsyncSession,
tenant_id: UUID,
start_date: datetime,
end_date: datetime
) -> Dict[str, Any]:
"""
Calculate waste avoided through AI predictions and smart planning
This is a KEY metric for marketing and grant applications
"""
try:
# Get AI-assisted batch data from production service
production_data = await self._get_production_waste_data(tenant_id, start_date, end_date)
# Extract data with AI batch tracking
total_planned = production_data.get('total_planned', 0) if production_data else 0
total_waste = production_data.get('total_production_waste', 0) if production_data else 0
ai_assisted_batches = production_data.get('ai_assisted_batches', 0) if production_data else 0
# Estimate waste avoided by comparing to industry average
if total_planned > 0:
# Industry average waste: 25%
# Current actual waste from production
industry_expected_waste = total_planned * EnvironmentalConstants.EU_BAKERY_BASELINE_WASTE
actual_waste = total_waste
estimated_avoided = max(0, industry_expected_waste - actual_waste)
# Calculate environmental impact of avoided waste
avoided_co2 = estimated_avoided * EnvironmentalConstants.CO2_PER_KG_WASTE
avoided_water = estimated_avoided * EnvironmentalConstants.WATER_FOOTPRINT['default']
return {
'waste_avoided_kg': round(estimated_avoided, 2),
'ai_assisted_batches': ai_assisted_batches,
'environmental_impact_avoided': {
'co2_kg': round(avoided_co2, 2),
'water_liters': round(avoided_water, 2)
},
'methodology': 'compared_to_industry_baseline'
}
else:
return {
'waste_avoided_kg': 0,
'ai_assisted_batches': 0,
'note': 'Insufficient data for avoided waste calculation'
}
except Exception as e:
logger.error("Failed to calculate avoided waste", error=str(e))
return {'waste_avoided_kg': 0, 'error': str(e)}
def _calculate_financial_impact(self, waste_data: Dict[str, Any]) -> Dict[str, Any]:
"""Calculate financial impact of food waste"""
# Average cost per kg of bakery products: €3.50
avg_cost_per_kg = 3.50
total_waste_kg = waste_data['total_waste_kg']
waste_cost = total_waste_kg * avg_cost_per_kg
# If waste was reduced by 30%, potential savings
potential_savings = waste_cost * 0.30
return {
'waste_cost_eur': round(waste_cost, 2),
'cost_per_kg': avg_cost_per_kg,
'potential_monthly_savings': round(potential_savings, 2),
'annual_projection': round(waste_cost * 12, 2)
}
def _identify_improvement_areas(self, waste_data: Dict[str, Any]) -> List[str]:
"""Identify areas for improvement based on waste data"""
areas = []
waste_by_reason = waste_data.get('waste_by_reason', {})
if waste_by_reason.get('production_defects', 0) > waste_data['total_waste_kg'] * 0.3:
areas.append('quality_control_in_production')
if waste_by_reason.get('expired_inventory', 0) > waste_data['total_waste_kg'] * 0.4:
areas.append('inventory_rotation_management')
if waste_data.get('waste_percentage', 0) > 20:
areas.append('demand_forecasting_accuracy')
if not areas:
areas.append('maintain_current_practices')
return areas
def _assess_grant_readiness(self, sdg_compliance: Dict[str, Any]) -> Dict[str, Any]:
"""Assess readiness for various grant programs"""
reduction = sdg_compliance['sdg_12_3']['reduction_achieved']
grants = {
'eu_horizon_europe': {
'eligible': reduction >= 30,
'confidence': 'high' if reduction >= 50 else 'medium' if reduction >= 30 else 'low',
'requirements_met': reduction >= 30
},
'eu_farm_to_fork': {
'eligible': reduction >= 20,
'confidence': 'high' if reduction >= 40 else 'medium' if reduction >= 20 else 'low',
'requirements_met': reduction >= 20
},
'national_circular_economy': {
'eligible': reduction >= 15,
'confidence': 'high' if reduction >= 25 else 'medium' if reduction >= 15 else 'low',
'requirements_met': reduction >= 15
},
'un_sdg_certified': {
'eligible': reduction >= 50,
'confidence': 'high' if reduction >= 50 else 'low',
'requirements_met': reduction >= 50
}
}
overall_readiness = sum(1 for g in grants.values() if g['eligible']) / len(grants) * 100
return {
'overall_readiness_percentage': round(overall_readiness, 1),
'grant_programs': grants,
'recommended_applications': [
name for name, details in grants.items() if details['eligible']
]
}
async def export_grant_report(
self,
db: AsyncSession,
tenant_id: UUID,
grant_type: str = 'general',
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""
Generate export-ready report for grant applications
Formats data according to common grant application requirements
"""
try:
metrics = await self.get_sustainability_metrics(
db, tenant_id, start_date, end_date
)
# Format for grant applications
report = {
'report_metadata': {
'generated_at': datetime.now().isoformat(),
'report_type': grant_type,
'period': metrics['period'],
'tenant_id': str(tenant_id)
},
'executive_summary': {
'total_waste_reduced_kg': metrics['waste_metrics']['total_waste_kg'],
'waste_reduction_percentage': metrics['sdg_compliance']['sdg_12_3']['reduction_achieved'],
'co2_emissions_avoided_kg': metrics['environmental_impact']['co2_emissions']['kg'],
'financial_savings_eur': metrics['financial_impact']['waste_cost_eur'],
'sdg_compliance_status': metrics['sdg_compliance']['sdg_12_3']['status_label']
},
'detailed_metrics': metrics,
'certifications': {
'sdg_12_3_compliant': metrics['sdg_compliance']['certification_ready'],
'grant_programs_eligible': metrics['grant_readiness']['recommended_applications']
},
'supporting_data': {
'baseline_comparison': {
'baseline': metrics['sdg_compliance']['sdg_12_3']['baseline_waste_percentage'],
'current': metrics['sdg_compliance']['sdg_12_3']['current_waste_percentage'],
'improvement': metrics['sdg_compliance']['sdg_12_3']['reduction_achieved']
},
'environmental_benefits': metrics['environmental_impact'],
'financial_benefits': metrics['financial_impact']
}
}
return report
except Exception as e:
logger.error("Failed to generate grant report", error=str(e))
raise