# ================================================================ # services/forecasting/app/api/historical_validation.py # ================================================================ """ Historical Validation API - Backfill validation for late-arriving sales data """ from fastapi import APIRouter, Depends, HTTPException, Path, Query, status from typing import Dict, Any, List, Optional from uuid import UUID from datetime import date import structlog from pydantic import BaseModel, Field from app.services.historical_validation_service import HistoricalValidationService from shared.auth.decorators import get_current_user_dep from shared.auth.access_control import require_user_role from shared.routing import RouteBuilder from app.core.database import get_db from sqlalchemy.ext.asyncio import AsyncSession route_builder = RouteBuilder('forecasting') router = APIRouter(tags=["historical-validation"]) logger = structlog.get_logger() # ================================================================ # Request/Response Schemas # ================================================================ class DetectGapsRequest(BaseModel): """Request model for gap detection""" lookback_days: int = Field(default=90, ge=1, le=365, description="Days to look back") class BackfillRequest(BaseModel): """Request model for manual backfill""" start_date: date = Field(..., description="Start date for backfill") end_date: date = Field(..., description="End date for backfill") class SalesDataUpdateRequest(BaseModel): """Request model for registering sales data update""" start_date: date = Field(..., description="Start date of updated data") end_date: date = Field(..., description="End date of updated data") records_affected: int = Field(..., ge=0, description="Number of records affected") update_source: str = Field(default="import", description="Source of update") import_job_id: Optional[str] = Field(None, description="Import job ID if applicable") auto_trigger_validation: bool = Field(default=True, description="Auto-trigger validation") class AutoBackfillRequest(BaseModel): """Request model for automatic backfill""" lookback_days: int = Field(default=90, ge=1, le=365, description="Days to look back") max_gaps_to_process: int = Field(default=10, ge=1, le=50, description="Max gaps to process") # ================================================================ # Endpoints # ================================================================ @router.post( route_builder.build_base_route("validation/detect-gaps"), status_code=status.HTTP_200_OK ) @require_user_role(['admin', 'owner', 'member']) async def detect_validation_gaps( request: DetectGapsRequest, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Detect date ranges where forecasts exist but haven't been validated yet Returns list of gap periods that need validation backfill. """ try: logger.info( "Detecting validation gaps", tenant_id=tenant_id, lookback_days=request.lookback_days, user_id=current_user.get("user_id") ) service = HistoricalValidationService(db) gaps = await service.detect_validation_gaps( tenant_id=tenant_id, lookback_days=request.lookback_days ) return { "gaps_found": len(gaps), "lookback_days": request.lookback_days, "gaps": [ { "start_date": gap["start_date"].isoformat(), "end_date": gap["end_date"].isoformat(), "days_count": gap["days_count"] } for gap in gaps ] } except Exception as e: logger.error( "Failed to detect validation gaps", tenant_id=tenant_id, error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to detect validation gaps: {str(e)}" ) @router.post( route_builder.build_base_route("validation/backfill"), status_code=status.HTTP_200_OK ) @require_user_role(['admin', 'owner']) async def backfill_validation( request: BackfillRequest, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Manually trigger validation backfill for a specific date range Validates forecasts against sales data for historical periods. """ try: logger.info( "Manual validation backfill requested", tenant_id=tenant_id, start_date=request.start_date.isoformat(), end_date=request.end_date.isoformat(), user_id=current_user.get("user_id") ) service = HistoricalValidationService(db) result = await service.backfill_validation( tenant_id=tenant_id, start_date=request.start_date, end_date=request.end_date, triggered_by="manual" ) return result except Exception as e: logger.error( "Failed to backfill validation", tenant_id=tenant_id, error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to backfill validation: {str(e)}" ) @router.post( route_builder.build_base_route("validation/auto-backfill"), status_code=status.HTTP_200_OK ) @require_user_role(['admin', 'owner']) async def auto_backfill_validation_gaps( request: AutoBackfillRequest, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Automatically detect and backfill validation gaps Finds all date ranges with missing validations and processes them. """ try: logger.info( "Auto backfill requested", tenant_id=tenant_id, lookback_days=request.lookback_days, max_gaps=request.max_gaps_to_process, user_id=current_user.get("user_id") ) service = HistoricalValidationService(db) result = await service.auto_backfill_gaps( tenant_id=tenant_id, lookback_days=request.lookback_days, max_gaps_to_process=request.max_gaps_to_process ) return result except Exception as e: logger.error( "Failed to auto backfill", tenant_id=tenant_id, error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to auto backfill: {str(e)}" ) @router.post( route_builder.build_base_route("validation/register-sales-update"), status_code=status.HTTP_201_CREATED ) @require_user_role(['admin', 'owner', 'member']) async def register_sales_data_update( request: SalesDataUpdateRequest, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Register a sales data update and optionally trigger validation Call this endpoint after importing historical sales data to automatically trigger validation for the affected date range. """ try: logger.info( "Registering sales data update", tenant_id=tenant_id, date_range=f"{request.start_date} to {request.end_date}", records_affected=request.records_affected, user_id=current_user.get("user_id") ) service = HistoricalValidationService(db) result = await service.register_sales_data_update( tenant_id=tenant_id, start_date=request.start_date, end_date=request.end_date, records_affected=request.records_affected, update_source=request.update_source, import_job_id=request.import_job_id, auto_trigger_validation=request.auto_trigger_validation ) return result except Exception as e: logger.error( "Failed to register sales data update", tenant_id=tenant_id, error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to register sales data update: {str(e)}" ) @router.get( route_builder.build_base_route("validation/pending"), status_code=status.HTTP_200_OK ) @require_user_role(['admin', 'owner', 'member']) async def get_pending_validations( tenant_id: UUID = Path(..., description="Tenant ID"), limit: int = Query(50, ge=1, le=100, description="Number of records to return"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Get pending sales data updates awaiting validation Returns list of sales data updates that have been registered but not yet validated. """ try: service = HistoricalValidationService(db) pending = await service.get_pending_validations( tenant_id=tenant_id, limit=limit ) return { "pending_count": len(pending), "pending_validations": [record.to_dict() for record in pending] } except Exception as e: logger.error( "Failed to get pending validations", tenant_id=tenant_id, error=str(e) ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get pending validations: {str(e)}" )