Files
bakery-ia/services/inventory/app/api/sustainability.py
2025-12-15 13:39:33 +01:00

399 lines
14 KiB
Python

# ================================================================
# services/inventory/app/api/sustainability.py
# ================================================================
"""
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 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.repositories.stock_movement_repository import StockMovementRepository
from app.repositories.stock_repository import StockRepository
logger = structlog.get_logger()
router = APIRouter(tags=["sustainability"])
# ===== INVENTORY SUSTAINABILITY ENDPOINTS =====
@router.get(
"/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_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),
db: AsyncSession = Depends(get_db)
):
"""
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
**Domain**: Inventory Service owns this data
**Use case**: Frontend aggregates with production service waste metrics
"""
try:
# 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,
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(
"Inventory waste metrics retrieved",
tenant_id=str(tenant_id),
waste_kg=result['inventory_waste_kg'],
movements=result['waste_movements_count']
)
return result
except Exception as e:
logger.error(
"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 inventory waste metrics: {str(e)}"
)
@router.get(
"/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_expiry_alerts(
tenant_id: UUID = Path(..., description="Tenant ID"),
days_ahead: int = Query(7, ge=1, le=30, description="Days ahead to check for expiry"),
current_user: dict = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get items at risk of expiring within the specified time window.
**Purpose**: Waste prevention and FIFO compliance
**Returns**:
- Items expiring soon
- Potential waste value
- Recommended actions
"""
try:
stock_repo = StockRepository(db)
# 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)
# Get waste metrics
stock_movement_repo = StockMovementRepository(db)
waste_movements = await stock_movement_repo.get_waste_movements(
tenant_id=tenant_id,
days_back=days,
limit=1000
)
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(
"Inventory sustainability summary retrieved",
tenant_id=str(tenant_id),
waste_kg=result['inventory_waste_kg']
)
return result
except Exception as e:
logger.error(
"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 inventory sustainability summary: {str(e)}"
)