584 lines
24 KiB
Python
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
|