demo seed change 7
This commit is contained in:
@@ -402,16 +402,92 @@ async def clone_demo_data_internal(
|
||||
db.add(stock)
|
||||
records_cloned += 1
|
||||
|
||||
# Clone stock movements (for waste tracking and sustainability metrics)
|
||||
from app.models.inventory import StockMovement, StockMovementType
|
||||
|
||||
for movement_data in seed_data.get('stock_movements', []):
|
||||
# Transform ID
|
||||
from shared.utils.demo_id_transformer import transform_id
|
||||
try:
|
||||
movement_uuid = UUID(movement_data['id'])
|
||||
tenant_uuid = UUID(virtual_tenant_id)
|
||||
transformed_id = transform_id(movement_data['id'], tenant_uuid)
|
||||
except ValueError:
|
||||
import hashlib
|
||||
movement_id_string = movement_data['id']
|
||||
tenant_uuid = UUID(virtual_tenant_id)
|
||||
combined = f"{movement_id_string}-{tenant_uuid}"
|
||||
hash_obj = hashlib.sha256(combined.encode('utf-8'))
|
||||
transformed_id = UUID(hash_obj.hexdigest()[:32])
|
||||
|
||||
# Transform dates
|
||||
movement_data['movement_date'] = parse_date_field(
|
||||
movement_data.get('movement_date'), session_time, 'movement_date'
|
||||
) or session_time
|
||||
movement_data['created_at'] = parse_date_field(
|
||||
movement_data.get('created_at'), session_time, 'created_at'
|
||||
) or session_time
|
||||
|
||||
# Transform related IDs
|
||||
if 'ingredient_id' in movement_data:
|
||||
ingredient_id_str = movement_data['ingredient_id']
|
||||
try:
|
||||
transformed_ingredient_id = transform_id(ingredient_id_str, tenant_uuid)
|
||||
movement_data['ingredient_id'] = str(transformed_ingredient_id)
|
||||
except ValueError as e:
|
||||
logger.error("Failed to transform ingredient_id in movement",
|
||||
original_id=ingredient_id_str, error=str(e))
|
||||
raise HTTPException(status_code=400, detail=f"Invalid ingredient_id: {str(e)}")
|
||||
|
||||
if 'stock_id' in movement_data and movement_data['stock_id']:
|
||||
stock_id_str = movement_data['stock_id']
|
||||
try:
|
||||
transformed_stock_id = transform_id(stock_id_str, tenant_uuid)
|
||||
movement_data['stock_id'] = str(transformed_stock_id)
|
||||
except ValueError:
|
||||
# If stock_id doesn't exist or can't be transformed, set to None
|
||||
movement_data['stock_id'] = None
|
||||
|
||||
if 'supplier_id' in movement_data and movement_data['supplier_id']:
|
||||
supplier_id_str = movement_data['supplier_id']
|
||||
try:
|
||||
transformed_supplier_id = transform_id(supplier_id_str, tenant_uuid)
|
||||
movement_data['supplier_id'] = str(transformed_supplier_id)
|
||||
except ValueError:
|
||||
movement_data['supplier_id'] = None
|
||||
|
||||
if 'created_by' in movement_data and movement_data['created_by']:
|
||||
created_by_str = movement_data['created_by']
|
||||
try:
|
||||
transformed_created_by = transform_id(created_by_str, tenant_uuid)
|
||||
movement_data['created_by'] = str(transformed_created_by)
|
||||
except ValueError:
|
||||
movement_data['created_by'] = None
|
||||
|
||||
# Remove original id and tenant_id
|
||||
movement_data.pop('id', None)
|
||||
movement_data.pop('tenant_id', None)
|
||||
|
||||
# Create stock movement
|
||||
stock_movement = StockMovement(
|
||||
id=str(transformed_id),
|
||||
tenant_id=str(virtual_tenant_id),
|
||||
**movement_data
|
||||
)
|
||||
db.add(stock_movement)
|
||||
records_cloned += 1
|
||||
|
||||
# Note: Edge cases are now handled exclusively through JSON seed data
|
||||
# The seed data files already contain comprehensive edge cases including:
|
||||
# - Low stock items below reorder points
|
||||
# - Items expiring soon
|
||||
# - Freshly received stock
|
||||
# - Waste movements for sustainability tracking
|
||||
# This ensures standardization and single source of truth for demo data
|
||||
|
||||
|
||||
logger.info(
|
||||
"Edge cases handled by JSON seed data - no manual creation needed",
|
||||
seed_data_edge_cases="low_stock, expiring_soon, fresh_stock"
|
||||
seed_data_edge_cases="low_stock, expiring_soon, fresh_stock, waste_movements"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
@@ -424,7 +500,8 @@ async def clone_demo_data_internal(
|
||||
records_cloned=records_cloned,
|
||||
duration_ms=duration_ms,
|
||||
ingredients_cloned=len(seed_data.get('ingredients', [])),
|
||||
stock_batches_cloned=len(seed_data.get('stock', []))
|
||||
stock_batches_cloned=len(seed_data.get('stock', [])),
|
||||
stock_movements_cloned=len(seed_data.get('stock_movements', []))
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,373 +2,397 @@
|
||||
# services/inventory/app/api/sustainability.py
|
||||
# ================================================================
|
||||
"""
|
||||
Sustainability API endpoints for Environmental Impact & SDG Compliance
|
||||
Following standardized URL structure: /api/v1/tenants/{tenant_id}/sustainability/{operation}
|
||||
Inventory Sustainability API - Microservices Architecture
|
||||
Provides inventory-specific sustainability metrics (waste tracking, expiry alerts)
|
||||
Following microservices principles: each service owns its domain data
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import structlog
|
||||
|
||||
from shared.auth.decorators import get_current_user_dep
|
||||
from app.core.database import get_db
|
||||
from app.services.sustainability_service import SustainabilityService
|
||||
from app.schemas.sustainability import (
|
||||
SustainabilityMetrics,
|
||||
GrantReport,
|
||||
SustainabilityWidgetData,
|
||||
SustainabilityMetricsRequest,
|
||||
GrantReportRequest
|
||||
)
|
||||
from shared.routing import RouteBuilder
|
||||
from app.repositories.stock_movement_repository import StockMovementRepository
|
||||
from app.repositories.stock_repository import StockRepository
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Create route builder for consistent URL structure
|
||||
route_builder = RouteBuilder('sustainability')
|
||||
|
||||
router = APIRouter(tags=["sustainability"])
|
||||
|
||||
|
||||
# ===== Dependency Injection =====
|
||||
|
||||
async def get_sustainability_service() -> SustainabilityService:
|
||||
"""Get sustainability service instance"""
|
||||
return SustainabilityService()
|
||||
|
||||
|
||||
# ===== SUSTAINABILITY ENDPOINTS =====
|
||||
# ===== INVENTORY SUSTAINABILITY ENDPOINTS =====
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/sustainability/metrics",
|
||||
response_model=SustainabilityMetrics,
|
||||
summary="Get Sustainability Metrics",
|
||||
description="Get comprehensive sustainability metrics including environmental impact, SDG compliance, and grant readiness"
|
||||
"/api/v1/tenants/{tenant_id}/inventory/sustainability/waste-metrics",
|
||||
summary="Get Inventory Waste Metrics",
|
||||
description="Get inventory-specific waste metrics from stock movements and expired items"
|
||||
)
|
||||
async def get_sustainability_metrics(
|
||||
async def get_inventory_waste_metrics(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
start_date: Optional[datetime] = Query(None, description="Start date for metrics (default: 30 days ago)"),
|
||||
end_date: Optional[datetime] = Query(None, description="End date for metrics (default: now)"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
sustainability_service: SustainabilityService = Depends(get_sustainability_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get comprehensive sustainability metrics for the tenant.
|
||||
Get inventory waste metrics including:
|
||||
- Waste from stock movements (expired, damaged, contaminated, spillage)
|
||||
- Total waste quantity and cost
|
||||
- Breakdown by waste reason
|
||||
- Number of waste incidents
|
||||
|
||||
**Includes:**
|
||||
- Food waste metrics (production, inventory, total)
|
||||
- Environmental impact (CO2, water, land use)
|
||||
- UN SDG 12.3 compliance tracking
|
||||
- Waste avoided through AI predictions
|
||||
- Financial impact analysis
|
||||
- Grant program eligibility assessment
|
||||
|
||||
**Use cases:**
|
||||
- Dashboard displays
|
||||
- Grant applications
|
||||
- Sustainability reporting
|
||||
- Compliance verification
|
||||
**Domain**: Inventory Service owns this data
|
||||
**Use case**: Frontend aggregates with production service waste metrics
|
||||
"""
|
||||
try:
|
||||
metrics = await sustainability_service.get_sustainability_metrics(
|
||||
db=db,
|
||||
# Default to last 30 days
|
||||
if not end_date:
|
||||
end_date = datetime.now()
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=30)
|
||||
|
||||
# Get inventory waste from stock movements
|
||||
stock_movement_repo = StockMovementRepository(db)
|
||||
|
||||
# Get waste movements using explicit date range
|
||||
waste_movements = await stock_movement_repo.get_waste_movements(
|
||||
tenant_id=tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
end_date=end_date,
|
||||
limit=1000
|
||||
)
|
||||
|
||||
# Calculate period days
|
||||
days_back = (end_date - start_date).days
|
||||
|
||||
# Calculate totals
|
||||
total_waste_kg = 0.0
|
||||
total_waste_cost_eur = 0.0
|
||||
waste_by_reason = {
|
||||
'expired': 0.0,
|
||||
'damaged': 0.0,
|
||||
'contaminated': 0.0,
|
||||
'spillage': 0.0,
|
||||
'other': 0.0
|
||||
}
|
||||
|
||||
for movement in (waste_movements or []):
|
||||
quantity = float(movement.quantity) if movement.quantity else 0.0
|
||||
total_waste_kg += quantity
|
||||
|
||||
# Add to cost if available
|
||||
if movement.total_cost:
|
||||
total_waste_cost_eur += float(movement.total_cost)
|
||||
|
||||
# Categorize by reason
|
||||
reason = movement.reason_code or 'other'
|
||||
if reason in waste_by_reason:
|
||||
waste_by_reason[reason] += quantity
|
||||
else:
|
||||
waste_by_reason['other'] += quantity
|
||||
|
||||
result = {
|
||||
'inventory_waste_kg': round(total_waste_kg, 2),
|
||||
'waste_cost_eur': round(total_waste_cost_eur, 2),
|
||||
'waste_by_reason': {
|
||||
key: round(val, 2) for key, val in waste_by_reason.items()
|
||||
},
|
||||
'waste_movements_count': len(waste_movements) if waste_movements else 0,
|
||||
'period': {
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat(),
|
||||
'days': days_back
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Sustainability metrics retrieved",
|
||||
"Inventory waste metrics retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get('user_id'),
|
||||
waste_reduction=metrics.get('sdg_compliance', {}).get('sdg_12_3', {}).get('reduction_achieved', 0)
|
||||
waste_kg=result['inventory_waste_kg'],
|
||||
movements=result['waste_movements_count']
|
||||
)
|
||||
|
||||
return metrics
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting sustainability metrics",
|
||||
"Error getting inventory waste metrics",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve sustainability metrics: {str(e)}"
|
||||
detail=f"Failed to retrieve inventory waste metrics: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/sustainability/widget",
|
||||
response_model=SustainabilityWidgetData,
|
||||
summary="Get Sustainability Widget Data",
|
||||
description="Get simplified sustainability data optimized for dashboard widgets"
|
||||
"/api/v1/tenants/{tenant_id}/inventory/sustainability/expiry-alerts",
|
||||
summary="Get Expiry Alerts",
|
||||
description="Get items at risk of expiring soon (waste prevention opportunities)"
|
||||
)
|
||||
async def get_sustainability_widget_data(
|
||||
async def get_expiry_alerts(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
days_ahead: int = Query(7, ge=1, le=30, description="Days ahead to check for expiry"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
sustainability_service: SustainabilityService = Depends(get_sustainability_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get simplified sustainability metrics for dashboard widgets.
|
||||
Get items at risk of expiring within the specified time window.
|
||||
|
||||
**Optimized for:**
|
||||
- Dashboard displays
|
||||
- Quick overview cards
|
||||
- Real-time monitoring
|
||||
**Purpose**: Waste prevention and FIFO compliance
|
||||
**Returns**:
|
||||
- Items expiring soon
|
||||
- Potential waste value
|
||||
- Recommended actions
|
||||
"""
|
||||
try:
|
||||
stock_repo = StockRepository(db)
|
||||
|
||||
**Returns:**
|
||||
- Key metrics only
|
||||
- Human-readable values
|
||||
- Status indicators
|
||||
# Get stock items expiring soon
|
||||
expiring_soon = await stock_repo.get_expiring_stock(
|
||||
tenant_id=tenant_id,
|
||||
days_ahead=days_ahead
|
||||
)
|
||||
|
||||
at_risk_items = []
|
||||
total_at_risk_kg = 0.0
|
||||
total_at_risk_value_eur = 0.0
|
||||
|
||||
for stock in (expiring_soon or []):
|
||||
quantity = float(stock.quantity) if stock.quantity else 0.0
|
||||
unit_cost = float(stock.unit_cost) if stock.unit_cost else 0.0
|
||||
total_value = quantity * unit_cost
|
||||
|
||||
total_at_risk_kg += quantity
|
||||
total_at_risk_value_eur += total_value
|
||||
|
||||
at_risk_items.append({
|
||||
'stock_id': str(stock.id),
|
||||
'ingredient_id': str(stock.ingredient_id),
|
||||
'ingredient_name': stock.ingredient.name if stock.ingredient else 'Unknown',
|
||||
'quantity': round(quantity, 2),
|
||||
'unit': stock.unit,
|
||||
'expiry_date': stock.expiry_date.isoformat() if stock.expiry_date else None,
|
||||
'days_until_expiry': (stock.expiry_date - datetime.now()).days if stock.expiry_date else None,
|
||||
'value_eur': round(total_value, 2),
|
||||
'location': stock.location or 'unspecified'
|
||||
})
|
||||
|
||||
result = {
|
||||
'at_risk_items': at_risk_items,
|
||||
'total_items': len(at_risk_items),
|
||||
'total_at_risk_kg': round(total_at_risk_kg, 2),
|
||||
'total_at_risk_value_eur': round(total_at_risk_value_eur, 2),
|
||||
'alert_window_days': days_ahead,
|
||||
'checked_at': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Expiry alerts retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
at_risk_items=result['total_items'],
|
||||
at_risk_value=result['total_at_risk_value_eur']
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting expiry alerts",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve expiry alerts: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/inventory/sustainability/waste-events",
|
||||
summary="Get Waste Event Log",
|
||||
description="Get detailed waste event history with reasons, costs, and timestamps"
|
||||
)
|
||||
async def get_waste_events(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
limit: int = Query(50, ge=1, le=500, description="Maximum number of events to return"),
|
||||
offset: int = Query(0, ge=0, description="Number of events to skip"),
|
||||
start_date: Optional[datetime] = Query(None, description="Start date filter"),
|
||||
end_date: Optional[datetime] = Query(None, description="End date filter"),
|
||||
reason_code: Optional[str] = Query(None, description="Filter by reason code (expired, damaged, etc.)"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get detailed waste event log for trend analysis and auditing.
|
||||
|
||||
**Use cases**:
|
||||
- Root cause analysis
|
||||
- Waste trend identification
|
||||
- Compliance auditing
|
||||
- Process improvement
|
||||
"""
|
||||
try:
|
||||
stock_movement_repo = StockMovementRepository(db)
|
||||
|
||||
# Default to last 90 days if no date range
|
||||
if not end_date:
|
||||
end_date = datetime.now()
|
||||
if not start_date:
|
||||
start_date = end_date - timedelta(days=90)
|
||||
|
||||
days_back = (end_date - start_date).days
|
||||
|
||||
# Get waste movements
|
||||
waste_movements = await stock_movement_repo.get_waste_movements(
|
||||
tenant_id=tenant_id,
|
||||
days_back=days_back,
|
||||
limit=limit + offset # Get extra for offset handling
|
||||
)
|
||||
|
||||
# Filter by reason if specified
|
||||
if reason_code and waste_movements:
|
||||
waste_movements = [
|
||||
m for m in waste_movements
|
||||
if m.reason_code == reason_code
|
||||
]
|
||||
|
||||
# Apply pagination
|
||||
total_count = len(waste_movements) if waste_movements else 0
|
||||
paginated_movements = (waste_movements or [])[offset:offset + limit]
|
||||
|
||||
# Format events
|
||||
events = []
|
||||
for movement in paginated_movements:
|
||||
events.append({
|
||||
'event_id': str(movement.id),
|
||||
'ingredient_id': str(movement.ingredient_id),
|
||||
'ingredient_name': movement.ingredient.name if movement.ingredient else 'Unknown',
|
||||
'quantity': float(movement.quantity) if movement.quantity else 0.0,
|
||||
'unit': movement.unit,
|
||||
'reason_code': movement.reason_code,
|
||||
'total_cost_eur': float(movement.total_cost) if movement.total_cost else 0.0,
|
||||
'movement_date': movement.movement_date.isoformat() if movement.movement_date else None,
|
||||
'notes': movement.notes or '',
|
||||
'created_by': movement.created_by
|
||||
})
|
||||
|
||||
result = {
|
||||
'events': events,
|
||||
'total_count': total_count,
|
||||
'returned_count': len(events),
|
||||
'offset': offset,
|
||||
'limit': limit,
|
||||
'period': {
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat()
|
||||
},
|
||||
'filter': {
|
||||
'reason_code': reason_code
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Waste events retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
total_events=total_count,
|
||||
returned=len(events)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting waste events",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve waste events: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/inventory/sustainability/summary",
|
||||
summary="Get Inventory Sustainability Summary",
|
||||
description="Get condensed inventory sustainability data for dashboard widgets"
|
||||
)
|
||||
async def get_inventory_sustainability_summary(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get summary of inventory sustainability metrics optimized for widgets.
|
||||
|
||||
**Returns**: Condensed version of waste metrics and expiry alerts
|
||||
**Use case**: Dashboard widgets, quick overview cards
|
||||
"""
|
||||
try:
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
metrics = await sustainability_service.get_sustainability_metrics(
|
||||
db=db,
|
||||
# Get waste metrics
|
||||
stock_movement_repo = StockMovementRepository(db)
|
||||
waste_movements = await stock_movement_repo.get_waste_movements(
|
||||
tenant_id=tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
days_back=days,
|
||||
limit=1000
|
||||
)
|
||||
|
||||
# Extract widget-friendly data
|
||||
widget_data = {
|
||||
'total_waste_kg': metrics['waste_metrics']['total_waste_kg'],
|
||||
'waste_reduction_percentage': metrics['sdg_compliance']['sdg_12_3']['reduction_achieved'],
|
||||
'co2_saved_kg': metrics['environmental_impact']['co2_emissions']['kg'],
|
||||
'water_saved_liters': metrics['environmental_impact']['water_footprint']['liters'],
|
||||
'trees_equivalent': metrics['environmental_impact']['co2_emissions']['trees_to_offset'],
|
||||
'sdg_status': metrics['sdg_compliance']['sdg_12_3']['status'],
|
||||
'sdg_progress': metrics['sdg_compliance']['sdg_12_3']['progress_to_target'],
|
||||
'grant_programs_ready': len(metrics['grant_readiness']['recommended_applications']),
|
||||
'financial_savings_eur': metrics['financial_impact']['waste_cost_eur']
|
||||
total_waste_kg = sum(
|
||||
float(m.quantity) for m in (waste_movements or [])
|
||||
if m.quantity
|
||||
)
|
||||
|
||||
total_waste_cost = sum(
|
||||
float(m.total_cost) for m in (waste_movements or [])
|
||||
if m.total_cost
|
||||
)
|
||||
|
||||
# Get expiry alerts
|
||||
stock_repo = StockRepository(db)
|
||||
expiring_soon = await stock_repo.get_expiring_stock(
|
||||
tenant_id=tenant_id,
|
||||
days_ahead=7
|
||||
)
|
||||
|
||||
at_risk_count = len(expiring_soon) if expiring_soon else 0
|
||||
|
||||
result = {
|
||||
'inventory_waste_kg': round(total_waste_kg, 2),
|
||||
'waste_cost_eur': round(total_waste_cost, 2),
|
||||
'waste_incidents': len(waste_movements) if waste_movements else 0,
|
||||
'items_at_risk_expiry': at_risk_count,
|
||||
'period_days': days,
|
||||
'period': {
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Widget data retrieved",
|
||||
"Inventory sustainability summary retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
user_id=current_user.get('user_id')
|
||||
waste_kg=result['inventory_waste_kg']
|
||||
)
|
||||
|
||||
return widget_data
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting widget data",
|
||||
"Error getting inventory sustainability summary",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve widget data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/api/v1/tenants/{tenant_id}/sustainability/export/grant-report",
|
||||
response_model=GrantReport,
|
||||
summary="Export Grant Application Report",
|
||||
description="Generate a comprehensive report formatted for grant applications"
|
||||
)
|
||||
async def export_grant_report(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
request: GrantReportRequest = None,
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
sustainability_service: SustainabilityService = Depends(get_sustainability_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generate comprehensive grant application report.
|
||||
|
||||
**Supported grant types:**
|
||||
- `general`: General sustainability report
|
||||
- `eu_horizon`: EU Horizon Europe format
|
||||
- `farm_to_fork`: EU Farm to Fork Strategy
|
||||
- `circular_economy`: Circular Economy grants
|
||||
- `un_sdg`: UN SDG certification
|
||||
|
||||
**Export formats:**
|
||||
- `json`: JSON format (default)
|
||||
- `pdf`: PDF document (future)
|
||||
- `csv`: CSV export (future)
|
||||
|
||||
**Use cases:**
|
||||
- Grant applications
|
||||
- Compliance reporting
|
||||
- Investor presentations
|
||||
- Certification requests
|
||||
"""
|
||||
try:
|
||||
if request is None:
|
||||
request = GrantReportRequest()
|
||||
|
||||
report = await sustainability_service.export_grant_report(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
grant_type=request.grant_type,
|
||||
start_date=request.start_date,
|
||||
end_date=request.end_date
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Grant report exported",
|
||||
tenant_id=str(tenant_id),
|
||||
grant_type=request.grant_type,
|
||||
user_id=current_user.get('user_id')
|
||||
)
|
||||
|
||||
# For now, return JSON. In future, support PDF/CSV generation
|
||||
if request.format == 'json':
|
||||
return report
|
||||
else:
|
||||
# Future: Generate PDF or CSV
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail=f"Export format '{request.format}' not yet implemented. Use 'json' for now."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error exporting grant report",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to export grant report: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/sustainability/sdg-compliance",
|
||||
summary="Get SDG 12.3 Compliance Status",
|
||||
description="Get detailed UN SDG 12.3 compliance status and progress"
|
||||
)
|
||||
async def get_sdg_compliance(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
sustainability_service: SustainabilityService = Depends(get_sustainability_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get detailed UN SDG 12.3 compliance information.
|
||||
|
||||
**SDG 12.3 Target:**
|
||||
By 2030, halve per capita global food waste at the retail and consumer levels
|
||||
and reduce food losses along production and supply chains, including post-harvest losses.
|
||||
|
||||
**Returns:**
|
||||
- Current compliance status
|
||||
- Progress toward 50% reduction target
|
||||
- Baseline comparison
|
||||
- Certification readiness
|
||||
- Improvement recommendations
|
||||
"""
|
||||
try:
|
||||
metrics = await sustainability_service.get_sustainability_metrics(
|
||||
db=db,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
sdg_data = {
|
||||
'sdg_12_3_compliance': metrics['sdg_compliance']['sdg_12_3'],
|
||||
'baseline_period': metrics['sdg_compliance']['baseline_period'],
|
||||
'certification_ready': metrics['sdg_compliance']['certification_ready'],
|
||||
'improvement_areas': metrics['sdg_compliance']['improvement_areas'],
|
||||
'current_waste': metrics['waste_metrics'],
|
||||
'environmental_impact': metrics['environmental_impact']
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"SDG compliance data retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
status=sdg_data['sdg_12_3_compliance']['status']
|
||||
)
|
||||
|
||||
return sdg_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting SDG compliance",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve SDG compliance data: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/tenants/{tenant_id}/sustainability/environmental-impact",
|
||||
summary="Get Environmental Impact",
|
||||
description="Get detailed environmental impact metrics"
|
||||
)
|
||||
async def get_environmental_impact(
|
||||
tenant_id: UUID = Path(..., description="Tenant ID"),
|
||||
days: int = Query(30, ge=1, le=365, description="Number of days to analyze"),
|
||||
current_user: dict = Depends(get_current_user_dep),
|
||||
sustainability_service: SustainabilityService = Depends(get_sustainability_service),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get detailed environmental impact of food waste.
|
||||
|
||||
**Metrics included:**
|
||||
- CO2 emissions (kg and tons)
|
||||
- Water footprint (liters and cubic meters)
|
||||
- Land use (m² and hectares)
|
||||
- Human-relatable equivalents (car km, showers, etc.)
|
||||
|
||||
**Use cases:**
|
||||
- Sustainability reports
|
||||
- Marketing materials
|
||||
- Customer communication
|
||||
- ESG reporting
|
||||
"""
|
||||
try:
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=days)
|
||||
|
||||
metrics = await sustainability_service.get_sustainability_metrics(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
)
|
||||
|
||||
impact_data = {
|
||||
'period': metrics['period'],
|
||||
'waste_metrics': metrics['waste_metrics'],
|
||||
'environmental_impact': metrics['environmental_impact'],
|
||||
'avoided_impact': metrics['avoided_waste']['environmental_impact_avoided'],
|
||||
'financial_impact': metrics['financial_impact']
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Environmental impact data retrieved",
|
||||
tenant_id=str(tenant_id),
|
||||
co2_kg=impact_data['environmental_impact']['co2_emissions']['kg']
|
||||
)
|
||||
|
||||
return impact_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error getting environmental impact",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(e)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to retrieve environmental impact: {str(e)}"
|
||||
detail=f"Failed to retrieve inventory sustainability summary: {str(e)}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user