Files
bakery-ia/services/forecasting/app/api/historical_validation.py

305 lines
9.8 KiB
Python
Raw Normal View History

2025-11-18 07:17:17 +01:00
# ================================================================
# 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)}"
)