demo seed change 7

This commit is contained in:
Urtzi Alfaro
2025-12-15 13:39:33 +01:00
parent 46bd4f77b6
commit 5642b5a0c0
14 changed files with 5653 additions and 780 deletions

View File

@@ -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 {

View File

@@ -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)}"
)