Improve backend
This commit is contained in:
304
services/forecasting/app/api/historical_validation.py
Normal file
304
services/forecasting/app/api/historical_validation.py
Normal file
@@ -0,0 +1,304 @@
|
||||
# ================================================================
|
||||
# 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)}"
|
||||
)
|
||||
Reference in New Issue
Block a user