883 lines
37 KiB
Python
883 lines
37 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 app.repositories.food_safety_repository import FoodSafetyRepository
|
|
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)
|
|
|
|
# Check if there's sufficient data for meaningful calculations
|
|
# Minimum: 50kg production to avoid false metrics on empty accounts
|
|
total_production = waste_data['total_production_kg']
|
|
has_sufficient_data = total_production >= 50.0
|
|
|
|
logger.info(
|
|
"Checking data sufficiency for sustainability metrics",
|
|
tenant_id=str(tenant_id),
|
|
total_production=total_production,
|
|
has_sufficient_data=has_sufficient_data
|
|
)
|
|
|
|
# If insufficient data, return a special "collecting data" state
|
|
if not has_sufficient_data:
|
|
return self._get_insufficient_data_response(start_date, end_date, waste_data)
|
|
|
|
# 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),
|
|
'data_sufficient': True
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to calculate sustainability metrics",
|
|
tenant_id=str(tenant_id), error=str(e))
|
|
raise
|
|
|
|
def _get_insufficient_data_response(
|
|
self,
|
|
start_date: datetime,
|
|
end_date: datetime,
|
|
waste_data: Dict[str, Any]
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Return response for tenants with insufficient data
|
|
|
|
This prevents showing misleading "100% compliant" status for empty accounts
|
|
"""
|
|
return {
|
|
'period': {
|
|
'start_date': start_date.isoformat(),
|
|
'end_date': end_date.isoformat(),
|
|
'days': (end_date - start_date).days
|
|
},
|
|
'waste_metrics': {
|
|
'total_waste_kg': 0.0,
|
|
'production_waste_kg': 0.0,
|
|
'expired_waste_kg': 0.0,
|
|
'waste_percentage': 0.0,
|
|
'waste_by_reason': {}
|
|
},
|
|
'environmental_impact': {
|
|
'co2_emissions': {
|
|
'kg': 0.0,
|
|
'tons': 0.0,
|
|
'trees_to_offset': 0.0
|
|
},
|
|
'water_footprint': {
|
|
'liters': 0.0,
|
|
'cubic_meters': 0.0
|
|
},
|
|
'land_use': {
|
|
'square_meters': 0.0,
|
|
'hectares': 0.0
|
|
},
|
|
'human_equivalents': {
|
|
'car_km_equivalent': 0.0,
|
|
'smartphone_charges': 0.0,
|
|
'showers_equivalent': 0.0,
|
|
'trees_planted': 0.0
|
|
}
|
|
},
|
|
'sdg_compliance': {
|
|
'sdg_12_3': {
|
|
'baseline_waste_percentage': 0.0,
|
|
'current_waste_percentage': 0.0,
|
|
'reduction_achieved': 0.0,
|
|
'target_reduction': 50.0,
|
|
'progress_to_target': 0.0,
|
|
'status': 'insufficient_data',
|
|
'status_label': 'Collecting Baseline Data',
|
|
'target_waste_percentage': 0.0
|
|
},
|
|
'baseline_period': 'not_available',
|
|
'certification_ready': False,
|
|
'improvement_areas': ['start_production_tracking']
|
|
},
|
|
'avoided_waste': {
|
|
'waste_avoided_kg': 0.0,
|
|
'ai_assisted_batches': 0,
|
|
'environmental_impact_avoided': {
|
|
'co2_kg': 0.0,
|
|
'water_liters': 0.0
|
|
},
|
|
'methodology': 'insufficient_data'
|
|
},
|
|
'financial_impact': {
|
|
'waste_cost_eur': 0.0,
|
|
'cost_per_kg': 3.50,
|
|
'potential_monthly_savings': 0.0,
|
|
'annual_projection': 0.0
|
|
},
|
|
'grant_readiness': {
|
|
'overall_readiness_percentage': 0.0,
|
|
'grant_programs': {
|
|
'life_circular_economy': {
|
|
'eligible': False,
|
|
'confidence': 'low',
|
|
'requirements_met': False,
|
|
'funding_eur': 73_000_000,
|
|
'deadline': '2025-09-23',
|
|
'program_type': 'grant'
|
|
},
|
|
'horizon_europe_cluster_6': {
|
|
'eligible': False,
|
|
'confidence': 'low',
|
|
'requirements_met': False,
|
|
'funding_eur': 880_000_000,
|
|
'deadline': 'rolling_2025',
|
|
'program_type': 'grant'
|
|
},
|
|
'fedima_sustainability_grant': {
|
|
'eligible': False,
|
|
'confidence': 'low',
|
|
'requirements_met': False,
|
|
'funding_eur': 20_000,
|
|
'deadline': '2025-06-30',
|
|
'program_type': 'grant',
|
|
'sector_specific': 'bakery'
|
|
},
|
|
'eit_food_retail': {
|
|
'eligible': False,
|
|
'confidence': 'low',
|
|
'requirements_met': False,
|
|
'funding_eur': 45_000,
|
|
'deadline': 'rolling',
|
|
'program_type': 'grant',
|
|
'sector_specific': 'retail'
|
|
},
|
|
'un_sdg_certified': {
|
|
'eligible': False,
|
|
'confidence': 'low',
|
|
'requirements_met': False,
|
|
'funding_eur': 0,
|
|
'deadline': 'ongoing',
|
|
'program_type': 'certification'
|
|
}
|
|
},
|
|
'recommended_applications': [],
|
|
'spain_compliance': {
|
|
'law_1_2025': False,
|
|
'circular_economy_strategy': False
|
|
}
|
|
},
|
|
'data_sufficient': False,
|
|
'minimum_production_required_kg': 50.0,
|
|
'current_production_kg': waste_data['total_production_kg']
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
# Count waste incidents from stock movements
|
|
total_waste_incidents = 0
|
|
try:
|
|
# Calculate days back from start_date to now
|
|
days_back = (end_date - start_date).days if start_date and end_date else 30
|
|
waste_movements = await stock_movement_repo.get_waste_movements(
|
|
tenant_id=tenant_id,
|
|
days_back=days_back,
|
|
limit=1000 # Get all waste movements
|
|
)
|
|
total_waste_incidents = len(waste_movements) if waste_movements else 0
|
|
except Exception as e:
|
|
logger.warning("Could not get waste incidents count", error=str(e))
|
|
total_waste_incidents = 0
|
|
|
|
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': total_waste_incidents
|
|
}
|
|
|
|
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
|
|
|
|
IMPORTANT: This method assumes sufficient data validation was done upstream.
|
|
It should only be called when waste_data has meaningful production volumes.
|
|
"""
|
|
try:
|
|
# Get baseline (first 90 days of operation)
|
|
baseline = await self._get_baseline_waste(db, tenant_id)
|
|
|
|
current_waste_percentage = waste_data['waste_percentage']
|
|
total_production = waste_data['total_production_kg']
|
|
|
|
# Check if we have a real baseline from production history
|
|
has_real_baseline = baseline.get('data_available', False)
|
|
baseline_percentage = baseline.get('waste_percentage', 0.0)
|
|
|
|
# If no real baseline AND insufficient current production, we can't make comparisons
|
|
if not has_real_baseline and total_production < 50:
|
|
logger.warning(
|
|
"Cannot calculate SDG compliance without baseline or sufficient production",
|
|
tenant_id=str(tenant_id),
|
|
total_production=total_production
|
|
)
|
|
return self._get_insufficient_sdg_data()
|
|
|
|
# If we have no real baseline but have current production, use it as baseline
|
|
if not has_real_baseline:
|
|
logger.info(
|
|
"Using current period as baseline (no historical data available)",
|
|
tenant_id=str(tenant_id),
|
|
current_waste_percentage=current_waste_percentage
|
|
)
|
|
baseline_percentage = current_waste_percentage
|
|
# Set reduction to 0 since we're establishing baseline
|
|
reduction_percentage = 0
|
|
progress_to_target = 0
|
|
status = 'baseline'
|
|
status_label = 'Establishing Baseline'
|
|
else:
|
|
# We have a real baseline - calculate actual reduction
|
|
# If current waste is higher than baseline, show negative reduction (worse than baseline)
|
|
# If current waste is lower than baseline, show positive reduction (better than baseline)
|
|
if baseline_percentage > 0:
|
|
reduction_percentage = ((baseline_percentage - current_waste_percentage) / baseline_percentage) * 100
|
|
else:
|
|
reduction_percentage = 0
|
|
|
|
# Calculate progress toward 50% reduction target
|
|
# The target is to achieve 50% reduction from baseline
|
|
# So if baseline is 25%, target is to reach 12.5% (25% * 0.5)
|
|
target_reduction_percentage = 50.0
|
|
target_waste_percentage = baseline_percentage * (1 - (target_reduction_percentage / 100))
|
|
|
|
# Calculate progress: how much of the 50% target has been achieved
|
|
# If we've reduced from 25% to 19.28%, we've achieved (25-19.28)/(25-12.5) = 5.72/12.5 = 45.8% of target
|
|
if baseline_percentage > target_waste_percentage:
|
|
max_possible_reduction = baseline_percentage - target_waste_percentage
|
|
actual_reduction = baseline_percentage - current_waste_percentage
|
|
progress_to_target = (actual_reduction / max_possible_reduction) * 100 if max_possible_reduction > 0 else 0
|
|
else:
|
|
# If current is already better than target
|
|
progress_to_target = 100.0 if current_waste_percentage <= target_waste_percentage else 0.0
|
|
|
|
# Ensure progress doesn't exceed 100%
|
|
progress_to_target = min(progress_to_target, 100.0)
|
|
|
|
# Status assessment based on actual reduction achieved
|
|
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'
|
|
elif reduction_percentage > 0:
|
|
status = 'improving'
|
|
status_label = 'Improving'
|
|
elif reduction_percentage < 0:
|
|
status = 'above_baseline'
|
|
status_label = 'Above Baseline'
|
|
else:
|
|
status = 'baseline'
|
|
status_label = 'At 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(max(progress_to_target, 0), 1), # Ensure non-negative
|
|
'status': status,
|
|
'status_label': status_label,
|
|
'target_waste_percentage': round(baseline_percentage * 0.5, 2) if baseline_percentage > 0 else 0.0
|
|
},
|
|
'baseline_period': baseline.get('period', 'current_period'),
|
|
'certification_ready': reduction_percentage >= 50 if has_real_baseline else False,
|
|
'improvement_areas': self._identify_improvement_areas(waste_data)
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to calculate SDG compliance", error=str(e))
|
|
raise
|
|
|
|
def _get_insufficient_sdg_data(self) -> Dict[str, Any]:
|
|
"""Return SDG compliance structure for insufficient data case"""
|
|
return {
|
|
'sdg_12_3': {
|
|
'baseline_waste_percentage': 0.0,
|
|
'current_waste_percentage': 0.0,
|
|
'reduction_achieved': 0.0,
|
|
'target_reduction': 50.0,
|
|
'progress_to_target': 0.0,
|
|
'status': 'insufficient_data',
|
|
'status_label': 'Collecting Baseline Data',
|
|
'target_waste_percentage': 0.0
|
|
},
|
|
'baseline_period': 'not_available',
|
|
'certification_ready': False,
|
|
'improvement_areas': ['start_production_tracking']
|
|
}
|
|
|
|
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,
|
|
'environmental_impact_avoided': {
|
|
'co2_kg': 0,
|
|
'water_liters': 0
|
|
},
|
|
'methodology': 'insufficient_data'
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error("Failed to calculate avoided waste", error=str(e))
|
|
return {
|
|
'waste_avoided_kg': 0,
|
|
'ai_assisted_batches': 0,
|
|
'environmental_impact_avoided': {
|
|
'co2_kg': 0,
|
|
'water_liters': 0
|
|
},
|
|
'methodology': 'error_occurred'
|
|
}
|
|
|
|
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 EU grant programs accessible to Spanish bakeries and retail.
|
|
Based on 2026 verified research. Updated Dec 2025.
|
|
"""
|
|
reduction = sdg_compliance['sdg_12_3']['reduction_achieved']
|
|
|
|
grants = {
|
|
'horizon_europe_food_systems': {
|
|
'eligible': reduction >= 20,
|
|
'confidence': 'high' if reduction >= 35 else 'medium' if reduction >= 20 else 'low',
|
|
'requirements_met': reduction >= 20,
|
|
'funding_eur': 12_000_000, # €3-12M per project
|
|
'deadline': '2026-02-18',
|
|
'program_type': 'grant',
|
|
'category': 'European Union'
|
|
},
|
|
'horizon_europe_circular_sme': {
|
|
'eligible': reduction >= 15,
|
|
'confidence': 'high' if reduction >= 25 else 'medium' if reduction >= 15 else 'low',
|
|
'requirements_met': reduction >= 15,
|
|
'funding_eur': 10_000_000, # €10M total program
|
|
'deadline': '2026-02-18',
|
|
'program_type': 'grant',
|
|
'category': 'European Union'
|
|
},
|
|
'eit_food_impact_2026': {
|
|
'eligible': reduction >= 15,
|
|
'confidence': 'high' if reduction >= 25 else 'medium' if reduction >= 15 else 'low',
|
|
'requirements_met': reduction >= 15,
|
|
'funding_eur': 1_000_000, # €50K-1M range
|
|
'deadline': 'rolling_2026',
|
|
'program_type': 'grant',
|
|
'category': 'European Union'
|
|
},
|
|
'eib_circular_economy': {
|
|
'eligible': reduction >= 10,
|
|
'confidence': 'high' if reduction >= 20 else 'medium' if reduction >= 10 else 'low',
|
|
'requirements_met': reduction >= 10,
|
|
'funding_eur': 12_500_000, # Up to €12.5M loans
|
|
'deadline': 'ongoing_2026',
|
|
'program_type': 'loan',
|
|
'category': 'European Union'
|
|
},
|
|
'circular_economy_perte': {
|
|
'eligible': reduction >= 15,
|
|
'confidence': 'high' if reduction >= 25 else 'medium' if reduction >= 15 else 'low',
|
|
'requirements_met': reduction >= 15,
|
|
'funding_eur': 10_000_000, # €150K-10M range
|
|
'deadline': 'rolling_until_2026',
|
|
'program_type': 'grant',
|
|
'category': 'Spain'
|
|
},
|
|
'planes_turismo_2026': {
|
|
'eligible': reduction >= 10,
|
|
'confidence': 'medium',
|
|
'requirements_met': reduction >= 10,
|
|
'funding_eur': 500_000, # Variable by region
|
|
'deadline': '2026-12-31',
|
|
'program_type': 'grant',
|
|
'category': 'Spain',
|
|
'sector_specific': 'tourism'
|
|
},
|
|
'un_sdg_certified': {
|
|
'eligible': reduction >= 50,
|
|
'confidence': 'high' if reduction >= 50 else 'low',
|
|
'requirements_met': reduction >= 50,
|
|
'funding_eur': 0, # Certification, not funding
|
|
'deadline': 'ongoing',
|
|
'program_type': 'certification',
|
|
'category': 'International'
|
|
}
|
|
}
|
|
|
|
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']
|
|
],
|
|
'spain_compliance': {
|
|
'law_1_2025': True, # Spanish food waste prevention law
|
|
'circular_economy_strategy': True # Spanish Circular Economy Strategy
|
|
}
|
|
}
|
|
|
|
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
|