305 lines
9.8 KiB
Python
305 lines
9.8 KiB
Python
|
|
# ================================================================
|
||
|
|
# 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)}"
|
||
|
|
)
|