# ================================================================ # 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)}" )