Forecasting app: hybrid port to HNF stack

Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-04 18:49:34 +00:00
commit 75d2c1fa9d
103 changed files with 70316 additions and 0 deletions

1
backend/api/__init__.py Normal file
View file

@ -0,0 +1 @@
# API routers

457
backend/api/accuracy.py Normal file
View file

@ -0,0 +1,457 @@
"""
Accuracy tracking API endpoints
"""
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/summary")
async def get_accuracy_summary(
from_date: date = Query(..., description="Start date"),
to_date: date = Query(..., description="End date"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get model accuracy comparison over date range.
Returns MAE, RMSE, MAPE for each model.
"""
query = """
SELECT
metric_type,
COUNT(*) as sample_count,
-- Prophet metrics
AVG(ABS(prophet_error)) as prophet_mae,
SQRT(AVG(prophet_error * prophet_error)) as prophet_rmse,
AVG(ABS(prophet_pct_error)) as prophet_mape,
-- XGBoost metrics
AVG(ABS(xgboost_error)) as xgboost_mae,
SQRT(AVG(xgboost_error * xgboost_error)) as xgboost_rmse,
AVG(ABS(xgboost_pct_error)) as xgboost_mape,
-- CatBoost metrics
AVG(ABS(catboost_error)) as catboost_mae,
SQRT(AVG(catboost_error * catboost_error)) as catboost_rmse,
AVG(ABS(catboost_pct_error)) as catboost_mape,
-- Pickup metrics
AVG(ABS(pickup_error)) as pickup_mae,
SQRT(AVG(pickup_error * pickup_error)) as pickup_rmse,
AVG(ABS(pickup_pct_error)) as pickup_mape,
-- Best model distribution
SUM(CASE WHEN best_model = 'prophet' THEN 1 ELSE 0 END) as prophet_wins,
SUM(CASE WHEN best_model = 'xgboost' THEN 1 ELSE 0 END) as xgboost_wins,
SUM(CASE WHEN best_model = 'catboost' THEN 1 ELSE 0 END) as catboost_wins,
SUM(CASE WHEN best_model = 'pickup' THEN 1 ELSE 0 END) as pickup_wins
FROM actual_vs_forecast
WHERE date BETWEEN :from_date AND :to_date
AND actual_value IS NOT NULL
GROUP BY metric_type
ORDER BY metric_type
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"metric_type": row.metric_type,
"sample_count": row.sample_count,
"prophet": {
"mae": round(float(row.prophet_mae), 2) if row.prophet_mae else None,
"rmse": round(float(row.prophet_rmse), 2) if row.prophet_rmse else None,
"mape": round(float(row.prophet_mape), 2) if row.prophet_mape else None,
"wins": row.prophet_wins
},
"xgboost": {
"mae": round(float(row.xgboost_mae), 2) if row.xgboost_mae else None,
"rmse": round(float(row.xgboost_rmse), 2) if row.xgboost_rmse else None,
"mape": round(float(row.xgboost_mape), 2) if row.xgboost_mape else None,
"wins": row.xgboost_wins
},
"catboost": {
"mae": round(float(row.catboost_mae), 2) if row.catboost_mae else None,
"rmse": round(float(row.catboost_rmse), 2) if row.catboost_rmse else None,
"mape": round(float(row.catboost_mape), 2) if row.catboost_mape else None,
"wins": row.catboost_wins
},
"pickup": {
"mae": round(float(row.pickup_mae), 2) if row.pickup_mae else None,
"rmse": round(float(row.pickup_rmse), 2) if row.pickup_rmse else None,
"mape": round(float(row.pickup_mape), 2) if row.pickup_mape else None,
"wins": row.pickup_wins
}
}
for row in rows
]
@router.get("/model-weights")
async def get_model_weights(
metric_code: Optional[str] = Query(None, description="Metric code (if None, returns all metrics)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get MAPE-based model weights used for blending.
Shows the actual MAPE scores from backtest data and calculated weights.
This is what the blended_tuned_weighted service uses for weighting.
"""
# Map metric codes to forecast_snapshots metric codes
metric_map = {
'hotel_occupancy_pct': 'occupancy',
'hotel_room_nights': 'rooms',
'hotel_guests': 'guests',
'hotel_arr': 'arr',
'ave_guest_rate': 'ave_guest_rate',
'net_accom': 'net_accom',
'net_dry': 'net_dry',
'net_wet': 'net_wet',
'total_rev': 'total_rev',
}
# Pace metrics use pickup model
pace_metrics = ['hotel_occupancy_pct', 'hotel_room_nights']
# If specific metric requested, process just that one
metrics_to_process = [metric_code] if metric_code else list(metric_map.keys())
results = []
for metric in metrics_to_process:
snapshot_metric = metric_map.get(metric, metric)
is_pace_metric = metric in pace_metrics
# Query MAPE from forecast_snapshots
models_to_query = ['prophet', 'xgboost', 'catboost']
if is_pace_metric:
models_to_query.append('pickup')
mape_scores = {}
sample_counts = {}
for model in models_to_query:
query = text("""
SELECT
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape,
COUNT(*) as sample_count
FROM forecast_snapshots
WHERE actual_value IS NOT NULL
AND actual_value != 0
AND forecast_value IS NOT NULL
AND metric_code = :metric_code
AND model = :model
""")
result = await db.execute(query, {"metric_code": snapshot_metric, "model": model})
row = result.fetchone()
if row and row.mape is not None:
mape_scores[model] = float(row.mape)
sample_counts[model] = int(row.sample_count)
else:
mape_scores[model] = None
sample_counts[model] = 0
# Calculate inverse-MAPE weights (same logic as blended_tuned_weighted)
valid_mapes = {k: v for k, v in mape_scores.items() if v is not None}
if valid_mapes:
# Calculate weights: lower MAPE = higher weight
weights = {model: 1.0 / max(mape, 0.1) for model, mape in valid_mapes.items()}
weight_sum = sum(weights.values())
normalized_weights = {k: v / weight_sum for k, v in weights.items()}
else:
# Fall back to equal weights
normalized_weights = {model: 1.0 / len(models_to_query) for model in models_to_query}
# Build response
model_data = {}
for model in models_to_query:
model_data[model] = {
"mape": round(mape_scores.get(model), 2) if mape_scores.get(model) is not None else None,
"weight": round(normalized_weights.get(model, 0), 4),
"sample_count": sample_counts.get(model, 0)
}
results.append({
"metric_code": metric,
"snapshot_metric": snapshot_metric,
"is_pace_metric": is_pace_metric,
"models": model_data,
"total_samples": sum(sample_counts.values())
})
return results
@router.get("/by-model")
async def get_accuracy_by_model(
model: str = Query(..., description="Model: prophet, xgboost, catboost, pickup"),
from_date: date = Query(...),
to_date: date = Query(...),
metric_type: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get detailed accuracy for a specific model.
"""
column_map = {
"prophet": ("prophet_forecast", "prophet_error", "prophet_pct_error"),
"xgboost": ("xgboost_forecast", "xgboost_error", "xgboost_pct_error"),
"catboost": ("catboost_forecast", "catboost_error", "catboost_pct_error"),
"pickup": ("pickup_forecast", "pickup_error", "pickup_pct_error")
}
if model not in column_map:
raise ValueError(f"Invalid model: {model}")
forecast_col, error_col, pct_error_col = column_map[model]
query = f"""
SELECT
date,
metric_type,
actual_value,
{forecast_col} as forecast,
{error_col} as error,
{pct_error_col} as pct_error,
best_model
FROM actual_vs_forecast
WHERE date BETWEEN :from_date AND :to_date
AND actual_value IS NOT NULL
"""
params = {"from_date": from_date, "to_date": to_date}
if metric_type:
query += " AND metric_type = :metric_type"
params["metric_type"] = metric_type
query += " ORDER BY date, metric_type"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"date": row.date,
"metric_type": row.metric_type,
"actual": float(row.actual_value),
"forecast": float(row.forecast) if row.forecast else None,
"error": float(row.error) if row.error else None,
"pct_error": float(row.pct_error) if row.pct_error else None,
"was_best": row.best_model == model
}
for row in rows
]
@router.get("/best-model")
async def get_best_model_analysis(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Analyze which model performs best by metric type and time period.
"""
query = """
WITH model_performance AS (
SELECT
metric_type,
DATE_TRUNC('week', date) as week,
best_model,
COUNT(*) as count
FROM actual_vs_forecast
WHERE date BETWEEN :from_date AND :to_date
AND actual_value IS NOT NULL
GROUP BY metric_type, DATE_TRUNC('week', date), best_model
)
SELECT
metric_type,
week,
best_model,
count,
ROUND(count * 100.0 / SUM(count) OVER (PARTITION BY metric_type, week), 1) as pct
FROM model_performance
ORDER BY metric_type, week, count DESC
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"metric_type": row.metric_type,
"week": row.week,
"best_model": row.best_model,
"count": row.count,
"percentage": float(row.pct)
}
for row in rows
]
@router.get("/by-lead-time")
async def get_accuracy_by_lead_time(
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
metric_code: Optional[str] = Query(None, description="Filter by metric code"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get accuracy aggregated by lead time brackets from backtest data.
Returns MAPE for each model at different lead times (7, 14, 28, 60, 90 days).
"""
if from_date is None:
from_date = date.today() - timedelta(days=365)
if to_date is None:
to_date = date.today()
# Define lead time brackets
brackets = [
(0, 7, "1 week"),
(8, 14, "2 weeks"),
(15, 28, "1 month"),
(29, 60, "2 months"),
(61, 90, "3 months"),
(91, 180, "6 months"),
(181, 365, "1 year")
]
query = """
SELECT
CASE
WHEN days_out <= 7 THEN '1 week'
WHEN days_out <= 14 THEN '2 weeks'
WHEN days_out <= 28 THEN '1 month'
WHEN days_out <= 60 THEN '2 months'
WHEN days_out <= 90 THEN '3 months'
WHEN days_out <= 180 THEN '6 months'
ELSE '1 year'
END as lead_time_label,
CASE
WHEN days_out <= 7 THEN 1
WHEN days_out <= 14 THEN 2
WHEN days_out <= 28 THEN 3
WHEN days_out <= 60 THEN 4
WHEN days_out <= 90 THEN 5
WHEN days_out <= 180 THEN 6
ELSE 7
END as sort_order,
model,
metric_code,
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0) * 100)) as mape,
AVG(ABS(forecast_value - actual_value)) as mae,
COUNT(*) as sample_count
FROM forecast_snapshots
WHERE target_date BETWEEN :from_date AND :to_date
AND actual_value IS NOT NULL
"""
params = {"from_date": from_date, "to_date": to_date}
if metric_code:
query += " AND metric_code = :metric_code"
params["metric_code"] = metric_code
query += """
GROUP BY
CASE
WHEN days_out <= 7 THEN '1 week'
WHEN days_out <= 14 THEN '2 weeks'
WHEN days_out <= 28 THEN '1 month'
WHEN days_out <= 60 THEN '2 months'
WHEN days_out <= 90 THEN '3 months'
WHEN days_out <= 180 THEN '6 months'
ELSE '1 year'
END,
CASE
WHEN days_out <= 7 THEN 1
WHEN days_out <= 14 THEN 2
WHEN days_out <= 28 THEN 3
WHEN days_out <= 60 THEN 4
WHEN days_out <= 90 THEN 5
WHEN days_out <= 180 THEN 6
ELSE 7
END,
model,
metric_code
ORDER BY sort_order, model
"""
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"lead_time": row.lead_time_label,
"model": row.model,
"metric_code": row.metric_code,
"mape": round(float(row.mape), 2) if row.mape else None,
"mae": round(float(row.mae), 2) if row.mae else None,
"sample_count": row.sample_count
}
for row in rows
]
@router.get("/by-horizon")
async def get_accuracy_by_horizon(
horizon: int = Query(..., description="Lead time in days (7, 14, 28)"),
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get accuracy at different lead times from backtest data.
Shows how forecast accuracy degrades as horizon increases.
Uses forecast_snapshots table populated by backtests.
"""
if from_date is None:
from_date = date.today() - timedelta(days=90)
if to_date is None:
to_date = date.today()
# Query forecast_snapshots table (populated by backtests)
query = """
SELECT
metric_code as forecast_type,
days_out as horizon_days,
model as model_type,
AVG(ABS(forecast_value - actual_value)) as mae,
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0) * 100)) as mape,
COUNT(*) as sample_count
FROM forecast_snapshots
WHERE target_date BETWEEN :from_date AND :to_date
AND days_out = :horizon
AND actual_value IS NOT NULL
GROUP BY metric_code, days_out, model
ORDER BY metric_code, model
"""
result = await db.execute(text(query), {
"from_date": from_date,
"to_date": to_date,
"horizon": horizon
})
rows = result.fetchall()
return [
{
"forecast_type": row.forecast_type,
"horizon_days": row.horizon_days,
"model_type": row.model_type,
"mae": round(float(row.mae), 2) if row.mae else None,
"mape": round(float(row.mape), 2) if row.mape else None,
"sample_count": row.sample_count
}
for row in rows
]

177
backend/api/ai_insights.py Normal file
View file

@ -0,0 +1,177 @@
"""
AI Insights API Endpoints
Serves pre-computed daily insights to the dashboard and allows manual generation.
"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
# Rate limit: minimum minutes between manual generations
MANUAL_RATE_LIMIT_MINUTES = 5
@router.get("/latest")
async def get_latest_insight(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get the most recent AI insight for the dashboard card."""
result = await db.execute(
text("""
SELECT id, generated_at, insight_type, content, model,
input_tokens, output_tokens, triggered_by
FROM ai_insights
ORDER BY generated_at DESC
LIMIT 1
""")
)
row = result.fetchone()
if not row:
return None
return {
"id": row.id,
"generated_at": row.generated_at.isoformat(),
"insight_type": row.insight_type,
"content": row.content,
"model": row.model,
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"triggered_by": row.triggered_by,
}
@router.get("/history")
async def get_insight_history(
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get historical AI insights with pagination."""
result = await db.execute(
text("""
SELECT id, generated_at, insight_type, content, model,
input_tokens, output_tokens, triggered_by
FROM ai_insights
ORDER BY generated_at DESC
LIMIT :limit OFFSET :offset
"""),
{"limit": limit, "offset": offset}
)
count_result = await db.execute(text("SELECT COUNT(*) FROM ai_insights"))
total = count_result.scalar()
insights = []
for row in result.fetchall():
insights.append({
"id": row.id,
"generated_at": row.generated_at.isoformat(),
"insight_type": row.insight_type,
"content": row.content,
"model": row.model,
"input_tokens": row.input_tokens,
"output_tokens": row.output_tokens,
"triggered_by": row.triggered_by,
})
return {"insights": insights, "total": total}
@router.get("/usage")
async def get_usage_stats(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get token usage statistics for the current month."""
result = await db.execute(
text("""
SELECT
COUNT(*) as generation_count,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens
FROM ai_insights
WHERE generated_at >= DATE_TRUNC('month', CURRENT_DATE)
""")
)
row = result.fetchone()
# Also get today's usage for budget display
today_result = await db.execute(
text("""
SELECT COALESCE(SUM(input_tokens + output_tokens), 0) as today_total
FROM ai_insights
WHERE generated_at >= CURRENT_DATE
""")
)
today_row = today_result.fetchone()
return {
"month": {
"generations": row.generation_count,
"input_tokens": row.total_input_tokens,
"output_tokens": row.total_output_tokens,
"total_tokens": row.total_input_tokens + row.total_output_tokens,
},
"today_tokens": today_row.today_total if today_row else 0,
}
@router.post("/generate")
async def generate_insight_manual(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Manually trigger AI insight generation.
Rate-limited to prevent accidental spam.
"""
# Check rate limit
result = await db.execute(
text("""
SELECT generated_at FROM ai_insights
WHERE triggered_by = 'manual'
ORDER BY generated_at DESC
LIMIT 1
""")
)
last_manual = result.fetchone()
if last_manual:
elapsed = datetime.now(timezone.utc) - last_manual.generated_at.replace(tzinfo=timezone.utc)
if elapsed.total_seconds() < MANUAL_RATE_LIMIT_MINUTES * 60:
remaining = MANUAL_RATE_LIMIT_MINUTES - (elapsed.total_seconds() / 60)
raise HTTPException(
status_code=429,
detail=f"Rate limited. Please wait {remaining:.0f} more minutes."
)
# Run generation
from jobs.ai_insights import generate_insight
result = await generate_insight(db, triggered_by="manual")
if result.get("success"):
return {
"success": True,
"content": result["content"],
"input_tokens": result["input_tokens"],
"output_tokens": result["output_tokens"],
"model": result["model"],
}
else:
raise HTTPException(
status_code=400,
detail=result.get("error", "Failed to generate insight")
)

1580
backend/api/backtest.py Normal file

File diff suppressed because it is too large Load diff

303
backend/api/backup.py Normal file
View file

@ -0,0 +1,303 @@
"""
Backup and Restore API Endpoints
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
from database import get_db
from auth import get_current_user
from services.backup_service import BackupService
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================
# HELPER FUNCTIONS
# ============================================
# Note: All backup endpoints require authentication but not admin role,
# consistent with other sensitive operations in the application
# ============================================
# API ENDPOINTS
# ============================================
@router.get("/settings")
async def get_backup_settings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get backup configuration settings
Returns backup frequency, retention count, destination, and last backup status
"""
try:
service = BackupService(db)
await service.ensure_backup_table_exists()
settings = await service.get_backup_settings()
return settings
except Exception as e:
logger.error(f"Failed to get backup settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/settings")
async def update_backup_settings(
updates: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update backup configuration settings
Body: {
frequency?: 'manual' | 'daily' | 'weekly' | 'monthly',
retention_count?: number,
destination?: 'local',
time?: 'HH:MM'
}
"""
try:
service = BackupService(db)
success = await service.update_backup_settings(updates)
if success:
return {"message": "Settings updated successfully"}
else:
raise HTTPException(status_code=500, detail="Failed to update settings")
except Exception as e:
logger.error(f"Failed to update backup settings: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/create")
async def create_backup(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger manual backup creation
Creates a ZIP file containing database dump, JSON export, and all data files.
Returns backup ID and status.
"""
try:
service = BackupService(db)
await service.ensure_backup_table_exists()
username = current_user.get('username', 'unknown')
success, message, backup_id = await service.create_backup(
backup_type='manual',
created_by=username
)
if success:
return {
"success": True,
"message": message,
"backup_id": backup_id
}
else:
raise HTTPException(status_code=500, detail=message)
except Exception as e:
logger.error(f"Backup creation failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history")
async def get_backup_history(
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
List all backups, newest first
Query params:
- limit: Maximum number of backups to return (default 50)
"""
try:
service = BackupService(db)
await service.ensure_backup_table_exists()
backups = await service.list_backups(limit=limit)
return backups
except Exception as e:
logger.error(f"Failed to get backup history: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{backup_id}")
async def get_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get a specific backup by ID"""
try:
service = BackupService(db)
backup = await service.get_backup(backup_id)
if not backup:
raise HTTPException(status_code=404, detail="Backup not found")
return backup
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get backup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{backup_id}/download")
async def download_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Download a backup file
Returns the backup ZIP file for download
"""
try:
service = BackupService(db)
backup = await service.get_backup(backup_id)
if not backup:
raise HTTPException(status_code=404, detail="Backup not found")
if backup['status'] != 'success':
raise HTTPException(
status_code=400,
detail="Cannot download backup that did not complete successfully"
)
file_path = Path(backup['file_path'])
if not file_path.exists():
raise HTTPException(status_code=404, detail="Backup file not found on disk")
return FileResponse(
path=str(file_path),
filename=backup['filename'],
media_type='application/zip'
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download backup: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{backup_id}/restore")
async def restore_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Restore from an existing backup
WARNING: This will overwrite the current database with the backup data.
All current data will be replaced.
"""
try:
service = BackupService(db)
success, message = await service.restore_from_backup(backup_id)
if success:
return {"success": True, "message": message}
else:
raise HTTPException(status_code=500, detail=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Restore failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upload-restore")
async def upload_and_restore(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Upload and restore from a backup file
Upload a backup ZIP file and restore database and files from it.
WARNING: This will overwrite the current database with the backup data.
"""
try:
# Validate file type
if not file.filename or not file.filename.endswith('.zip'):
raise HTTPException(
status_code=400,
detail="Only ZIP files are accepted"
)
# Read file content
file_content = await file.read()
# Validate file size (max 5GB)
max_size = 5 * 1024 * 1024 * 1024 # 5GB
if len(file_content) > max_size:
raise HTTPException(
status_code=400,
detail=f"File too large (max {max_size / 1024 / 1024 / 1024}GB)"
)
# Restore from uploaded file
service = BackupService(db)
success, message = await service.restore_from_upload(
file_content=file_content,
filename=file.filename
)
if success:
return {"success": True, "message": message}
else:
raise HTTPException(status_code=500, detail=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Upload restore failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{backup_id}")
async def delete_backup(
backup_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Delete a backup file and record
Removes the backup file from disk and deletes the database record.
"""
try:
service = BackupService(db)
success, message = await service.delete_backup(backup_id)
if success:
return {"success": True, "message": message}
else:
raise HTTPException(status_code=500, detail=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete backup: {e}")
raise HTTPException(status_code=500, detail=str(e))

598
backend/api/bookability.py Normal file
View file

@ -0,0 +1,598 @@
"""
Bookability API endpoints
Rate availability matrix and competitor rate comparison
"""
from typing import Optional, List, Dict, Any
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
import logging
import json
from database import get_db, SyncSessionLocal
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================
# RESPONSE MODELS
# ============================================
class CategoryInfo(BaseModel):
category_id: str
category_name: str
room_count: int
class TariffInfo(BaseModel):
name: str
description: Optional[str] = None
rate: Optional[float] = None
average_nightly: Optional[float] = None
available: bool
message: str
sort_order: int = 999
min_stay: Optional[int] = None
available_for_min_stay: Optional[bool] = None # True if available when queried with min_stay nights
class OccupancyInfo(BaseModel):
occupied: int = 0
available: int = 0
maintenance: int = 0
class DateRateInfo(BaseModel):
rate_gross: Optional[float] = None
rate_net: Optional[float] = None
tariffs: List[TariffInfo]
tariff_count: int
occupancy: Optional[OccupancyInfo] = None
class RateMatrixResponse(BaseModel):
categories: List[CategoryInfo]
dates: List[str]
matrix: Dict[str, Dict[str, DateRateInfo]]
# ============================================
# RATE MATRIX ENDPOINT
# ============================================
@router.get("/rate-matrix", response_model=RateMatrixResponse)
async def get_rate_matrix(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
category_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate availability matrix for all tariffs across dates and categories.
Returns a matrix showing all available tariff options for each room category
and date combination, including availability status and rates.
Args:
from_date: Start date (YYYY-MM-DD), defaults to today
to_date: End date (YYYY-MM-DD), defaults to today + 30 days
category_id: Optional filter to specific category
Returns:
RateMatrixResponse with categories, dates, and the matrix data
"""
# Default date range
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Validate date range
if end < start:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (end - start).days > 366:
raise HTTPException(status_code=400, detail="Date range cannot exceed 366 days")
# Fetch categories
cat_query = """
SELECT site_id, site_name, room_count
FROM newbook_room_categories
WHERE is_included = true
"""
params: Dict[str, Any] = {}
if category_id:
cat_query += " AND site_id = :category_id"
params["category_id"] = category_id
cat_query += " ORDER BY display_order, site_name"
cat_result = await db.execute(text(cat_query), params)
categories = [
CategoryInfo(
category_id=row.site_id,
category_name=row.site_name,
room_count=row.room_count or 0
)
for row in cat_result.fetchall()
]
if not categories:
return RateMatrixResponse(categories=[], dates=[], matrix={})
# Build date list
dates = []
current = start
while current <= end:
dates.append(current.isoformat())
current += timedelta(days=1)
# Fetch rates with tariffs_data (get latest version per category/date)
rates_query = """
SELECT DISTINCT ON (category_id, rate_date)
category_id, rate_date, rate_gross, rate_net, tariffs_data, valid_from
FROM newbook_current_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
"""
rates_params: Dict[str, Any] = {"from_date": start, "to_date": end}
if category_id:
rates_query += " AND category_id = :category_id"
rates_params["category_id"] = category_id
rates_query += " ORDER BY category_id, rate_date, valid_from DESC"
rates_result = await db.execute(text(rates_query), rates_params)
rates_rows = rates_result.fetchall()
# Fetch occupancy data from newbook_occupancy_report_data
occupancy_query = """
SELECT category_id, date, occupied, available, maintenance
FROM newbook_occupancy_report_data
WHERE date >= :from_date AND date <= :to_date
"""
occupancy_params: Dict[str, Any] = {"from_date": start, "to_date": end}
if category_id:
occupancy_query += " AND category_id = :category_id"
occupancy_params["category_id"] = category_id
occupancy_result = await db.execute(text(occupancy_query), occupancy_params)
occupancy_rows = occupancy_result.fetchall()
# Build occupancy lookup: category_id -> date -> OccupancyInfo
occupancy_map: Dict[str, Dict[str, OccupancyInfo]] = {}
for row in occupancy_rows:
cat_id = row.category_id
occ_date = row.date.isoformat()
if cat_id not in occupancy_map:
occupancy_map[cat_id] = {}
occupancy_map[cat_id][occ_date] = OccupancyInfo(
occupied=row.occupied or 0,
available=row.available or 0,
maintenance=row.maintenance or 0
)
# Build matrix
matrix: Dict[str, Dict[str, DateRateInfo]] = {}
# Initialize matrix with empty data for all categories and dates
for cat in categories:
matrix[cat.category_id] = {}
for date_str in dates:
# Get occupancy for this category/date if available
occ = occupancy_map.get(cat.category_id, {}).get(date_str)
matrix[cat.category_id][date_str] = DateRateInfo(
rate_gross=None,
rate_net=None,
tariffs=[],
tariff_count=0,
occupancy=occ
)
# Populate matrix with actual data
for row in rates_rows:
cat_id = row.category_id
rate_date = row.rate_date.isoformat()
if cat_id not in matrix or rate_date not in matrix[cat_id]:
continue
# Parse tariffs_data
tariffs_data = row.tariffs_data or {}
if isinstance(tariffs_data, str):
try:
tariffs_data = json.loads(tariffs_data)
except json.JSONDecodeError:
tariffs_data = {}
# Build tariff list
tariffs_list = []
raw_tariffs = tariffs_data.get('tariffs', [])
for idx, tariff in enumerate(raw_tariffs):
tariffs_list.append(TariffInfo(
name=tariff.get('name', 'Unknown'),
description=tariff.get('description'),
rate=tariff.get('rate'),
average_nightly=tariff.get('average_nightly'),
available=tariff.get('success', False),
message=tariff.get('message', ''),
sort_order=tariff.get('sort_order', idx),
min_stay=tariff.get('min_stay'),
available_for_min_stay=tariff.get('available_for_min_stay')
))
# Preserve existing occupancy data
existing_occ = matrix[cat_id][rate_date].occupancy
matrix[cat_id][rate_date] = DateRateInfo(
rate_gross=float(row.rate_gross) if row.rate_gross else None,
rate_net=float(row.rate_net) if row.rate_net else None,
tariffs=tariffs_list,
tariff_count=tariffs_data.get('tariff_count', len(tariffs_list)),
occupancy=existing_occ
)
return RateMatrixResponse(
categories=categories,
dates=dates,
matrix=matrix
)
# ============================================
# RATE MATRIX SUMMARY (lightweight endpoint)
# ============================================
@router.get("/rate-matrix/summary")
async def get_rate_matrix_summary(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get a summary of rate availability issues.
Returns counts of unavailable tariffs by category and date for quick
identification of potential bookability problems.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Query rates with issues (get latest version per category/date)
result = await db.execute(
text("""
SELECT DISTINCT ON (category_id, rate_date)
category_id,
rate_date,
tariffs_data
FROM newbook_current_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
AND tariffs_data IS NOT NULL
ORDER BY category_id, rate_date, valid_from DESC
"""),
{"from_date": start, "to_date": end}
)
issues = []
for row in result.fetchall():
tariffs_data = row.tariffs_data or {}
if isinstance(tariffs_data, str):
try:
tariffs_data = json.loads(tariffs_data)
except json.JSONDecodeError:
continue
tariffs = tariffs_data.get('tariffs', [])
unavailable = [t for t in tariffs if not t.get('success', False)]
if unavailable:
issues.append({
"category_id": row.category_id,
"date": row.rate_date.isoformat(),
"unavailable_count": len(unavailable),
"unavailable_tariffs": [t.get('name') for t in unavailable],
"messages": [t.get('message') for t in unavailable if t.get('message')]
})
return {
"from_date": start.isoformat(),
"to_date": end.isoformat(),
"total_issues": len(issues),
"issues": issues
}
# ============================================
# RATE HISTORY ENDPOINT
# ============================================
@router.get("/rate-history/{category_id}/{rate_date}")
async def get_rate_history(
category_id: str,
rate_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate change history for a specific category and date.
Returns all rate snapshots showing how rates evolved over time.
Useful for understanding when rates changed and by how much.
"""
try:
target_date = date.fromisoformat(rate_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
result = await db.execute(
text("""
SELECT
id,
rate_gross,
rate_net,
tariffs_data,
valid_from,
last_verified_at
FROM newbook_current_rates
WHERE category_id = :category_id AND rate_date = :rate_date
ORDER BY valid_from DESC
"""),
{"category_id": category_id, "rate_date": target_date}
)
history = []
for row in result.fetchall():
tariffs_data = row.tariffs_data or {}
if isinstance(tariffs_data, str):
try:
tariffs_data = json.loads(tariffs_data)
except json.JSONDecodeError:
tariffs_data = {}
tariffs = tariffs_data.get('tariffs', [])
history.append({
"id": row.id,
"rate_gross": float(row.rate_gross) if row.rate_gross else None,
"rate_net": float(row.rate_net) if row.rate_net else None,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"last_verified_at": row.last_verified_at.isoformat() if row.last_verified_at else None,
"tariff_count": len(tariffs),
"tariffs_available": sum(1 for t in tariffs if t.get('success', False)),
"tariffs_unavailable": sum(1 for t in tariffs if not t.get('success', False)),
"tariffs": [
{
"name": t.get('name'),
"rate": t.get('rate'),
"available": t.get('success', False),
"message": t.get('message', ''),
"min_stay": t.get('min_stay')
}
for t in tariffs
]
})
return {
"category_id": category_id,
"rate_date": rate_date,
"version_count": len(history),
"history": history
}
# ============================================
# RATE CHANGES SUMMARY
# ============================================
@router.get("/rate-changes")
async def get_rate_changes(
days: int = 7,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get summary of rate changes in the last N days.
Shows which rates changed and when, useful for tracking pricing strategy changes.
"""
cutoff = datetime.now() - timedelta(days=days)
# Find dates with multiple versions (indicating changes)
result = await db.execute(
text("""
SELECT
category_id,
rate_date,
COUNT(*) as version_count,
MIN(valid_from) as first_version,
MAX(valid_from) as latest_version
FROM newbook_current_rates
WHERE valid_from >= :cutoff
GROUP BY category_id, rate_date
HAVING COUNT(*) > 1
ORDER BY MAX(valid_from) DESC
LIMIT 100
"""),
{"cutoff": cutoff}
)
changes = []
for row in result.fetchall():
changes.append({
"category_id": row.category_id,
"rate_date": row.rate_date.isoformat(),
"version_count": row.version_count,
"first_version": row.first_version.isoformat() if row.first_version else None,
"latest_version": row.latest_version.isoformat() if row.latest_version else None
})
return {
"days": days,
"total_changes": len(changes),
"changes": changes
}
# ============================================
# FETCH RATES TRIGGER (manual refresh)
# ============================================
@router.post("/refresh-rates")
async def refresh_rates(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
category_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a manual refresh of current rates from Newbook.
This runs the rate fetch job for the specified date range.
Note: This can be slow as it respects Newbook API rate limits.
"""
from jobs.fetch_current_rates import run_fetch_current_rates
# For now, just run the standard fetch
# TODO: Add support for custom date range and category filter
try:
await run_fetch_current_rates()
return {"status": "success", "message": "Rates refresh completed"}
except Exception as e:
logger.error(f"Rates refresh failed: {e}")
raise HTTPException(status_code=500, detail=f"Rates refresh failed: {str(e)}")
# ============================================
# SINGLE-DATE RATE REFRESH
# ============================================
def _refresh_date_sync(rate_date: date):
"""
Fetch rates for a single date from Newbook and save to DB.
Runs synchronously in a background task.
"""
import asyncio
from decimal import Decimal
from services.newbook_rates_client import NewbookRatesClient
from jobs.fetch_current_rates import save_rate_snapshot
db = SyncSessionLocal()
try:
# Get config
config_result = db.execute(
text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate')
""")
)
config = {row.config_key: row.config_value for row in config_result.fetchall()}
if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']):
logger.error("Newbook credentials not configured for single-date refresh")
return
vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20'))
# Get included categories
cat_result = db.execute(
text("SELECT site_id FROM newbook_room_categories WHERE is_included = true")
)
included_categories = set(row.site_id for row in cat_result.fetchall())
client = NewbookRatesClient(
api_key=config['newbook_api_key'],
username=config['newbook_username'],
password=config['newbook_password'],
region=config['newbook_region'],
vat_rate=vat_rate
)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
async def _fetch():
async with client:
# Single-night query for this date (all categories)
category_rates = await client._fetch_all_categories_batch(
rate_date, guests_adults=2, guests_children=0
)
# Check for min_stay tariffs needing multi-night verification
dates_by_nights: Dict[int, list] = {}
for cat_id, rates in category_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
for tariff in rate.get('tariffs_data', {}).get('tariffs', []):
min_stay = tariff.get('min_stay')
if min_stay and min_stay > 1 and not tariff.get('success', False):
if min_stay not in dates_by_nights:
dates_by_nights[min_stay] = []
if rate_date not in dates_by_nights[min_stay]:
dates_by_nights[min_stay].append(rate_date)
# Run multi-night verification if needed
if dates_by_nights:
multi_results = await client.get_multi_night_availability(dates_by_nights)
for cat_id, rates in category_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
if rate_date in multi_results:
cat_avail = multi_results[rate_date].get(cat_id, {})
for tariff in rate.get('tariffs_data', {}).get('tariffs', []):
if tariff.get('min_stay') and tariff['min_stay'] > 1:
tariff['available_for_min_stay'] = cat_avail.get(tariff.get('name', ''), False)
# Save snapshots
inserted = 0
for cat_id, rates in category_rates.items():
if cat_id not in included_categories:
continue
for rate in rates:
result = save_rate_snapshot(db, cat_id, rate['date'], rate)
if result == 'inserted':
inserted += 1
return inserted
inserted = loop.run_until_complete(_fetch())
db.commit()
logger.info(f"Single-date refresh for {rate_date}: {inserted} new snapshots")
finally:
loop.close()
except Exception as e:
logger.error(f"Single-date refresh failed for {rate_date}: {e}", exc_info=True)
db.rollback()
finally:
db.close()
@router.post("/refresh-date/{rate_date}")
async def refresh_single_date(
rate_date: str,
background_tasks: BackgroundTasks,
current_user: dict = Depends(get_current_user)
):
"""
Trigger a refresh of rates for a single date from Newbook.
Runs in background - returns immediately.
"""
try:
target_date = date.fromisoformat(rate_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
background_tasks.add_task(_refresh_date_sync, target_date)
return {"status": "queued", "date": rate_date, "message": f"Refreshing rates for {rate_date}"}

559
backend/api/budget.py Normal file
View file

@ -0,0 +1,559 @@
"""
Budget API endpoints
"""
import io
import re
import logging
from datetime import date, datetime
from typing import Optional, List
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
logger = logging.getLogger(__name__)
# Mapping from spreadsheet row labels to budget_type values
BUDGET_TYPE_MAPPING = {
'accom': 'net_accom',
'accommodation': 'net_accom',
'acc': 'net_accom',
'dry': 'net_dry',
'food': 'net_dry',
'wet': 'net_wet',
'beverage': 'net_wet',
'beverages': 'net_wet',
}
router = APIRouter()
class MonthlyBudgetCreate(BaseModel):
year: int
month: int
budget_type: str
budget_value: float
notes: Optional[str] = None
class MonthlyBudgetResponse(BaseModel):
id: int
year: int
month: int
budget_type: str
budget_value: float
notes: Optional[str]
@router.get("/monthly")
async def get_monthly_budgets(
year: int = Query(..., description="Year to get budgets for"),
budget_type: Optional[str] = Query(None, description="Filter by budget type"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get monthly budgets for a year.
"""
query = """
SELECT id, year, month, budget_type, budget_value, notes, created_at, updated_at
FROM monthly_budgets
WHERE year = :year
"""
params = {"year": year}
if budget_type:
query += " AND budget_type = :budget_type"
params["budget_type"] = budget_type
query += " ORDER BY month, budget_type"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"id": row.id,
"year": row.year,
"month": row.month,
"budget_type": row.budget_type,
"budget_value": float(row.budget_value),
"notes": row.notes,
"created_at": row.created_at,
"updated_at": row.updated_at
}
for row in rows
]
@router.post("/monthly")
async def create_or_update_monthly_budget(
budget: MonthlyBudgetCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Create or update a monthly budget.
"""
query = """
INSERT INTO monthly_budgets (year, month, budget_type, budget_value, notes, updated_at)
VALUES (:year, :month, :budget_type, :budget_value, :notes, NOW())
ON CONFLICT (year, month, budget_type)
DO UPDATE SET budget_value = :budget_value, notes = :notes, updated_at = NOW()
RETURNING id
"""
result = await db.execute(text(query), {
"year": budget.year,
"month": budget.month,
"budget_type": budget.budget_type,
"budget_value": budget.budget_value,
"notes": budget.notes
})
await db.commit()
row = result.fetchone()
return {"id": row.id, "status": "saved", **budget.model_dump()}
@router.get("/daily")
async def get_daily_budgets(
from_date: date = Query(...),
to_date: date = Query(...),
budget_type: Optional[str] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get daily distributed budgets for a date range.
"""
query = """
SELECT
date,
budget_type,
budget_value,
distribution_method,
prior_year_pct
FROM daily_budgets
WHERE date BETWEEN :from_date AND :to_date
"""
params = {"from_date": from_date, "to_date": to_date}
if budget_type:
query += " AND budget_type = :budget_type"
params["budget_type"] = budget_type
query += " ORDER BY date, budget_type"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"date": row.date,
"budget_type": row.budget_type,
"budget_value": float(row.budget_value),
"distribution_method": row.distribution_method,
"prior_year_pct": float(row.prior_year_pct) if row.prior_year_pct else None
}
for row in rows
]
@router.post("/distribute")
async def distribute_monthly_budget(
year: int = Query(...),
month: int = Query(...),
budget_type: Optional[str] = Query(None, description="Budget type to distribute, or all if not specified"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Distribute monthly budget to daily values using prior year patterns.
"""
from services.forecasting.budget_service import distribute_budget
result = await distribute_budget(db, year, month, budget_type)
return {
"status": "distributed",
"year": year,
"month": month,
"budget_type": budget_type or "all",
"days_distributed": result.get("days_distributed", 0)
}
@router.get("/variance")
async def get_budget_variance(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get forecast vs budget vs actual variance for date range.
"""
query = """
SELECT
db.date,
db.budget_type,
db.budget_value,
f.predicted_value as forecast_value,
dm.actual_value,
(f.predicted_value - db.budget_value) as forecast_vs_budget,
CASE WHEN db.budget_value != 0 THEN
ROUND(((f.predicted_value - db.budget_value) / db.budget_value * 100)::numeric, 2)
END as forecast_vs_budget_pct,
CASE WHEN dm.actual_value IS NOT NULL THEN
(dm.actual_value - db.budget_value)
END as actual_vs_budget,
CASE WHEN dm.actual_value IS NOT NULL AND db.budget_value != 0 THEN
ROUND(((dm.actual_value - db.budget_value) / db.budget_value * 100)::numeric, 2)
END as actual_vs_budget_pct
FROM daily_budgets db
LEFT JOIN forecasts f ON db.date = f.forecast_date
AND db.budget_type = f.forecast_type
AND f.model_type = 'prophet'
LEFT JOIN daily_metrics dm ON db.date = dm.date
AND db.budget_type = dm.metric_code
WHERE db.date BETWEEN :from_date AND :to_date
ORDER BY db.date, db.budget_type
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"date": row.date,
"budget_type": row.budget_type,
"budget": float(row.budget_value) if row.budget_value else None,
"forecast": float(row.forecast_value) if row.forecast_value else None,
"actual": float(row.actual_value) if row.actual_value else None,
"forecast_vs_budget": float(row.forecast_vs_budget) if row.forecast_vs_budget else None,
"forecast_vs_budget_pct": float(row.forecast_vs_budget_pct) if row.forecast_vs_budget_pct else None,
"actual_vs_budget": float(row.actual_vs_budget) if row.actual_vs_budget else None,
"actual_vs_budget_pct": float(row.actual_vs_budget_pct) if row.actual_vs_budget_pct else None
}
for row in rows
]
def parse_month_header(header) -> Optional[tuple]:
"""
Parse month header in various formats to (year, month).
Supported formats:
- datetime/Timestamp objects (from Excel date cells)
- mm/yy (01/25, 12/26)
- mm-yy (01-25, 12-26)
- mmm/yy (Jan/25, Dec-26)
- mmm yy (Jan 25, Dec 26)
- yyyy-mm (2025-01)
- dd/mm/yyyy or mm/dd/yyyy (will use first of month)
- Full month names (January 2025)
Returns None if cannot parse.
"""
if header is None:
return None
# Handle pandas NaT or NaN
if pd.isna(header):
return None
# Handle datetime objects (from Excel date columns)
if isinstance(header, (datetime, date)):
return (header.year, header.month)
# Handle pandas Timestamp
if hasattr(header, 'year') and hasattr(header, 'month'):
try:
return (int(header.year), int(header.month))
except (ValueError, TypeError):
pass
# Convert to string for text parsing
if not isinstance(header, str):
header = str(header)
header = header.strip()
if not header:
return None
# Try mm/yy or mm-yy format
match = re.match(r'^(\d{1,2})[/\-](\d{2,4})$', header)
if match:
month = int(match.group(1))
year = int(match.group(2))
if year < 100:
year = 2000 + year if year < 50 else 1900 + year
if 1 <= month <= 12:
return (year, month)
# Try yyyy-mm format
match = re.match(r'^(\d{4})[/\-](\d{1,2})$', header)
if match:
year = int(match.group(1))
month = int(match.group(2))
if 1 <= month <= 12:
return (year, month)
# Try dd/mm/yyyy or yyyy-mm-dd format (use year/month, ignore day)
match = re.match(r'^(\d{1,2})[/\-](\d{1,2})[/\-](\d{4})$', header)
if match:
# Assume dd/mm/yyyy
day = int(match.group(1))
month = int(match.group(2))
year = int(match.group(3))
if 1 <= month <= 12:
return (year, month)
match = re.match(r'^(\d{4})[/\-](\d{1,2})[/\-](\d{1,2})$', header)
if match:
year = int(match.group(1))
month = int(match.group(2))
if 1 <= month <= 12:
return (year, month)
# Try month name formats (Jan/25, Jan-25, Jan 25, Jan25)
month_names = {
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'june': 6,
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12
}
match = re.match(r'^([a-zA-Z]+)[/\-\s]?(\d{2,4})$', header, re.IGNORECASE)
if match:
month_str = match.group(1).lower()
year = int(match.group(2))
if year < 100:
year = 2000 + year if year < 50 else 1900 + year
if month_str in month_names:
return (year, month_names[month_str])
# Try "2025 January" or "2025-January" format
match = re.match(r'^(\d{4})[/\-\s]?([a-zA-Z]+)$', header, re.IGNORECASE)
if match:
year = int(match.group(1))
month_str = match.group(2).lower()
if month_str in month_names:
return (year, month_names[month_str])
return None
def clean_numeric_value(value) -> Optional[float]:
"""
Clean a value that might contain currency symbols, commas, etc.
Returns None if the value cannot be converted to a number.
"""
if value is None or (isinstance(value, float) and pd.isna(value)):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
# Remove currency symbols, commas, spaces
cleaned = re.sub(r'[£$€,\s]', '', value.strip())
if cleaned == '' or cleaned == '-':
return None
try:
return float(cleaned)
except ValueError:
return None
return None
@router.post("/upload")
async def upload_budget_spreadsheet(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Upload budget spreadsheet (CSV/Excel) in format:
month | 01/25 | 02/25 | 03/25 | ...
accom | 150000 | 145000 | 160000 | ...
dry | 45000 | 42000 | 48000 | ...
wet | 35000 | 32000 | 38000 | ...
Returns summary of records created/updated.
"""
# Validate file type
filename = file.filename.lower()
if not (filename.endswith('.csv') or filename.endswith('.xlsx') or filename.endswith('.xls')):
raise HTTPException(
status_code=400,
detail="Invalid file type. Please upload a CSV or Excel file (.csv, .xlsx, .xls)"
)
# Read file content
content = await file.read()
try:
# Parse file based on type
if filename.endswith('.csv'):
df = pd.read_csv(io.BytesIO(content), header=None)
else:
df = pd.read_excel(io.BytesIO(content), header=None)
except Exception as e:
logger.error(f"Failed to parse budget file: {e}")
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
if df.empty:
raise HTTPException(status_code=400, detail="File is empty")
# Parse the spreadsheet structure
# First row should contain month headers (skip first column which is the label column)
# Subsequent rows contain budget type label and values
records_created = 0
records_updated = 0
errors = []
# Get month headers from first row (skip first column)
month_headers = df.iloc[0, 1:].tolist()
parsed_months = []
logger.info(f"Found {len(month_headers)} column headers")
for idx, header in enumerate(month_headers):
logger.debug(f"Header {idx}: {header} (type: {type(header).__name__})")
parsed = parse_month_header(header) # Pass raw value, parser handles types
if parsed:
parsed_months.append((idx + 1, parsed)) # Store column index and (year, month)
logger.debug(f" -> Parsed as {parsed[0]}-{parsed[1]:02d}")
else:
if header is not None and not pd.isna(header) and str(header).strip():
errors.append(f"Could not parse month header: '{header}' (type: {type(header).__name__})")
if not parsed_months:
# Log what we received for debugging
sample_headers = month_headers[:5] if len(month_headers) > 5 else month_headers
logger.error(f"No valid month headers found. Sample headers: {sample_headers}")
raise HTTPException(
status_code=400,
detail=f"No valid month headers found. Got: {sample_headers}. Expected formats: mm/yy, Jan-25, 2025-01, or Excel dates"
)
# Process budget rows (skip first header row)
for row_idx in range(1, len(df)):
row = df.iloc[row_idx]
row_label = str(row.iloc[0]).lower().strip() if row.iloc[0] else ''
# Map row label to budget_type
budget_type = BUDGET_TYPE_MAPPING.get(row_label)
if not budget_type:
if row_label and row_label not in ['month', 'total', '']:
errors.append(f"Unknown budget type: '{row_label}'")
continue
# Process each month column
for col_idx, (year, month) in parsed_months:
value = clean_numeric_value(row.iloc[col_idx])
if value is None:
continue
# Upsert the budget value
try:
result = await db.execute(
text("""
INSERT INTO monthly_budgets (year, month, budget_type, budget_value, updated_at)
VALUES (:year, :month, :budget_type, :budget_value, NOW())
ON CONFLICT (year, month, budget_type)
DO UPDATE SET budget_value = :budget_value, updated_at = NOW()
RETURNING (xmax = 0) as inserted
"""),
{
"year": year,
"month": month,
"budget_type": budget_type,
"budget_value": value
}
)
row_result = result.fetchone()
if row_result and row_result.inserted:
records_created += 1
else:
records_updated += 1
except Exception as e:
errors.append(f"Failed to save {budget_type} for {month:02d}/{year}: {str(e)}")
await db.commit()
logger.info(f"Budget upload complete: {records_created} created, {records_updated} updated")
return {
"status": "success",
"filename": file.filename,
"records_created": records_created,
"records_updated": records_updated,
"total_records": records_created + records_updated,
"errors": errors if errors else None
}
@router.get("/template")
async def download_budget_template(
current_user: dict = Depends(get_current_user)
):
"""
Download empty budget template Excel file.
Pre-fills month headers for current and next year.
"""
# Generate month headers for current year and next year
current_year = datetime.now().year
months = []
for year in [current_year, current_year + 1]:
for month in range(1, 13):
months.append(f"{month:02d}/{year % 100:02d}")
# Create DataFrame with template structure
data = {
'Type': ['accom', 'dry', 'wet']
}
# Add empty columns for each month
for month_header in months:
data[month_header] = ['', '', '']
df = pd.DataFrame(data)
# Write to Excel
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Budget', index=False)
# Auto-adjust column widths
worksheet = writer.sheets['Budget']
for column in worksheet.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
worksheet.column_dimensions[column_letter].width = max(max_length + 2, 10)
output.seek(0)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f"attachment; filename=budget_template_{current_year}.xlsx"
}
)

View file

@ -0,0 +1,940 @@
"""
Competitor Rates API endpoints
Booking.com rate scraping, hotel management, and competitor comparison
"""
from typing import Optional, List, Dict, Any
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
import logging
from database import get_db, SyncSessionLocal
from auth import get_current_user
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================
# REQUEST/RESPONSE MODELS
# ============================================
class ScrapeRequest(BaseModel):
from_date: str
to_date: Optional[str] = None
class LocationConfigRequest(BaseModel):
location_name: str
pages_to_scrape: int = 2
adults: int = 2
class HotelTierUpdate(BaseModel):
tier: str # 'own', 'competitor', 'market'
display_order: Optional[int] = None
class HotelResponse(BaseModel):
id: int
booking_com_id: str
name: str
booking_com_url: Optional[str]
star_rating: Optional[float]
review_score: Optional[float]
review_count: Optional[int]
tier: str
display_order: int
notes: Optional[str]
first_seen_at: Optional[datetime]
last_seen_at: Optional[datetime]
class RateResponse(BaseModel):
rate_date: str
hotel_id: int
hotel_name: str
tier: str
star_rating: Optional[float]
review_score: Optional[float]
availability_status: str
rate_gross: Optional[float]
room_type: Optional[str]
breakfast_included: Optional[bool]
free_cancellation: Optional[bool]
no_prepayment: Optional[bool]
rooms_left: Optional[int]
scraped_at: Optional[datetime]
class ScraperStatusResponse(BaseModel):
enabled: bool
paused: bool
pause_until: Optional[str]
backend: str
location_configured: bool
location_name: Optional[str]
last_scrape: Optional[dict]
# ============================================
# SCRAPER STATUS & CONFIGURATION
# ============================================
@router.get("/status", response_model=ScraperStatusResponse)
async def get_scraper_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get current scraper status and configuration."""
# Get config values
config_result = await db.execute(
text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN (
'booking_scraper_enabled',
'booking_scraper_paused',
'booking_scraper_pause_until',
'booking_scraper_backend'
)
""")
)
config = {row.config_key: row.config_value for row in config_result.fetchall()}
# Get location config
location_result = await db.execute(
text("SELECT location_name FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
)
location_row = location_result.fetchone()
# Get last scrape info
last_scrape_result = await db.execute(
text("""
SELECT batch_id, scrape_type, started_at, completed_at, status,
hotels_found, rates_scraped, error_message
FROM booking_scrape_log
ORDER BY started_at DESC
LIMIT 1
""")
)
last_scrape_row = last_scrape_result.fetchone()
last_scrape = None
if last_scrape_row:
last_scrape = {
'batch_id': str(last_scrape_row.batch_id),
'scrape_type': last_scrape_row.scrape_type,
'started_at': last_scrape_row.started_at.isoformat() if last_scrape_row.started_at else None,
'completed_at': last_scrape_row.completed_at.isoformat() if last_scrape_row.completed_at else None,
'status': last_scrape_row.status,
'hotels_found': last_scrape_row.hotels_found,
'rates_scraped': last_scrape_row.rates_scraped,
'error_message': last_scrape_row.error_message,
}
return ScraperStatusResponse(
enabled=config.get('booking_scraper_enabled', 'false') == 'true',
paused=config.get('booking_scraper_paused', 'false') == 'true',
pause_until=config.get('booking_scraper_pause_until'),
backend=config.get('booking_scraper_backend', 'playwright_local'),
location_configured=location_row is not None,
location_name=location_row.location_name if location_row else None,
last_scrape=last_scrape
)
@router.post("/config/location")
async def set_location_config(
config: LocationConfigRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Set the location to scrape for competitor rates."""
# Deactivate existing configs
await db.execute(
text("UPDATE booking_scrape_config SET is_active = FALSE")
)
# Insert new config
await db.execute(
text("""
INSERT INTO booking_scrape_config (location_name, pages_to_scrape, adults, is_active)
VALUES (:location, :pages, :adults, TRUE)
"""),
{'location': config.location_name, 'pages': config.pages_to_scrape, 'adults': config.adults}
)
await db.commit()
return {"status": "success", "location": config.location_name}
@router.post("/config/enable")
async def enable_scraper(
enabled: bool = True,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Enable or disable the booking.com scraper."""
await db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_enabled'"),
{'val': 'true' if enabled else 'false'}
)
await db.commit()
return {"status": "success", "enabled": enabled}
@router.post("/config/unpause")
async def unpause_scraper(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Manually unpause the scraper (clears blocking pause)."""
await db.execute(
text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'")
)
await db.commit()
return {"status": "success", "message": "Scraper unpaused"}
# ============================================
# MANUAL SCRAPE TRIGGER
# ============================================
def run_scrape_sync(from_date: date, to_date: date):
"""Run scrape in sync context for background task."""
import asyncio
from services.booking_scraper import run_manual_scrape, cleanup_stale_batches
db = SyncSessionLocal()
try:
# Clean up any stale batches before starting
cleanup_stale_batches(db, max_age_minutes=60)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(run_manual_scrape(db, from_date, to_date))
logger.info(f"Background scrape completed: {result}")
finally:
loop.close()
except Exception as e:
logger.error(f"Background scrape failed: {e}", exc_info=True)
finally:
db.close()
@router.post("/scrape")
async def trigger_manual_scrape(
request: ScrapeRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a manual scrape for the specified date range.
Runs in background - check /status for progress.
"""
try:
from_date = date.fromisoformat(request.from_date)
to_date = date.fromisoformat(request.to_date) if request.to_date else from_date
except ValueError as e:
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
if to_date < from_date:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (to_date - from_date).days > 30:
raise HTTPException(status_code=400, detail="Date range cannot exceed 30 days for manual scrape")
# Check if location is configured
location_result = await db.execute(
text("SELECT id FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
)
if not location_result.fetchone():
raise HTTPException(status_code=400, detail="No scrape location configured. Set location first.")
# Check if paused
paused_result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'")
)
paused_row = paused_result.fetchone()
if paused_row and paused_row.config_value == 'true':
raise HTTPException(status_code=400, detail="Scraper is currently paused. Use /unpause first or wait for cooldown.")
# Start background task
background_tasks.add_task(run_scrape_sync, from_date, to_date)
return {
"status": "started",
"from_date": from_date.isoformat(),
"to_date": to_date.isoformat(),
"message": "Scrape started in background. Check /status for progress."
}
# ============================================
# HOTELS MANAGEMENT
# ============================================
@router.get("/hotels", response_model=List[HotelResponse])
async def list_hotels(
tier: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
List all discovered hotels.
Filter by tier: 'own', 'competitor', 'market', or None for all.
"""
query = """
SELECT id, booking_com_id, name, booking_com_url,
star_rating, review_score, review_count,
tier, display_order, notes, first_seen_at, last_seen_at
FROM booking_com_hotels
WHERE is_active = TRUE
"""
params = {}
if tier:
if tier not in ('own', 'competitor', 'market'):
raise HTTPException(status_code=400, detail="Invalid tier. Must be 'own', 'competitor', or 'market'")
query += " AND tier = :tier"
params['tier'] = tier
query += " ORDER BY display_order, name"
result = await db.execute(text(query), params)
return [
HotelResponse(
id=row.id,
booking_com_id=row.booking_com_id or '',
name=row.name,
booking_com_url=row.booking_com_url,
star_rating=float(row.star_rating) if row.star_rating else None,
review_score=float(row.review_score) if row.review_score else None,
review_count=row.review_count,
tier=row.tier,
display_order=row.display_order,
notes=row.notes,
first_seen_at=row.first_seen_at,
last_seen_at=row.last_seen_at
)
for row in result.fetchall()
]
@router.put("/hotels/{hotel_id}/tier")
async def update_hotel_tier(
hotel_id: int,
update: HotelTierUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update a hotel's tier and display order.
Tiers:
- 'own': Your hotel (for parity checking)
- 'competitor': Main competitors (full tracking)
- 'market': Other hotels (context only)
"""
if update.tier not in ('own', 'competitor', 'market'):
raise HTTPException(status_code=400, detail="Invalid tier")
# If setting as 'own', clear any existing 'own' hotel
if update.tier == 'own':
await db.execute(
text("UPDATE booking_com_hotels SET tier = 'market' WHERE tier = 'own'")
)
# Update the hotel
set_clause = "tier = :tier"
params = {'hotel_id': hotel_id, 'tier': update.tier}
if update.display_order is not None:
set_clause += ", display_order = :order"
params['order'] = update.display_order
result = await db.execute(
text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id RETURNING id"),
params
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Hotel not found")
await db.commit()
# If this is now the own hotel, update system config
if update.tier == 'own':
await db.execute(
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_own_hotel_id'"),
{'val': str(hotel_id)}
)
await db.commit()
return {"status": "success", "hotel_id": hotel_id, "tier": update.tier}
@router.put("/hotels/{hotel_id}/notes")
async def update_hotel_notes(
hotel_id: int,
notes: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update notes for a hotel."""
result = await db.execute(
text("UPDATE booking_com_hotels SET notes = :notes WHERE id = :hotel_id RETURNING id"),
{'hotel_id': hotel_id, 'notes': notes}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Hotel not found")
await db.commit()
return {"status": "success"}
# ============================================
# COMPETITOR RATES MATRIX
# ============================================
@router.get("/matrix")
async def get_competitor_matrix(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
include_market: bool = False,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate comparison matrix for competitors.
Returns rates for own hotel and competitors, organized by date.
Set include_market=true to also include market tier hotels.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
if end < start:
raise HTTPException(status_code=400, detail="to_date must be after from_date")
if (end - start).days > 90:
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
tier_filter = "h.tier IN ('own', 'competitor')"
if include_market:
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
# Get hotels
hotels_result = await db.execute(
text(f"""
SELECT id, name, tier, display_order, star_rating, review_score, booking_com_url
FROM booking_com_hotels
WHERE is_active = TRUE AND {tier_filter.replace('h.', '')}
ORDER BY display_order, name
""")
)
hotels = [dict(row._mapping) for row in hotels_result.fetchall()]
# Get latest rates using the view
rates_result = await db.execute(
text(f"""
SELECT DISTINCT ON (r.hotel_id, r.rate_date)
r.hotel_id,
r.rate_date,
r.availability_status,
r.rate_gross,
r.room_type,
r.breakfast_included,
r.free_cancellation,
r.no_prepayment,
r.rooms_left,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE {tier_filter}
AND h.is_active = TRUE
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC
"""),
{'from_date': start, 'to_date': end}
)
# Build matrix: hotel_id -> date -> rate data
rates_by_hotel: Dict[int, Dict[str, dict]] = {}
for row in rates_result.fetchall():
hotel_id = row.hotel_id
rate_date = row.rate_date.isoformat()
if hotel_id not in rates_by_hotel:
rates_by_hotel[hotel_id] = {}
rates_by_hotel[hotel_id][rate_date] = {
'availability_status': row.availability_status,
'rate_gross': float(row.rate_gross) if row.rate_gross else None,
'room_type': row.room_type,
'breakfast_included': row.breakfast_included,
'free_cancellation': row.free_cancellation,
'no_prepayment': row.no_prepayment,
'rooms_left': row.rooms_left,
'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None,
}
# Build date list
dates = []
current = start
while current <= end:
dates.append(current.isoformat())
current += timedelta(days=1)
return {
'from_date': start.isoformat(),
'to_date': end.isoformat(),
'dates': dates,
'hotels': hotels,
'rates': rates_by_hotel
}
# ============================================
# RATE PARITY (OWN HOTEL VS NEWBOOK)
# ============================================
@router.get("/parity")
async def get_rate_parity(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get rate parity comparison between booking.com and Newbook rates.
Compares scraped booking.com rates for own hotel against Newbook current rates.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Get own hotel's booking.com rates
booking_rates_result = await db.execute(
text("""
SELECT DISTINCT ON (r.rate_date)
r.rate_date,
r.rate_gross as booking_rate,
r.availability_status,
r.room_type as booking_room_type,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE h.tier = 'own'
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
ORDER BY r.rate_date, r.scraped_at DESC
"""),
{'from_date': start, 'to_date': end}
)
booking_rates = {row.rate_date: dict(row._mapping) for row in booking_rates_result.fetchall()}
# Get Newbook rates (best rate per date across categories)
newbook_rates_result = await db.execute(
text("""
SELECT DISTINCT ON (rate_date)
rate_date,
rate_gross as newbook_rate,
category_id
FROM newbook_current_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
ORDER BY rate_date, valid_from DESC
"""),
{'from_date': start, 'to_date': end}
)
newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()}
# Compare rates
parity_issues = []
all_dates = set(booking_rates.keys()) | set(newbook_rates.keys())
for rate_date in sorted(all_dates):
booking = booking_rates.get(rate_date)
newbook = newbook_rates.get(rate_date)
if not booking or not newbook:
continue
booking_rate = booking.get('booking_rate')
newbook_rate = newbook.get('newbook_rate')
if not booking_rate or not newbook_rate:
continue
diff_pct = ((float(booking_rate) - float(newbook_rate)) / float(newbook_rate)) * 100
if abs(diff_pct) > 1: # More than 1% difference
parity_issues.append({
'rate_date': rate_date.isoformat(),
'booking_rate': float(booking_rate),
'newbook_rate': float(newbook_rate),
'difference_pct': round(diff_pct, 2),
'alert_type': 'higher' if diff_pct > 0 else 'lower',
'booking_room_type': booking.get('booking_room_type'),
'availability_status': booking.get('availability_status'),
})
return {
'from_date': start.isoformat(),
'to_date': end.isoformat(),
'issues_count': len(parity_issues),
'issues': parity_issues
}
# ============================================
# PARITY ALERTS
# ============================================
@router.get("/parity/alerts")
async def get_parity_alerts(
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get rate parity alerts."""
query = """
SELECT id, rate_date, room_category, newbook_rate, booking_com_rate,
difference_pct, alert_type, alert_status, created_at,
acknowledged_at, acknowledged_by, notes
FROM rate_parity_alerts
"""
params = {}
if status:
query += " WHERE alert_status = :status"
params['status'] = status
query += " ORDER BY rate_date DESC, created_at DESC LIMIT 100"
result = await db.execute(text(query), params)
return [
{
'id': row.id,
'rate_date': row.rate_date.isoformat(),
'room_category': row.room_category,
'newbook_rate': float(row.newbook_rate) if row.newbook_rate else None,
'booking_com_rate': float(row.booking_com_rate) if row.booking_com_rate else None,
'difference_pct': float(row.difference_pct) if row.difference_pct else None,
'alert_type': row.alert_type,
'alert_status': row.alert_status,
'created_at': row.created_at.isoformat() if row.created_at else None,
'acknowledged_at': row.acknowledged_at.isoformat() if row.acknowledged_at else None,
'acknowledged_by': row.acknowledged_by,
'notes': row.notes,
}
for row in result.fetchall()
]
@router.put("/parity/alerts/{alert_id}/acknowledge")
async def acknowledge_parity_alert(
alert_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Acknowledge a parity alert."""
result = await db.execute(
text("""
UPDATE rate_parity_alerts
SET alert_status = 'acknowledged',
acknowledged_at = NOW(),
acknowledged_by = :username
WHERE id = :alert_id
RETURNING id
"""),
{'alert_id': alert_id, 'username': current_user.get('username', 'unknown')}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Alert not found")
await db.commit()
return {"status": "success"}
# ============================================
# QUEUE STATUS
# ============================================
@router.get("/queue-status")
async def get_queue_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get current scrape queue status."""
result = await db.execute(
text("""
SELECT
status,
COUNT(*) as count,
MIN(rate_date) as earliest_date,
MAX(rate_date) as latest_date
FROM booking_scrape_queue
GROUP BY status
""")
)
status_counts = {row.status: {
'count': row.count,
'earliest': row.earliest_date.isoformat() if row.earliest_date else None,
'latest': row.latest_date.isoformat() if row.latest_date else None,
} for row in result.fetchall()}
# Get retry items (failed but under max_attempts)
retry_result = await db.execute(
text("""
SELECT COUNT(*) as count
FROM booking_scrape_queue
WHERE status = 'pending' AND attempts > 0
""")
)
retry_count = retry_result.fetchone().count
return {
'statuses': status_counts,
'retries_pending': retry_count,
'total_pending': status_counts.get('pending', {}).get('count', 0),
'total_completed': status_counts.get('completed', {}).get('count', 0),
'total_failed': status_counts.get('failed', {}).get('count', 0),
}
# ============================================
# SCHEDULE INFO
# ============================================
@router.get("/schedule-info")
async def get_schedule_info(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get information about the scraping schedule."""
# Get configured time
time_result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_daily_time'")
)
time_row = time_result.fetchone()
daily_time = time_row.config_value if time_row and time_row.config_value else '05:30'
# Calculate what today's schedule would look like
from jobs.scrape_booking_rates import get_high_priority_dates, get_medium_priority_dates, get_low_priority_dates
high = get_high_priority_dates()
medium = get_medium_priority_dates()
low = get_low_priority_dates()
today = date.today()
weekday_name = today.strftime('%A')
return {
'daily_time': daily_time,
'today': today.isoformat(),
'weekday': weekday_name,
'tiers': {
'high': {
'description': 'Next 30 days (scraped first)',
'dates_today': len(high),
'range': f'{high[0].isoformat()} to {high[-1].isoformat()}' if high else None,
},
'medium': {
'description': 'Days 31-180 (scraped after high priority)',
'dates_today': len(medium),
'range': f'{medium[0].isoformat()} to {medium[-1].isoformat()}' if medium else None,
},
'low': {
'description': 'Days 181-365 (scraped last, or until rate limit)',
'dates_today': len(low),
'range': f'{low[0].isoformat()} to {low[-1].isoformat()}' if low else None,
},
},
'total_dates_today': len(set(high + medium + low)),
}
# ============================================
# SCRAPE COVERAGE (365-day view)
# ============================================
@router.get("/scrape-coverage")
async def get_scrape_coverage(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get 365-day scrape coverage showing last scraped time
and next expected scrape for every date.
"""
today = date.today()
end = today + timedelta(days=365)
# Get latest scraped_at per date (across all hotels)
result = await db.execute(
text("""
SELECT rate_date, MAX(scraped_at) as last_scraped
FROM booking_com_rates
WHERE rate_date >= :from_date AND rate_date <= :to_date
GROUP BY rate_date
"""),
{'from_date': today, 'to_date': end}
)
scraped_map = {row.rate_date: row.last_scraped for row in result.fetchall()}
# Compute tier and next scrape for each date
from jobs.scrape_booking_rates import compute_next_scrape_for_date
coverage = []
for offset in range(366):
d = today + timedelta(days=offset)
tier, next_scrape = compute_next_scrape_for_date(d)
last_scraped = scraped_map.get(d)
coverage.append({
'date': d.isoformat(),
'tier': tier,
'last_scraped': last_scraped.isoformat() if last_scraped else None,
'next_expected': next_scrape.isoformat() if next_scrape else None,
})
return {
'today': today.isoformat(),
'coverage': coverage,
}
# ============================================
# BOOKING.COM AVAILABILITY CHECK (for Bookability page)
# ============================================
@router.get("/booking-availability")
async def get_booking_availability(
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Check own hotel's availability on booking.com.
Returns a simple summary: for each date in the range, whether the own hotel
appears available on booking.com based on the latest scrape data.
"""
today = date.today()
start = date.fromisoformat(from_date) if from_date else today
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
# Get own hotel's latest scraped availability
result = await db.execute(
text("""
SELECT DISTINCT ON (r.rate_date)
r.rate_date,
r.availability_status,
r.rate_gross,
r.scraped_at
FROM booking_com_rates r
JOIN booking_com_hotels h ON r.hotel_id = h.id
WHERE h.tier = 'own'
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
ORDER BY r.rate_date, r.scraped_at DESC
"""),
{'from_date': start, 'to_date': end}
)
rows = result.fetchall()
if not rows:
return {
'has_own_hotel': False,
'dates_checked': 0,
'dates_available': 0,
'dates_sold_out': 0,
'dates_no_data': 0,
'latest_scrape': None,
'dates': {},
}
dates_map = {}
dates_available = 0
dates_sold_out = 0
dates_no_data = 0
latest_scrape = None
for row in rows:
status = row.availability_status
dates_map[row.rate_date.isoformat()] = {
'status': status,
'rate': float(row.rate_gross) if row.rate_gross else None,
}
if status == 'available':
dates_available += 1
elif status == 'sold_out':
dates_sold_out += 1
else:
dates_no_data += 1
if row.scraped_at and (not latest_scrape or row.scraped_at > latest_scrape):
latest_scrape = row.scraped_at
return {
'has_own_hotel': True,
'dates_checked': len(rows),
'dates_available': dates_available,
'dates_sold_out': dates_sold_out,
'dates_no_data': dates_no_data,
'latest_scrape': latest_scrape.isoformat() if latest_scrape else None,
'dates': dates_map,
}
# ============================================
# SCRAPE HISTORY
# ============================================
@router.get("/scrape-history")
async def get_scrape_history(
limit: int = 20,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get recent scrape batch history."""
result = await db.execute(
text("""
SELECT batch_id, scrape_type, started_at, completed_at, status,
dates_queued, dates_completed, dates_failed,
hotels_found, rates_scraped, error_message,
blocked_at, resume_after
FROM booking_scrape_log
ORDER BY started_at DESC
LIMIT :limit
"""),
{'limit': limit}
)
return [
{
'batch_id': str(row.batch_id),
'scrape_type': row.scrape_type,
'started_at': row.started_at.isoformat() if row.started_at else None,
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
'status': row.status,
'dates_queued': row.dates_queued,
'dates_completed': row.dates_completed,
'dates_failed': row.dates_failed,
'hotels_found': row.hotels_found,
'rates_scraped': row.rates_scraped,
'error_message': row.error_message,
'blocked_at': row.blocked_at.isoformat() if row.blocked_at else None,
'resume_after': row.resume_after.isoformat() if row.resume_after else None,
}
for row in result.fetchall()
]

1293
backend/api/config.py Normal file

File diff suppressed because it is too large Load diff

300
backend/api/crossref.py Normal file
View file

@ -0,0 +1,300 @@
"""
Cross-Reference Validation API endpoints
Validate that related forecasts align with each other
"""
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/check")
async def run_cross_reference_check(
check_date: date = Query(..., description="Date to run cross-reference checks for"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Run all cross-reference checks for a specific date.
Returns validation results showing if forecasts are internally consistent.
"""
# Get all active cross-reference configurations
config_query = """
SELECT check_name, check_category, formula, compares_to, tolerance_pct, input_metrics
FROM cross_reference_config
WHERE is_active = TRUE
ORDER BY display_order
"""
config_result = await db.execute(text(config_query))
configs = config_result.fetchall()
# Get all forecast values for the date
forecast_query = """
SELECT forecast_type, predicted_value
FROM forecasts
WHERE forecast_date = :check_date
AND model_type = 'prophet'
"""
forecast_result = await db.execute(text(forecast_query), {"check_date": check_date})
forecasts = {row.forecast_type: float(row.predicted_value) for row in forecast_result.fetchall()}
results = []
for config in configs:
# For now, return placeholder results
# In production, would evaluate formula against forecasts
compares_to_value = forecasts.get(config.compares_to)
results.append({
"check_name": config.check_name,
"check_category": config.check_category,
"formula": config.formula,
"compares_to": config.compares_to,
"forecasted_value": compares_to_value,
"calculated_value": None, # Would be calculated from formula
"difference": None,
"difference_pct": None,
"tolerance_pct": float(config.tolerance_pct),
"status": "ok" # Would be evaluated based on tolerance
})
return {
"check_date": check_date,
"results": results,
"alignment_score": 100 # Would be calculated
}
@router.get("/report")
async def get_cross_reference_report(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get cross-reference validation report for date range.
"""
query = """
SELECT
forecast_date,
check_name,
check_category,
calculated_value,
forecasted_value,
difference,
difference_pct,
tolerance_pct,
status
FROM forecast_cross_reference
WHERE forecast_date BETWEEN :from_date AND :to_date
ORDER BY forecast_date, check_category, check_name
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"date": row.forecast_date,
"check_name": row.check_name,
"category": row.check_category,
"calculated": float(row.calculated_value) if row.calculated_value else None,
"forecasted": float(row.forecasted_value) if row.forecasted_value else None,
"difference": float(row.difference) if row.difference else None,
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
"tolerance_pct": float(row.tolerance_pct),
"status": row.status
}
for row in rows
]
@router.get("/discrepancies")
async def get_discrepancies(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get list of dates with cross-reference discrepancies.
"""
query = """
SELECT
forecast_date,
check_name,
check_category,
difference_pct,
tolerance_pct,
possible_causes,
recommendation
FROM forecast_cross_reference
WHERE forecast_date BETWEEN :from_date AND :to_date
AND status = 'discrepancy'
ORDER BY ABS(difference_pct) DESC
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"date": row.forecast_date,
"check_name": row.check_name,
"category": row.check_category,
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
"tolerance_pct": float(row.tolerance_pct),
"possible_causes": row.possible_causes,
"recommendation": row.recommendation
}
for row in rows
]
@router.get("/alignment-score")
async def get_alignment_score(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get overall alignment score for date range.
Higher score = more internally consistent forecasts.
"""
query = """
SELECT
COUNT(*) as total_checks,
SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as passed,
SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warnings,
SUM(CASE WHEN status = 'discrepancy' THEN 1 ELSE 0 END) as discrepancies
FROM forecast_cross_reference
WHERE forecast_date BETWEEN :from_date AND :to_date
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
row = result.fetchone()
if not row or row.total_checks == 0:
return {
"from_date": from_date,
"to_date": to_date,
"alignment_score": 100,
"total_checks": 0,
"passed": 0,
"warnings": 0,
"discrepancies": 0
}
# Score: 100 * (passed / total), with warnings counting as 0.5
score = ((row.passed + row.warnings * 0.5) / row.total_checks) * 100
return {
"from_date": from_date,
"to_date": to_date,
"alignment_score": round(score, 1),
"total_checks": row.total_checks,
"passed": row.passed,
"warnings": row.warnings,
"discrepancies": row.discrepancies
}
@router.get("/config")
async def get_crossref_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get cross-reference check configuration.
"""
query = """
SELECT
check_name,
check_category,
description,
formula,
compares_to,
tolerance_pct,
input_metrics,
is_correlation_check,
expected_correlation,
is_active,
display_order
FROM cross_reference_config
ORDER BY display_order
"""
result = await db.execute(text(query))
rows = result.fetchall()
return [
{
"check_name": row.check_name,
"category": row.check_category,
"description": row.description,
"formula": row.formula,
"compares_to": row.compares_to,
"tolerance_pct": float(row.tolerance_pct),
"input_metrics": row.input_metrics,
"is_correlation_check": row.is_correlation_check,
"expected_correlation": float(row.expected_correlation) if row.expected_correlation else None,
"is_active": row.is_active
}
for row in rows
]
@router.put("/config/{check_name}")
async def update_crossref_config(
check_name: str,
tolerance_pct: Optional[float] = Query(None),
is_active: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Update tolerance or active status for a cross-reference check.
"""
updates = []
params = {"check_name": check_name}
if tolerance_pct is not None:
updates.append("tolerance_pct = :tolerance_pct")
params["tolerance_pct"] = tolerance_pct
if is_active is not None:
updates.append("is_active = :is_active")
params["is_active"] = is_active
if not updates:
return {"status": "no_changes", "check_name": check_name}
query = f"""
UPDATE cross_reference_config
SET {', '.join(updates)}
WHERE check_name = :check_name
RETURNING check_name
"""
result = await db.execute(text(query), params)
await db.commit()
row = result.fetchone()
if not row:
raise ValueError(f"Check not found: {check_name}")
return {
"status": "updated",
"check_name": check_name,
"updates": {k: v for k, v in params.items() if k != "check_name"}
}

292
backend/api/evolution.py Normal file
View file

@ -0,0 +1,292 @@
"""
Forecast Evolution API endpoints
Track how forecasts change over time as dates approach
"""
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/date")
async def get_forecast_evolution_for_date(
forecast_date: date = Query(..., description="The date to see evolution for"),
forecast_type: str = Query(..., description="Metric code"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get full forecast history for a specific date.
Shows how predictions changed as the date approached.
"""
query = """
SELECT
fh.generated_at,
fh.model_type,
fh.predicted_value,
fh.lower_bound,
fh.upper_bound,
fh.horizon_days,
fh.change_amount,
fh.change_pct,
fh.change_reason,
dm.actual_value
FROM forecast_history fh
LEFT JOIN daily_metrics dm ON fh.forecast_date = dm.date AND fh.forecast_type = dm.metric_code
WHERE fh.forecast_date = :forecast_date
AND fh.forecast_type = :forecast_type
ORDER BY fh.generated_at, fh.model_type
"""
result = await db.execute(text(query), {
"forecast_date": forecast_date,
"forecast_type": forecast_type
})
rows = result.fetchall()
return [
{
"generated_at": row.generated_at,
"model_type": row.model_type,
"predicted_value": float(row.predicted_value),
"lower_bound": float(row.lower_bound) if row.lower_bound else None,
"upper_bound": float(row.upper_bound) if row.upper_bound else None,
"horizon_days": row.horizon_days,
"change_amount": float(row.change_amount) if row.change_amount else None,
"change_pct": float(row.change_pct) if row.change_pct else None,
"change_reason": row.change_reason,
"actual_value": float(row.actual_value) if row.actual_value else None
}
for row in rows
]
@router.get("/chart-data")
async def get_evolution_chart_data(
forecast_date: date = Query(...),
forecast_type: str = Query(...),
model: str = Query("prophet", description="Model: prophet, xgboost, pickup"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get evolution data formatted for charting.
Returns time series of forecast values as date approached.
"""
query = """
SELECT
DATE(fh.generated_at) as update_date,
fh.horizon_days,
fh.predicted_value,
fh.lower_bound,
fh.upper_bound,
dm.actual_value
FROM forecast_history fh
LEFT JOIN daily_metrics dm ON fh.forecast_date = dm.date AND fh.forecast_type = dm.metric_code
WHERE fh.forecast_date = :forecast_date
AND fh.forecast_type = :forecast_type
AND fh.model_type = :model
ORDER BY fh.generated_at
"""
result = await db.execute(text(query), {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": model
})
rows = result.fetchall()
actual_value = None
chart_data = []
for row in rows:
# Use 'is not None' - 0 is valid actual data (e.g., 0 covers on closed day)
if row.actual_value is not None:
actual_value = float(row.actual_value)
chart_data.append({
"update_date": row.update_date,
"horizon_days": row.horizon_days,
"predicted_value": float(row.predicted_value),
"lower_bound": float(row.lower_bound) if row.lower_bound else None,
"upper_bound": float(row.upper_bound) if row.upper_bound else None
})
return {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": model,
"actual_value": actual_value,
"data_points": chart_data
}
@router.get("/changes")
async def get_forecast_changes(
forecast_date: date = Query(...),
forecast_type: str = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get list of all changes with reasons for a specific forecast.
"""
query = """
SELECT
changed_at,
model_type,
old_value,
new_value,
change_amount,
change_pct,
change_category,
change_reason,
bookings_added,
bookings_cancelled,
covers_change,
days_out,
otb_at_change
FROM forecast_change_log
WHERE forecast_date = :forecast_date
AND forecast_type = :forecast_type
ORDER BY changed_at DESC
"""
result = await db.execute(text(query), {
"forecast_date": forecast_date,
"forecast_type": forecast_type
})
rows = result.fetchall()
return [
{
"changed_at": row.changed_at,
"model_type": row.model_type,
"old_value": float(row.old_value) if row.old_value else None,
"new_value": float(row.new_value) if row.new_value else None,
"change_amount": float(row.change_amount) if row.change_amount else None,
"change_pct": float(row.change_pct) if row.change_pct else None,
"change_category": row.change_category,
"change_reason": row.change_reason,
"bookings_added": row.bookings_added,
"bookings_cancelled": row.bookings_cancelled,
"covers_change": row.covers_change,
"days_out": row.days_out,
"otb_at_change": float(row.otb_at_change) if row.otb_at_change is not None else None
}
for row in rows
]
@router.get("/convergence")
async def get_forecast_convergence(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Analyze how quickly forecasts converge to actuals.
Shows forecast accuracy at different lead times.
"""
query = """
WITH convergence_data AS (
SELECT
fh.forecast_type,
fh.model_type,
fh.horizon_days,
ABS(fh.predicted_value - dm.actual_value) as abs_error,
ABS((fh.predicted_value - dm.actual_value) / NULLIF(dm.actual_value, 0) * 100) as pct_error
FROM forecast_history fh
JOIN daily_metrics dm ON fh.forecast_date = dm.date AND fh.forecast_type = dm.metric_code
WHERE fh.forecast_date BETWEEN :from_date AND :to_date
AND dm.actual_value IS NOT NULL
)
SELECT
forecast_type,
model_type,
CASE
WHEN horizon_days <= 7 THEN '0-7 days'
WHEN horizon_days <= 14 THEN '8-14 days'
WHEN horizon_days <= 21 THEN '15-21 days'
WHEN horizon_days <= 28 THEN '22-28 days'
ELSE '29+ days'
END as horizon_bucket,
AVG(abs_error) as avg_error,
AVG(pct_error) as avg_pct_error,
COUNT(*) as sample_count
FROM convergence_data
GROUP BY forecast_type, model_type,
CASE
WHEN horizon_days <= 7 THEN '0-7 days'
WHEN horizon_days <= 14 THEN '8-14 days'
WHEN horizon_days <= 21 THEN '15-21 days'
WHEN horizon_days <= 28 THEN '22-28 days'
ELSE '29+ days'
END
ORDER BY forecast_type, model_type, horizon_bucket
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"forecast_type": row.forecast_type,
"model_type": row.model_type,
"horizon_bucket": row.horizon_bucket,
"avg_error": round(float(row.avg_error), 2) if row.avg_error else None,
"avg_pct_error": round(float(row.avg_pct_error), 2) if row.avg_pct_error else None,
"sample_count": row.sample_count
}
for row in rows
]
@router.get("/volatility")
async def get_forecast_volatility(
from_date: date = Query(...),
to_date: date = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Identify dates with high forecast volatility.
Shows which dates had the most forecast changes.
"""
query = """
SELECT
forecast_date,
forecast_type,
COUNT(*) as change_count,
MAX(ABS(change_amount)) as max_change,
SUM(ABS(change_amount)) as total_change,
array_agg(DISTINCT change_category) as change_categories
FROM forecast_change_log
WHERE forecast_date BETWEEN :from_date AND :to_date
GROUP BY forecast_date, forecast_type
HAVING COUNT(*) > 3 OR MAX(ABS(change_pct)) > 10
ORDER BY total_change DESC
LIMIT 20
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"forecast_date": row.forecast_date,
"forecast_type": row.forecast_type,
"change_count": row.change_count,
"max_change": float(row.max_change) if row.max_change else None,
"total_change": float(row.total_change) if row.total_change else None,
"change_categories": row.change_categories
}
for row in rows
]

328
backend/api/explain.py Normal file
View file

@ -0,0 +1,328 @@
"""
Model Explainability API endpoints
Explain why forecasts have specific values
"""
from datetime import date
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/forecast")
async def explain_forecast(
forecast_date: date = Query(...),
forecast_type: str = Query(...),
model: str = Query("prophet", description="Model: prophet, xgboost, pickup"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get full breakdown of how a forecast was calculated.
Returns different explanations based on model type.
"""
# Get the forecast value
forecast_query = """
SELECT predicted_value, lower_bound, upper_bound, generated_at
FROM forecasts
WHERE forecast_date = :forecast_date
AND forecast_type = :forecast_type
AND model_type = :model
ORDER BY generated_at DESC
LIMIT 1
"""
result = await db.execute(text(forecast_query), {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": model
})
forecast = result.fetchone()
if not forecast:
return {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": model,
"error": "No forecast found"
}
# Get model-specific explanation
if model == "prophet":
return await _get_prophet_explanation(db, forecast_date, forecast_type, forecast)
elif model == "xgboost":
return await _get_xgboost_explanation(db, forecast_date, forecast_type, forecast)
elif model == "pickup":
return await _get_pickup_explanation(db, forecast_date, forecast_type, forecast)
else:
return {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": model,
"predicted_value": float(forecast.predicted_value),
"explanation": "No detailed explanation available for this model"
}
async def _get_prophet_explanation(db, forecast_date, forecast_type, forecast):
"""Get Prophet model decomposition explanation"""
query = """
SELECT
trend,
yearly_seasonality,
weekly_seasonality,
daily_seasonality,
holiday_effects,
regressor_effects
FROM prophet_decomposition
WHERE forecast_date = :forecast_date
AND forecast_type = :forecast_type
ORDER BY generated_at DESC
LIMIT 1
"""
result = await db.execute(text(query), {
"forecast_date": forecast_date,
"forecast_type": forecast_type
})
decomp = result.fetchone()
explanation = {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": "prophet",
"predicted_value": float(forecast.predicted_value),
"lower_bound": float(forecast.lower_bound) if forecast.lower_bound else None,
"upper_bound": float(forecast.upper_bound) if forecast.upper_bound else None,
"generated_at": forecast.generated_at
}
if decomp:
components = []
if decomp.trend:
components.append({
"name": "Base trend",
"value": float(decomp.trend),
"description": "Long-term average trend"
})
if decomp.yearly_seasonality:
components.append({
"name": "Yearly seasonality",
"value": float(decomp.yearly_seasonality),
"description": "Annual pattern (e.g., summer peak, winter low)"
})
if decomp.weekly_seasonality:
components.append({
"name": "Weekly seasonality",
"value": float(decomp.weekly_seasonality),
"description": "Day-of-week pattern (e.g., weekend higher)"
})
if decomp.holiday_effects:
for holiday, effect in decomp.holiday_effects.items():
components.append({
"name": f"Holiday: {holiday}",
"value": float(effect),
"description": f"Effect of {holiday}"
})
explanation["components"] = components
explanation["breakdown"] = {
"trend": float(decomp.trend) if decomp.trend else 0,
"yearly": float(decomp.yearly_seasonality) if decomp.yearly_seasonality else 0,
"weekly": float(decomp.weekly_seasonality) if decomp.weekly_seasonality else 0,
"holidays": decomp.holiday_effects
}
return explanation
async def _get_xgboost_explanation(db, forecast_date, forecast_type, forecast):
"""Get XGBoost SHAP explanation"""
query = """
SELECT
base_value,
feature_values,
shap_values,
top_positive,
top_negative
FROM xgboost_explanations
WHERE forecast_date = :forecast_date
AND forecast_type = :forecast_type
ORDER BY generated_at DESC
LIMIT 1
"""
result = await db.execute(text(query), {
"forecast_date": forecast_date,
"forecast_type": forecast_type
})
shap = result.fetchone()
explanation = {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": "xgboost",
"predicted_value": float(forecast.predicted_value),
"generated_at": forecast.generated_at
}
if shap:
explanation["base_value"] = float(shap.base_value) if shap.base_value else None
explanation["feature_values"] = shap.feature_values
explanation["shap_values"] = shap.shap_values
explanation["top_drivers"] = {
"positive": shap.top_positive or [],
"negative": shap.top_negative or []
}
# Build human-readable summary
summary_parts = []
if shap.top_positive:
for item in shap.top_positive[:3]:
if isinstance(item, dict):
summary_parts.append(f"{item.get('feature', 'Unknown')} (+{item.get('contribution', 0):.1f})")
if shap.top_negative:
for item in shap.top_negative[:2]:
if isinstance(item, dict):
summary_parts.append(f"{item.get('feature', 'Unknown')} ({item.get('contribution', 0):.1f})")
explanation["summary"] = f"Main drivers: {', '.join(summary_parts)}" if summary_parts else None
return explanation
async def _get_pickup_explanation(db, forecast_date, forecast_type, forecast):
"""Get Pickup model explanation"""
query = """
SELECT
current_otb,
days_out,
comparison_date,
comparison_otb,
comparison_final,
pickup_curve_pct,
pickup_curve_stddev,
pace_vs_prior_pct,
projection_method,
projected_value,
confidence_note
FROM pickup_explanations
WHERE forecast_date = :forecast_date
AND forecast_type = :forecast_type
ORDER BY generated_at DESC
LIMIT 1
"""
result = await db.execute(text(query), {
"forecast_date": forecast_date,
"forecast_type": forecast_type
})
pickup = result.fetchone()
explanation = {
"forecast_date": forecast_date,
"forecast_type": forecast_type,
"model": "pickup",
"predicted_value": float(forecast.predicted_value),
"generated_at": forecast.generated_at
}
if pickup:
explanation["current_state"] = {
"on_the_books": float(pickup.current_otb) if pickup.current_otb is not None else None,
"days_out": pickup.days_out
}
explanation["comparison"] = {
"date": pickup.comparison_date,
"otb_at_same_lead_time": float(pickup.comparison_otb) if pickup.comparison_otb is not None else None,
"final_actual": float(pickup.comparison_final) if pickup.comparison_final is not None else None
}
explanation["pickup_curve"] = {
"avg_pct_of_final": float(pickup.pickup_curve_pct) if pickup.pickup_curve_pct is not None else None,
"std_dev": float(pickup.pickup_curve_stddev) if pickup.pickup_curve_stddev is not None else None
}
explanation["pace_analysis"] = {
"vs_prior_year_pct": float(pickup.pace_vs_prior_pct) if pickup.pace_vs_prior_pct is not None else None,
"projection_method": pickup.projection_method,
"projected_final": float(pickup.projected_value) if pickup.projected_value else None
}
explanation["confidence_note"] = pickup.confidence_note
# Build summary
pace_str = ""
if pickup.pace_vs_prior_pct:
if pickup.pace_vs_prior_pct > 0:
pace_str = f"{pickup.pace_vs_prior_pct:.1f}% ahead of last year's pace"
else:
pace_str = f"{abs(pickup.pace_vs_prior_pct):.1f}% behind last year's pace"
explanation["summary"] = f"At {pickup.days_out} days out, {pace_str}" if pace_str else None
return explanation
@router.get("/prophet")
async def get_prophet_decomposition(
forecast_date: date = Query(...),
forecast_type: str = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get Prophet decomposition (trend, seasonality, holidays) for a forecast.
"""
return await _get_prophet_explanation(
db, forecast_date, forecast_type,
type('obj', (object,), {
'predicted_value': 0,
'lower_bound': None,
'upper_bound': None,
'generated_at': None
})()
)
@router.get("/xgboost")
async def get_xgboost_shap(
forecast_date: date = Query(...),
forecast_type: str = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get XGBoost SHAP values and feature contributions for a forecast.
"""
return await _get_xgboost_explanation(
db, forecast_date, forecast_type,
type('obj', (object,), {
'predicted_value': 0,
'generated_at': None
})()
)
@router.get("/pickup")
async def get_pickup_breakdown(
forecast_date: date = Query(...),
forecast_type: str = Query(...),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get Pickup model calculation breakdown for a forecast.
"""
return await _get_pickup_explanation(
db, forecast_date, forecast_type,
type('obj', (object,), {
'predicted_value': 0,
'generated_at': None
})()
)

237
backend/api/export.py Normal file
View file

@ -0,0 +1,237 @@
"""
Export API endpoints for Excel/CSV downloads
"""
import io
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import pandas as pd
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/excel")
async def export_excel(
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Download Excel workbook with multiple sheets:
- Daily Forecast (all models)
- Weekly Summary
- Budget Comparison
- Model Accuracy
"""
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = from_date + timedelta(days=28)
# Create Excel writer
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
# Daily forecasts sheet
daily_query = """
SELECT
f.forecast_date as "Date",
f.forecast_type as "Metric",
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as "Prophet",
MAX(CASE WHEN f.model_type = 'prophet' THEN f.lower_bound END) as "Prophet Lower",
MAX(CASE WHEN f.model_type = 'prophet' THEN f.upper_bound END) as "Prophet Upper",
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as "XGBoost",
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as "Pickup",
db.budget_value as "Budget"
FROM forecasts f
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
GROUP BY f.forecast_date, f.forecast_type, db.budget_value
ORDER BY f.forecast_date, f.forecast_type
"""
result = await db.execute(text(daily_query), {"from_date": from_date, "to_date": to_date})
daily_df = pd.DataFrame(result.fetchall())
if not daily_df.empty:
daily_df.to_excel(writer, sheet_name='Daily Forecast', index=False)
# Weekly summary sheet
weekly_query = """
SELECT
DATE_TRUNC('week', f.forecast_date) as "Week Start",
f.forecast_type as "Metric",
AVG(f.predicted_value) as "Avg Forecast",
SUM(f.predicted_value) as "Total Forecast",
AVG(db.budget_value) as "Avg Budget",
SUM(db.budget_value) as "Total Budget"
FROM forecasts f
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
AND f.model_type = 'prophet'
GROUP BY DATE_TRUNC('week', f.forecast_date), f.forecast_type
ORDER BY "Week Start", f.forecast_type
"""
result = await db.execute(text(weekly_query), {"from_date": from_date, "to_date": to_date})
weekly_df = pd.DataFrame(result.fetchall())
if not weekly_df.empty:
weekly_df.to_excel(writer, sheet_name='Weekly Summary', index=False)
# Budget variance sheet
variance_query = """
SELECT
f.forecast_date as "Date",
f.forecast_type as "Metric",
f.predicted_value as "Forecast",
db.budget_value as "Budget",
(f.predicted_value - db.budget_value) as "Variance",
CASE
WHEN db.budget_value != 0 THEN
ROUND(((f.predicted_value - db.budget_value) / db.budget_value * 100)::numeric, 1)
ELSE NULL
END as "Variance %"
FROM forecasts f
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
AND f.model_type = 'prophet'
ORDER BY f.forecast_date, f.forecast_type
"""
result = await db.execute(text(variance_query), {"from_date": from_date, "to_date": to_date})
variance_df = pd.DataFrame(result.fetchall())
if not variance_df.empty:
variance_df.to_excel(writer, sheet_name='Budget Variance', index=False)
# Accuracy sheet (historical)
accuracy_query = """
SELECT
date as "Date",
metric_type as "Metric",
actual_value as "Actual",
prophet_forecast as "Prophet",
xgboost_forecast as "XGBoost",
pickup_forecast as "Pickup",
best_model as "Best Model"
FROM actual_vs_forecast
WHERE date BETWEEN :from_date - INTERVAL '30 days' AND :from_date
ORDER BY date, metric_type
"""
result = await db.execute(text(accuracy_query), {"from_date": from_date, "to_date": to_date})
accuracy_df = pd.DataFrame(result.fetchall())
if not accuracy_df.empty:
accuracy_df.to_excel(writer, sheet_name='Historical Accuracy', index=False)
output.seek(0)
filename = f"forecast_{from_date}_{to_date}.xlsx"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/csv/{metric}")
async def export_csv(
metric: str,
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Download CSV for a specific metric.
"""
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = from_date + timedelta(days=28)
query = """
SELECT
f.forecast_date,
f.model_type,
f.predicted_value,
f.lower_bound,
f.upper_bound,
dm.actual_value,
db.budget_value
FROM forecasts f
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
WHERE f.forecast_date BETWEEN :from_date AND :to_date
AND f.forecast_type = :metric
ORDER BY f.forecast_date, f.model_type
"""
result = await db.execute(text(query), {
"from_date": from_date,
"to_date": to_date,
"metric": metric
})
df = pd.DataFrame(result.fetchall())
output = io.StringIO()
df.to_csv(output, index=False)
output.seek(0)
filename = f"{metric}_{from_date}_{to_date}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/model-comparison")
async def export_model_comparison(
from_date: Optional[date] = Query(None),
to_date: Optional[date] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Export model comparison data for all metrics.
"""
if from_date is None:
from_date = date.today()
if to_date is None:
to_date = from_date + timedelta(days=28)
query = """
SELECT
f.forecast_date,
f.forecast_type,
fm.metric_name,
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as prophet,
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as xgboost,
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as pickup,
dm.actual_value
FROM forecasts f
LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.metric_code
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
WHERE f.forecast_date BETWEEN :from_date AND :to_date
GROUP BY f.forecast_date, f.forecast_type, fm.metric_name, dm.actual_value
ORDER BY f.forecast_date, f.forecast_type
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
df = pd.DataFrame(result.fetchall())
output = io.BytesIO()
with pd.ExcelWriter(output, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Model Comparison', index=False)
output.seek(0)
filename = f"model_comparison_{from_date}_{to_date}.xlsx"
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)

3727
backend/api/forecast.py Normal file

File diff suppressed because it is too large Load diff

221
backend/api/historical.py Normal file
View file

@ -0,0 +1,221 @@
"""
Historical data API endpoints
Provides access to aggregated actual data from daily_occupancy and daily_covers
"""
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from database import get_db
from auth import get_current_user
router = APIRouter()
@router.get("/occupancy")
async def get_occupancy_data(
from_date: Optional[date] = Query(None, description="Start date"),
to_date: Optional[date] = Query(None, description="End date"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get historical occupancy data from daily_occupancy table.
"""
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
query = """
SELECT
date,
total_rooms,
occupied_rooms,
occupancy_pct,
total_guests,
total_adults,
total_children,
total_infants,
arrival_count,
room_revenue,
adr,
revpar,
agr,
breakfast_allocation_qty,
dinner_allocation_qty,
by_room_type,
revenue_by_room_type
FROM daily_occupancy
WHERE date BETWEEN :from_date AND :to_date
ORDER BY date
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
return [
{
"date": row.date,
"total_rooms": row.total_rooms,
"occupied_rooms": row.occupied_rooms,
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct else 0,
"total_guests": row.total_guests,
"total_adults": row.total_adults,
"total_children": row.total_children,
"total_infants": row.total_infants,
"arrival_count": row.arrival_count,
"room_revenue": float(row.room_revenue) if row.room_revenue else 0,
"adr": float(row.adr) if row.adr else 0,
"revpar": float(row.revpar) if row.revpar else 0,
"agr": float(row.agr) if row.agr else 0,
"breakfast_allocation_qty": row.breakfast_allocation_qty,
"dinner_allocation_qty": row.dinner_allocation_qty,
"by_room_type": row.by_room_type,
"revenue_by_room_type": row.revenue_by_room_type
}
for row in rows
]
@router.get("/covers")
async def get_covers_data(
from_date: Optional[date] = Query(None, description="Start date"),
to_date: Optional[date] = Query(None, description="End date"),
service_period: Optional[str] = Query(None, description="Filter by: lunch, dinner"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get historical covers data from daily_covers table.
"""
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
query = """
SELECT
date,
service_period,
total_bookings,
total_covers,
avg_party_size,
hotel_guest_covers,
external_covers,
dbb_covers,
package_covers,
cancelled_bookings,
cancelled_covers,
no_show_bookings,
no_show_covers,
by_source
FROM daily_covers
WHERE date BETWEEN :from_date AND :to_date
"""
params = {"from_date": from_date, "to_date": to_date}
if service_period:
query += " AND service_period = :service_period"
params["service_period"] = service_period
query += " ORDER BY date, service_period"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"date": row.date,
"service_period": row.service_period,
"total_bookings": row.total_bookings,
"total_covers": row.total_covers,
"avg_party_size": float(row.avg_party_size) if row.avg_party_size else 0,
"hotel_guest_covers": row.hotel_guest_covers,
"external_covers": row.external_covers,
"dbb_covers": row.dbb_covers,
"package_covers": row.package_covers,
"cancelled_bookings": row.cancelled_bookings,
"cancelled_covers": row.cancelled_covers,
"no_show_bookings": row.no_show_bookings,
"no_show_covers": row.no_show_covers,
"by_source": row.by_source
}
for row in rows
]
@router.get("/summary")
async def get_daily_summary(
from_date: Optional[date] = Query(None, description="Start date"),
to_date: Optional[date] = Query(None, description="End date"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get combined daily summary with occupancy and covers data.
"""
if from_date is None:
from_date = date.today() - timedelta(days=30)
if to_date is None:
to_date = date.today()
query = """
SELECT
o.date,
EXTRACT(DOW FROM o.date) as day_of_week,
o.total_rooms,
o.available_rooms,
o.occupied_rooms,
o.occupancy_pct,
o.total_guests,
o.room_revenue,
o.adr,
o.revpar,
o.agr,
o.arrival_count,
o.breakfast_allocation_qty,
o.dinner_allocation_qty,
COALESCE(cl.total_covers, 0) as lunch_covers,
COALESCE(cd.total_covers, 0) as dinner_covers,
COALESCE(cl.total_bookings, 0) as lunch_bookings,
COALESCE(cd.total_bookings, 0) as dinner_bookings
FROM daily_occupancy o
LEFT JOIN daily_covers cl ON o.date = cl.date AND cl.service_period = 'lunch'
LEFT JOIN daily_covers cd ON o.date = cd.date AND cd.service_period = 'dinner'
WHERE o.date BETWEEN :from_date AND :to_date
ORDER BY o.date
"""
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
rows = result.fetchall()
day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
return [
{
"date": row.date,
"day_of_week": day_names[int(row.day_of_week)],
"total_rooms": row.total_rooms,
"available_rooms": row.available_rooms,
"occupied_rooms": row.occupied_rooms,
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct else 0,
"total_guests": row.total_guests,
"room_revenue": float(row.room_revenue) if row.room_revenue else 0,
"adr": float(row.adr) if row.adr else 0,
"revpar": float(row.revpar) if row.revpar else 0,
"agr": float(row.agr) if row.agr else 0,
"arrival_count": row.arrival_count,
"breakfast_allocation_qty": row.breakfast_allocation_qty,
"dinner_allocation_qty": row.dinner_allocation_qty,
"lunch_covers": row.lunch_covers,
"dinner_covers": row.dinner_covers,
"lunch_bookings": row.lunch_bookings,
"dinner_bookings": row.dinner_bookings
}
for row in rows
]

537
backend/api/public.py Normal file
View file

@ -0,0 +1,537 @@
"""
Public API endpoints - accessible with API key authentication
For external integrations like Kitchen Flash app
"""
from datetime import date, datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
import logging
from database import get_db
from auth import get_api_key_auth
router = APIRouter()
logger = logging.getLogger(__name__)
def get_prior_year_date(target_date: date) -> date:
"""
Get prior year date with 364-day offset for day-of-week alignment.
52 weeks = 364 days, so Monday aligns with Monday.
"""
return target_date - timedelta(days=364)
@router.get("/forecast/rooms")
async def get_rooms_forecast(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Get forecasted room bookings for external applications.
Returns: OTB rooms, forecast rooms, occupancy %, prior year data
"""
from services.forecasting.pickup_v2_model import forecast_rooms_for_date
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
end = start + timedelta(days=days - 1)
today = date.today()
# Get total rooms for occupancy calculation
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'total_rooms'")
)
row = result.fetchone()
total_rooms = int(row.config_value) if row and row.config_value else 30
data = []
current = start
while current <= end:
lead_days = (current - today).days if current >= today else 0
prior_date = get_prior_year_date(current)
try:
if current >= today:
# Future date - get forecast
forecast = await forecast_rooms_for_date(
db, current, lead_days, prior_date, 'hotel_room_nights'
)
otb_rooms = forecast.get('current_otb', 0) or 0
pickup_rooms = forecast.get('expected_pickup', 0) or 0
forecast_rooms = otb_rooms + pickup_rooms
prior_otb = forecast.get('prior_year_otb', 0) or 0
prior_final = forecast.get('prior_year_final', 0) or 0
# Get guest counts from stats
stats_result = await db.execute(
text("""
SELECT booking_count, guests_count FROM newbook_bookings_stats
WHERE date = :target_date
"""),
{"target_date": current}
)
stats_row = stats_result.fetchone()
otb_guests = stats_row.guests_count if stats_row and stats_row.guests_count else 0
# Prior year guests for ratio
prior_stats = await db.execute(
text("""
SELECT booking_count, guests_count FROM newbook_bookings_stats
WHERE date = :prior_date
"""),
{"prior_date": prior_date}
)
prior_stats_row = prior_stats.fetchone()
prior_guests = prior_stats_row.guests_count if prior_stats_row and prior_stats_row.guests_count else 0
prior_rooms_actual = prior_stats_row.booking_count if prior_stats_row and prior_stats_row.booking_count else 0
# Calculate guests per room ratio (prior year -> current OTB -> default)
if prior_rooms_actual > 0:
guests_per_room = prior_guests / prior_rooms_actual
elif otb_rooms > 0:
guests_per_room = otb_guests / otb_rooms
else:
guests_per_room = 1.8
pickup_guests = round(pickup_rooms * guests_per_room)
forecast_guests = otb_guests + pickup_guests
else:
# Past date - get actuals from stats
stats_result = await db.execute(
text("""
SELECT booking_count, guests_count FROM newbook_bookings_stats
WHERE date = :target_date
"""),
{"target_date": current}
)
stats_row = stats_result.fetchone()
otb_rooms = stats_row.booking_count if stats_row else 0
otb_guests = stats_row.guests_count if stats_row and stats_row.guests_count else 0
forecast_rooms = otb_rooms
forecast_guests = otb_guests
pickup_rooms = 0
pickup_guests = 0
# Prior year stats
prior_result = await db.execute(
text("""
SELECT booking_count FROM newbook_bookings_stats
WHERE date = :prior_date
"""),
{"prior_date": prior_date}
)
prior_row = prior_result.fetchone()
prior_final = prior_row.booking_count if prior_row else 0
prior_otb = prior_final
occupancy_pct = round((forecast_rooms / total_rooms) * 100, 1) if total_rooms > 0 else 0
data.append({
"date": current.isoformat(),
"day": current.strftime("%A"),
"lead_days": lead_days,
"otb_rooms": otb_rooms,
"pickup_rooms": pickup_rooms,
"forecast_rooms": forecast_rooms,
"otb_guests": otb_guests,
"pickup_guests": pickup_guests,
"forecast_guests": forecast_guests,
"occupancy_pct": occupancy_pct,
"prior_year_otb": prior_otb,
"prior_year_final": prior_final,
})
except Exception as e:
logger.warning(f"Error forecasting rooms for {current}: {e}")
data.append({
"date": current.isoformat(),
"day": current.strftime("%A"),
"lead_days": lead_days,
"otb_rooms": 0,
"pickup_rooms": 0,
"forecast_rooms": 0,
"otb_guests": 0,
"pickup_guests": 0,
"forecast_guests": 0,
"occupancy_pct": 0,
"prior_year_otb": 0,
"prior_year_final": 0,
"error": str(e)
})
current += timedelta(days=1)
return {"data": data, "total_rooms": total_rooms}
@router.get("/forecast/covers")
async def get_covers_forecast(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Get forecasted restaurant covers by period for external applications.
Returns: OTB covers, forecast covers, prior year data for breakfast, lunch, dinner
"""
from services.forecasting.covers_model import forecast_covers_range
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
end = start + timedelta(days=days - 1)
today = date.today()
try:
# Get covers forecast
covers_data = await forecast_covers_range(db, start, end, include_details=False)
except Exception as e:
logger.error(f"Covers forecast failed: {e}")
raise HTTPException(status_code=500, detail=f"Forecast error: {str(e)}")
# Get prior year covers from stats
prior_start = get_prior_year_date(start)
prior_end = get_prior_year_date(end)
prior_result = await db.execute(
text("""
SELECT date, breakfast_covers, lunch_covers, dinner_covers
FROM resos_bookings_stats
WHERE date >= :start_date AND date <= :end_date
"""),
{"start_date": prior_start, "end_date": prior_end}
)
prior_rows = prior_result.fetchall()
prior_by_date = {row.date: row for row in prior_rows}
data = []
for day_data in covers_data.get("data", []):
target_date = datetime.strptime(day_data["date"], "%Y-%m-%d").date()
prior_date = get_prior_year_date(target_date)
lead_days = (target_date - today).days if target_date >= today else 0
prior_row = prior_by_date.get(prior_date)
prior_breakfast = prior_row.breakfast_covers if prior_row else 0
prior_lunch = prior_row.lunch_covers if prior_row else 0
prior_dinner = prior_row.dinner_covers if prior_row else 0
# For prior OTB, we don't have pace data, so use same as final for simplicity
data.append({
"date": day_data["date"],
"day": day_data["day_of_week"],
"lead_days": lead_days,
"breakfast": {
"otb": day_data["breakfast"]["otb"],
"forecast": day_data["breakfast"]["forecast"],
"prior_otb": prior_breakfast, # Same as final for prior year
"prior_final": prior_breakfast
},
"lunch": {
"otb": day_data["lunch"]["otb"],
"forecast": day_data["lunch"]["forecast"],
"prior_otb": prior_lunch,
"prior_final": prior_lunch
},
"dinner": {
"otb": day_data["dinner"]["otb"],
"forecast": day_data["dinner"]["forecast"],
"prior_otb": prior_dinner,
"prior_final": prior_dinner
},
"total": {
"otb": day_data["totals"]["otb"],
"forecast": day_data["totals"]["forecast"],
"prior_final": prior_breakfast + prior_lunch + prior_dinner
}
})
return {"data": data}
@router.get("/forecast/revenue")
async def get_revenue_forecast(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
type: str = Query("all", description="Revenue type: all, accom, dry, wet"),
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Get forecasted revenue by type for external applications.
Returns: OTB revenue, forecast revenue, prior year actuals, budget
"""
from services.forecasting.covers_model import forecast_covers_range
from services.forecasting.pickup_v2_model import forecast_revenue_for_date
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
end = start + timedelta(days=days - 1)
today = date.today()
VAT_RATE = 1.20
# Get spend settings for restaurant revenue
spend_result = await db.execute(
text("""
SELECT config_key, config_value
FROM system_config
WHERE config_key LIKE 'resos_%_spend'
""")
)
spend_rows = spend_result.fetchall()
spend_settings = {row.config_key: float(row.config_value or 0) for row in spend_rows}
def get_spend_by_period(period: str, revenue_type: str) -> float:
if revenue_type == 'dry':
return spend_settings.get(f'resos_{period}_food_spend', 0) / VAT_RATE
elif revenue_type == 'wet':
return spend_settings.get(f'resos_{period}_drinks_spend', 0) / VAT_RATE
else:
food = spend_settings.get(f'resos_{period}_food_spend', 0)
drinks = spend_settings.get(f'resos_{period}_drinks_spend', 0)
return (food + drinks) / VAT_RATE
# Get actual revenue
actual_result = await db.execute(
text("""
SELECT date, accommodation, dry, wet
FROM newbook_net_revenue_data
WHERE date >= :start_date AND date <= :end_date
"""),
{"start_date": start, "end_date": end}
)
actual_by_date = {row.date: row for row in actual_result.fetchall()}
# Get prior year revenue
prior_start = get_prior_year_date(start)
prior_end = get_prior_year_date(end)
prior_result = await db.execute(
text("""
SELECT date, accommodation, dry, wet
FROM newbook_net_revenue_data
WHERE date >= :start_date AND date <= :end_date
"""),
{"start_date": prior_start, "end_date": prior_end}
)
prior_by_date = {row.date: row for row in prior_result.fetchall()}
# Get budgets
budget_types = []
if type in ['all', 'accom']:
budget_types.append('net_accom')
if type in ['all', 'dry']:
budget_types.append('net_dry')
if type in ['all', 'wet']:
budget_types.append('net_wet')
if type == 'all':
budget_types.append('total_rev')
budget_result = await db.execute(
text("""
SELECT date, budget_type, budget_value
FROM daily_budgets
WHERE date >= :start_date AND date <= :end_date
AND budget_type = ANY(:types)
"""),
{"start_date": start, "end_date": end, "types": budget_types if budget_types else ['']}
)
budget_data = {}
for row in budget_result.fetchall():
if row.date not in budget_data:
budget_data[row.date] = {}
budget_data[row.date][row.budget_type] = float(row.budget_value or 0)
# Get covers forecast for restaurant revenue
try:
covers_data = await forecast_covers_range(db, start, end, include_details=False)
covers_by_date = {c["date"]: c for c in covers_data.get("data", [])}
except Exception as e:
logger.warning(f"Covers forecast failed: {e}")
covers_by_date = {}
data = []
current = start
while current <= end:
is_past = current < today
lead_days = (current - today).days if current >= today else 0
prior_date = get_prior_year_date(current)
# Prior year actual
prior_row = prior_by_date.get(prior_date)
prior_accom = float(prior_row.accommodation) if prior_row and prior_row.accommodation else 0
prior_dry = float(prior_row.dry) if prior_row and prior_row.dry else 0
prior_wet = float(prior_row.wet) if prior_row and prior_row.wet else 0
# Budget
day_budget = budget_data.get(current, {})
budget_accom = day_budget.get('net_accom', 0)
budget_dry = day_budget.get('net_dry', 0)
budget_wet = day_budget.get('net_wet', 0)
budget_total = day_budget.get('total_rev', budget_accom + budget_dry + budget_wet)
if is_past:
# Past: use actual revenue
actual_row = actual_by_date.get(current)
accom_otb = float(actual_row.accommodation) if actual_row and actual_row.accommodation else 0
dry_otb = float(actual_row.dry) if actual_row and actual_row.dry else 0
wet_otb = float(actual_row.wet) if actual_row and actual_row.wet else 0
accom_forecast = accom_otb
dry_forecast = dry_otb
wet_forecast = wet_otb
accom_prior_otb = prior_accom
else:
# Future: forecast
# Accommodation - use revenue forecast model
try:
accom_forecast_data = await forecast_revenue_for_date(
db, current, lead_days, prior_date
)
accom_otb = accom_forecast_data.get('current_otb_rev', 0) or 0
accom_pickup = accom_forecast_data.get('forecast_pickup_rev', 0) or 0
accom_forecast = accom_otb + accom_pickup
accom_prior_otb = accom_forecast_data.get('prior_year_otb_rev', 0) or 0
except Exception:
accom_otb = accom_forecast = accom_prior_otb = 0
# Restaurant
day_covers = covers_by_date.get(current.isoformat())
if day_covers:
breakfast_otb = day_covers["breakfast"]["otb"]
lunch_otb = day_covers["lunch"]["otb"]
dinner_otb = day_covers["dinner"]["otb"]
breakfast_forecast = day_covers["breakfast"]["forecast"]
lunch_forecast = day_covers["lunch"]["forecast"]
dinner_forecast = day_covers["dinner"]["forecast"]
dry_otb = (
breakfast_otb * get_spend_by_period('breakfast', 'dry') +
lunch_otb * get_spend_by_period('lunch', 'dry') +
dinner_otb * get_spend_by_period('dinner', 'dry')
)
dry_forecast = (
breakfast_forecast * get_spend_by_period('breakfast', 'dry') +
lunch_forecast * get_spend_by_period('lunch', 'dry') +
dinner_forecast * get_spend_by_period('dinner', 'dry')
)
wet_otb = (
breakfast_otb * get_spend_by_period('breakfast', 'wet') +
lunch_otb * get_spend_by_period('lunch', 'wet') +
dinner_otb * get_spend_by_period('dinner', 'wet')
)
wet_forecast = (
breakfast_forecast * get_spend_by_period('breakfast', 'wet') +
lunch_forecast * get_spend_by_period('lunch', 'wet') +
dinner_forecast * get_spend_by_period('dinner', 'wet')
)
else:
dry_otb = dry_forecast = wet_otb = wet_forecast = 0
day_data = {
"date": current.isoformat(),
"day": current.strftime("%A"),
"is_past": is_past,
"lead_days": lead_days,
}
if type in ['all', 'accom']:
day_data["accom"] = {
"otb": round(accom_otb, 2),
"forecast": round(accom_forecast, 2),
"prior_otb": round(accom_prior_otb, 2) if not is_past else round(prior_accom, 2),
"prior_final": round(prior_accom, 2),
"budget": round(budget_accom, 2)
}
if type in ['all', 'dry']:
day_data["dry"] = {
"otb": round(dry_otb, 2),
"forecast": round(dry_forecast, 2),
"prior_final": round(prior_dry, 2),
"budget": round(budget_dry, 2)
}
if type in ['all', 'wet']:
day_data["wet"] = {
"otb": round(wet_otb, 2),
"forecast": round(wet_forecast, 2),
"prior_final": round(prior_wet, 2),
"budget": round(budget_wet, 2)
}
if type == 'all':
total_otb = accom_otb + dry_otb + wet_otb
total_forecast = accom_forecast + dry_forecast + wet_forecast
total_prior = prior_accom + prior_dry + prior_wet
day_data["total"] = {
"otb": round(total_otb, 2),
"forecast": round(total_forecast, 2),
"prior_final": round(total_prior, 2),
"budget": round(budget_total, 2)
}
data.append(day_data)
current += timedelta(days=1)
return {"data": data}
@router.get("/forecast/spend-rates")
async def get_spend_rates(
db: AsyncSession = Depends(get_db),
api_key: dict = Depends(get_api_key_auth)
):
"""
Return spend-per-cover rates for each meal period.
Values are gross (inc VAT) from system_config, plus the VAT rate.
Kitchen app can divide by VAT rate to get net values.
"""
VAT_RATE = 1.20
spend_result = await db.execute(
text("""
SELECT config_key, config_value
FROM system_config
WHERE config_key LIKE 'resos_%_spend'
""")
)
spend_rows = spend_result.fetchall()
spend_settings = {row.config_key: float(row.config_value or 0) for row in spend_rows}
return {
"vat_rate": VAT_RATE,
"periods": {
"breakfast": {
"food_spend_gross": spend_settings.get("resos_breakfast_food_spend", 0),
"drinks_spend_gross": spend_settings.get("resos_breakfast_drinks_spend", 0),
"food_spend_net": round(spend_settings.get("resos_breakfast_food_spend", 0) / VAT_RATE, 2),
"drinks_spend_net": round(spend_settings.get("resos_breakfast_drinks_spend", 0) / VAT_RATE, 2),
},
"lunch": {
"food_spend_gross": spend_settings.get("resos_lunch_food_spend", 0),
"drinks_spend_gross": spend_settings.get("resos_lunch_drinks_spend", 0),
"food_spend_net": round(spend_settings.get("resos_lunch_food_spend", 0) / VAT_RATE, 2),
"drinks_spend_net": round(spend_settings.get("resos_lunch_drinks_spend", 0) / VAT_RATE, 2),
},
"dinner": {
"food_spend_gross": spend_settings.get("resos_dinner_food_spend", 0),
"drinks_spend_gross": spend_settings.get("resos_dinner_drinks_spend", 0),
"food_spend_net": round(spend_settings.get("resos_dinner_food_spend", 0) / VAT_RATE, 2),
"drinks_spend_net": round(spend_settings.get("resos_dinner_drinks_spend", 0) / VAT_RATE, 2),
},
}
}

File diff suppressed because it is too large Load diff

927
backend/api/reports.py Normal file
View file

@ -0,0 +1,927 @@
"""
Reports API endpoints
Provide aggregated data for frontend reports and visualizations
"""
from datetime import date, datetime, timedelta
from typing import Optional, List, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
import calendar
from database import get_db
from auth import get_current_user
router = APIRouter()
# ============================================
# RESPONSE MODELS
# ============================================
class OccupancyDataPoint(BaseModel):
date: str
total_occupancy_pct: Optional[float] = None
bookable_occupancy_pct: Optional[float] = None
booking_count: int = 0
rooms_count: int = 0
bookable_count: int = 0
class BookingsDataPoint(BaseModel):
date: str
booking_count: int = 0
guests_count: int = 0
rooms_count: int = 0
class RatesDataPoint(BaseModel):
date: str
guest_rate_total: float = 0.0
net_booking_rev_total: float = 0.0
booking_count: int = 0
avg_guest_rate: Optional[float] = None
avg_net_rate: Optional[float] = None
class RevenueDataPoint(BaseModel):
date: str
accommodation: float = 0.0
dry: float = 0.0
wet: float = 0.0
total: float = 0.0
# ============================================
# OCCUPANCY REPORT ENDPOINT
# ============================================
@router.get("/occupancy", response_model=List[OccupancyDataPoint])
async def get_occupancy_report(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get occupancy data for the specified date range with optional consolidation.
- **day**: Returns daily data points
- **week**: Aggregates by week (Monday start)
- **month**: Aggregates by month
"""
# Validate dates
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if start > end:
raise HTTPException(status_code=400, detail="Start date must be before end date")
if consolidation not in ["day", "week", "month"]:
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
# Build the query based on consolidation type
# Use generate_series to include all dates in range, even those without data
if consolidation == "day":
query = text("""
SELECT
to_char(d, 'YYYY-MM-DD') as period_date,
COALESCE(s.total_occupancy_pct, 0) as total_occupancy_pct,
COALESCE(s.bookable_occupancy_pct, 0) as bookable_occupancy_pct,
COALESCE(s.booking_count, 0) as booking_count,
COALESCE(s.rooms_count, 0) as rooms_count,
COALESCE(s.bookable_count, 0) as bookable_count
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
LEFT JOIN newbook_bookings_stats s ON s.date = d
ORDER BY d
""")
elif consolidation == "week":
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
weekly_data AS (
SELECT
CAST(date_trunc('week', d) AS date) as week_start,
ROUND(CAST(AVG(COALESCE(s.total_occupancy_pct, 0)) AS numeric), 2) as total_occupancy_pct,
ROUND(CAST(AVG(COALESCE(s.bookable_occupancy_pct, 0)) AS numeric), 2) as bookable_occupancy_pct,
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count,
CAST(ROUND(AVG(COALESCE(s.bookable_count, 0))) AS integer) as bookable_count
FROM date_range dr
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('week', d)
)
SELECT
to_char(week_start, 'YYYY-MM-DD') as period_date,
total_occupancy_pct,
bookable_occupancy_pct,
booking_count,
rooms_count,
bookable_count
FROM weekly_data
ORDER BY week_start
""")
else: # month
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
monthly_data AS (
SELECT
CAST(date_trunc('month', d) AS date) as month_start,
ROUND(CAST(AVG(COALESCE(s.total_occupancy_pct, 0)) AS numeric), 2) as total_occupancy_pct,
ROUND(CAST(AVG(COALESCE(s.bookable_occupancy_pct, 0)) AS numeric), 2) as bookable_occupancy_pct,
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count,
CAST(ROUND(AVG(COALESCE(s.bookable_count, 0))) AS integer) as bookable_count
FROM date_range dr
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('month', d)
)
SELECT
to_char(month_start, 'YYYY-MM-DD') as period_date,
total_occupancy_pct,
bookable_occupancy_pct,
booking_count,
rooms_count,
bookable_count
FROM monthly_data
ORDER BY month_start
""")
result = await db.execute(query, {"start_date": start, "end_date": end})
rows = result.fetchall()
data_points = []
for row in rows:
data_points.append(OccupancyDataPoint(
date=row.period_date,
total_occupancy_pct=float(row.total_occupancy_pct) if row.total_occupancy_pct else None,
bookable_occupancy_pct=float(row.bookable_occupancy_pct) if row.bookable_occupancy_pct else None,
booking_count=row.booking_count or 0,
rooms_count=row.rooms_count or 0,
bookable_count=row.bookable_count or 0,
))
return data_points
# ============================================
# BOOKINGS REPORT ENDPOINT
# ============================================
@router.get("/bookings", response_model=List[BookingsDataPoint])
async def get_bookings_report(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get bookings and guests data for the specified date range with optional consolidation.
"""
# Validate dates
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if start > end:
raise HTTPException(status_code=400, detail="Start date must be before end date")
if consolidation not in ["day", "week", "month"]:
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
if consolidation == "day":
query = text("""
SELECT
to_char(d, 'YYYY-MM-DD') as period_date,
COALESCE(s.booking_count, 0) as booking_count,
COALESCE(s.guests_count, 0) as guests_count,
COALESCE(s.rooms_count, 0) as rooms_count
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
LEFT JOIN newbook_bookings_stats s ON s.date = d
ORDER BY d
""")
elif consolidation == "week":
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
weekly_data AS (
SELECT
CAST(date_trunc('week', d) AS date) as week_start,
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
CAST(SUM(COALESCE(s.guests_count, 0)) AS integer) as guests_count,
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count
FROM date_range dr
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('week', d)
)
SELECT
to_char(week_start, 'YYYY-MM-DD') as period_date,
booking_count,
guests_count,
rooms_count
FROM weekly_data
ORDER BY week_start
""")
else: # month
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
monthly_data AS (
SELECT
CAST(date_trunc('month', d) AS date) as month_start,
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
CAST(SUM(COALESCE(s.guests_count, 0)) AS integer) as guests_count,
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count
FROM date_range dr
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('month', d)
)
SELECT
to_char(month_start, 'YYYY-MM-DD') as period_date,
booking_count,
guests_count,
rooms_count
FROM monthly_data
ORDER BY month_start
""")
result = await db.execute(query, {"start_date": start, "end_date": end})
rows = result.fetchall()
data_points = []
for row in rows:
data_points.append(BookingsDataPoint(
date=row.period_date,
booking_count=row.booking_count or 0,
guests_count=row.guests_count or 0,
rooms_count=row.rooms_count or 0,
))
return data_points
# ============================================
# RATES REPORT ENDPOINT
# ============================================
@router.get("/rates", response_model=List[RatesDataPoint])
async def get_rates_report(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get guest rates data (gross tariff / calculated amount) for the specified date range.
"""
# Validate dates
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if start > end:
raise HTTPException(status_code=400, detail="Start date must be before end date")
if consolidation not in ["day", "week", "month"]:
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
if consolidation == "day":
query = text("""
SELECT
to_char(d, 'YYYY-MM-DD') as period_date,
COALESCE(s.guest_rate_total, 0) as guest_rate_total,
COALESCE(s.net_booking_rev_total, 0) as net_booking_rev_total,
COALESCE(s.booking_count, 0) as booking_count
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
LEFT JOIN newbook_bookings_stats s ON s.date = d
ORDER BY d
""")
elif consolidation == "week":
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
weekly_data AS (
SELECT
CAST(date_trunc('week', d) AS date) as week_start,
ROUND(CAST(SUM(COALESCE(s.guest_rate_total, 0)) AS numeric), 2) as guest_rate_total,
ROUND(CAST(SUM(COALESCE(s.net_booking_rev_total, 0)) AS numeric), 2) as net_booking_rev_total,
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count
FROM date_range dr
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('week', d)
)
SELECT
to_char(week_start, 'YYYY-MM-DD') as period_date,
guest_rate_total,
net_booking_rev_total,
booking_count
FROM weekly_data
ORDER BY week_start
""")
else: # month
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
monthly_data AS (
SELECT
CAST(date_trunc('month', d) AS date) as month_start,
ROUND(CAST(SUM(COALESCE(s.guest_rate_total, 0)) AS numeric), 2) as guest_rate_total,
ROUND(CAST(SUM(COALESCE(s.net_booking_rev_total, 0)) AS numeric), 2) as net_booking_rev_total,
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count
FROM date_range dr
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('month', d)
)
SELECT
to_char(month_start, 'YYYY-MM-DD') as period_date,
guest_rate_total,
net_booking_rev_total,
booking_count
FROM monthly_data
ORDER BY month_start
""")
result = await db.execute(query, {"start_date": start, "end_date": end})
rows = result.fetchall()
data_points = []
for row in rows:
guest_rate = float(row.guest_rate_total) if row.guest_rate_total else 0.0
net_rate = float(row.net_booking_rev_total) if row.net_booking_rev_total else 0.0
bookings = row.booking_count or 0
data_points.append(RatesDataPoint(
date=row.period_date,
guest_rate_total=guest_rate,
net_booking_rev_total=net_rate,
booking_count=bookings,
avg_guest_rate=round(guest_rate / bookings, 2) if bookings > 0 else None,
avg_net_rate=round(net_rate / bookings, 2) if bookings > 0 else None,
))
return data_points
# ============================================
# REVENUE REPORT ENDPOINT
# ============================================
@router.get("/revenue", response_model=List[RevenueDataPoint])
async def get_revenue_report(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get net revenue data (accommodation, dry, wet) for the specified date range.
"""
# Validate dates
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if start > end:
raise HTTPException(status_code=400, detail="Start date must be before end date")
if consolidation not in ["day", "week", "month"]:
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
if consolidation == "day":
query = text("""
SELECT
to_char(d, 'YYYY-MM-DD') as period_date,
COALESCE(r.accommodation, 0) as accommodation,
COALESCE(r.dry, 0) as dry,
COALESCE(r.wet, 0) as wet
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
LEFT JOIN newbook_net_revenue_data r ON r.date = d
ORDER BY d
""")
elif consolidation == "week":
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
weekly_data AS (
SELECT
CAST(date_trunc('week', d) AS date) as week_start,
ROUND(CAST(SUM(COALESCE(r.accommodation, 0)) AS numeric), 2) as accommodation,
ROUND(CAST(SUM(COALESCE(r.dry, 0)) AS numeric), 2) as dry,
ROUND(CAST(SUM(COALESCE(r.wet, 0)) AS numeric), 2) as wet
FROM date_range dr
LEFT JOIN newbook_net_revenue_data r ON r.date = dr.d
GROUP BY date_trunc('week', d)
)
SELECT
to_char(week_start, 'YYYY-MM-DD') as period_date,
accommodation,
dry,
wet
FROM weekly_data
ORDER BY week_start
""")
else: # month
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
monthly_data AS (
SELECT
CAST(date_trunc('month', d) AS date) as month_start,
ROUND(CAST(SUM(COALESCE(r.accommodation, 0)) AS numeric), 2) as accommodation,
ROUND(CAST(SUM(COALESCE(r.dry, 0)) AS numeric), 2) as dry,
ROUND(CAST(SUM(COALESCE(r.wet, 0)) AS numeric), 2) as wet
FROM date_range dr
LEFT JOIN newbook_net_revenue_data r ON r.date = dr.d
GROUP BY date_trunc('month', d)
)
SELECT
to_char(month_start, 'YYYY-MM-DD') as period_date,
accommodation,
dry,
wet
FROM monthly_data
ORDER BY month_start
""")
result = await db.execute(query, {"start_date": start, "end_date": end})
rows = result.fetchall()
data_points = []
for row in rows:
accom = float(row.accommodation) if row.accommodation else 0.0
dry = float(row.dry) if row.dry else 0.0
wet = float(row.wet) if row.wet else 0.0
data_points.append(RevenueDataPoint(
date=row.period_date,
accommodation=accom,
dry=dry,
wet=wet,
total=round(accom + dry + wet, 2),
))
return data_points
# ============================================
# RESTAURANT REPORTS
# ============================================
class ResosBookingsDataPoint(BaseModel):
date: str
total_bookings: int = 0
breakfast_bookings: int = 0
lunch_bookings: int = 0
afternoon_bookings: int = 0
dinner_bookings: int = 0
other_bookings: int = 0
class ResosCoversDataPoint(BaseModel):
date: str
total_covers: int = 0
breakfast_covers: int = 0
lunch_covers: int = 0
afternoon_covers: int = 0
dinner_covers: int = 0
other_covers: int = 0
hotel_guest_covers: int = 0
non_hotel_guest_covers: int = 0
@router.get("/restaurant-bookings", response_model=List[ResosBookingsDataPoint])
async def get_restaurant_bookings_report(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get restaurant bookings data for the specified date range with optional consolidation.
"""
# Validate dates
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if start > end:
raise HTTPException(status_code=400, detail="Start date must be before end date")
if consolidation not in ["day", "week", "month"]:
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
if consolidation == "day":
query = text("""
SELECT
to_char(d, 'YYYY-MM-DD') as period_date,
COALESCE(s.total_bookings, 0) as total_bookings,
COALESCE(s.breakfast_bookings, 0) as breakfast_bookings,
COALESCE(s.lunch_bookings, 0) as lunch_bookings,
COALESCE(s.afternoon_bookings, 0) as afternoon_bookings,
COALESCE(s.dinner_bookings, 0) as dinner_bookings,
COALESCE(s.other_bookings, 0) as other_bookings
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
LEFT JOIN resos_bookings_stats s ON s.date = d
ORDER BY d
""")
elif consolidation == "week":
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
weekly_data AS (
SELECT
CAST(date_trunc('week', d) AS date) as week_start,
CAST(SUM(COALESCE(s.total_bookings, 0)) AS integer) as total_bookings,
CAST(SUM(COALESCE(s.breakfast_bookings, 0)) AS integer) as breakfast_bookings,
CAST(SUM(COALESCE(s.lunch_bookings, 0)) AS integer) as lunch_bookings,
CAST(SUM(COALESCE(s.afternoon_bookings, 0)) AS integer) as afternoon_bookings,
CAST(SUM(COALESCE(s.dinner_bookings, 0)) AS integer) as dinner_bookings,
CAST(SUM(COALESCE(s.other_bookings, 0)) AS integer) as other_bookings
FROM date_range dr
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('week', d)
)
SELECT
to_char(week_start, 'YYYY-MM-DD') as period_date,
total_bookings, breakfast_bookings, lunch_bookings,
afternoon_bookings, dinner_bookings, other_bookings
FROM weekly_data
ORDER BY week_start
""")
else: # month
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
monthly_data AS (
SELECT
CAST(date_trunc('month', d) AS date) as month_start,
CAST(SUM(COALESCE(s.total_bookings, 0)) AS integer) as total_bookings,
CAST(SUM(COALESCE(s.breakfast_bookings, 0)) AS integer) as breakfast_bookings,
CAST(SUM(COALESCE(s.lunch_bookings, 0)) AS integer) as lunch_bookings,
CAST(SUM(COALESCE(s.afternoon_bookings, 0)) AS integer) as afternoon_bookings,
CAST(SUM(COALESCE(s.dinner_bookings, 0)) AS integer) as dinner_bookings,
CAST(SUM(COALESCE(s.other_bookings, 0)) AS integer) as other_bookings
FROM date_range dr
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('month', d)
)
SELECT
to_char(month_start, 'YYYY-MM-DD') as period_date,
total_bookings, breakfast_bookings, lunch_bookings,
afternoon_bookings, dinner_bookings, other_bookings
FROM monthly_data
ORDER BY month_start
""")
result = await db.execute(query, {"start_date": start, "end_date": end})
rows = result.fetchall()
data_points = []
for row in rows:
data_points.append(ResosBookingsDataPoint(
date=row.period_date,
total_bookings=row.total_bookings or 0,
breakfast_bookings=row.breakfast_bookings or 0,
lunch_bookings=row.lunch_bookings or 0,
afternoon_bookings=row.afternoon_bookings or 0,
dinner_bookings=row.dinner_bookings or 0,
other_bookings=row.other_bookings or 0,
))
return data_points
@router.get("/restaurant-covers", response_model=List[ResosCoversDataPoint])
async def get_restaurant_covers_report(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get restaurant covers (guests) data for the specified date range with optional consolidation.
"""
# Validate dates
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if start > end:
raise HTTPException(status_code=400, detail="Start date must be before end date")
if consolidation not in ["day", "week", "month"]:
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
if consolidation == "day":
query = text("""
SELECT
to_char(d, 'YYYY-MM-DD') as period_date,
COALESCE(s.total_covers, 0) as total_covers,
COALESCE(s.breakfast_covers, 0) as breakfast_covers,
COALESCE(s.lunch_covers, 0) as lunch_covers,
COALESCE(s.afternoon_covers, 0) as afternoon_covers,
COALESCE(s.dinner_covers, 0) as dinner_covers,
COALESCE(s.other_covers, 0) as other_covers,
COALESCE(s.hotel_guest_covers, 0) as hotel_guest_covers,
COALESCE(s.non_hotel_guest_covers, 0) as non_hotel_guest_covers
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
LEFT JOIN resos_bookings_stats s ON s.date = d
ORDER BY d
""")
elif consolidation == "week":
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
weekly_data AS (
SELECT
CAST(date_trunc('week', d) AS date) as week_start,
CAST(SUM(COALESCE(s.total_covers, 0)) AS integer) as total_covers,
CAST(SUM(COALESCE(s.breakfast_covers, 0)) AS integer) as breakfast_covers,
CAST(SUM(COALESCE(s.lunch_covers, 0)) AS integer) as lunch_covers,
CAST(SUM(COALESCE(s.afternoon_covers, 0)) AS integer) as afternoon_covers,
CAST(SUM(COALESCE(s.dinner_covers, 0)) AS integer) as dinner_covers,
CAST(SUM(COALESCE(s.other_covers, 0)) AS integer) as other_covers,
CAST(SUM(COALESCE(s.hotel_guest_covers, 0)) AS integer) as hotel_guest_covers,
CAST(SUM(COALESCE(s.non_hotel_guest_covers, 0)) AS integer) as non_hotel_guest_covers
FROM date_range dr
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('week', d)
)
SELECT
to_char(week_start, 'YYYY-MM-DD') as period_date,
total_covers, breakfast_covers, lunch_covers,
afternoon_covers, dinner_covers, other_covers,
hotel_guest_covers, non_hotel_guest_covers
FROM weekly_data
ORDER BY week_start
""")
else: # month
query = text("""
WITH date_range AS (
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
),
monthly_data AS (
SELECT
CAST(date_trunc('month', d) AS date) as month_start,
CAST(SUM(COALESCE(s.total_covers, 0)) AS integer) as total_covers,
CAST(SUM(COALESCE(s.breakfast_covers, 0)) AS integer) as breakfast_covers,
CAST(SUM(COALESCE(s.lunch_covers, 0)) AS integer) as lunch_covers,
CAST(SUM(COALESCE(s.afternoon_covers, 0)) AS integer) as afternoon_covers,
CAST(SUM(COALESCE(s.dinner_covers, 0)) AS integer) as dinner_covers,
CAST(SUM(COALESCE(s.other_covers, 0)) AS integer) as other_covers,
CAST(SUM(COALESCE(s.hotel_guest_covers, 0)) AS integer) as hotel_guest_covers,
CAST(SUM(COALESCE(s.non_hotel_guest_covers, 0)) AS integer) as non_hotel_guest_covers
FROM date_range dr
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
GROUP BY date_trunc('month', d)
)
SELECT
to_char(month_start, 'YYYY-MM-DD') as period_date,
total_covers, breakfast_covers, lunch_covers,
afternoon_covers, dinner_covers, other_covers,
hotel_guest_covers, non_hotel_guest_covers
FROM monthly_data
ORDER BY month_start
""")
result = await db.execute(query, {"start_date": start, "end_date": end})
rows = result.fetchall()
data_points = []
for row in rows:
data_points.append(ResosCoversDataPoint(
date=row.period_date,
total_covers=row.total_covers or 0,
breakfast_covers=row.breakfast_covers or 0,
lunch_covers=row.lunch_covers or 0,
afternoon_covers=row.afternoon_covers or 0,
dinner_covers=row.dinner_covers or 0,
other_covers=row.other_covers or 0,
hotel_guest_covers=row.hotel_guest_covers or 0,
non_hotel_guest_covers=row.non_hotel_guest_covers or 0,
))
return data_points
# ============================================
# 3D PICKUP VISUALIZATION ENDPOINT
# ============================================
class Pickup3DResponse(BaseModel):
"""Response model for 3D pickup visualization data"""
start_date: str
end_date: str
metric: str
consolidation: str
arrival_dates: List[str] # X-axis: dates in range
lead_times: List[int] # Y-axis: lead times (days out)
surface_data: List[List[Optional[float]]] # Z-axis: [lead_time][arrival_date] values
final_values: List[Optional[float]] # Final values (d0) for each arrival date
@router.get("/pickup-3d", response_model=Pickup3DResponse)
async def get_pickup_3d_data(
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
metric: str = Query("rooms", description="Metric: rooms or occupancy"),
consolidation: str = Query("day", description="Consolidation: day or week"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get 3D pickup visualization data for a date range.
Returns booking pace data structured for a 3D surface plot:
- X-axis: Arrival dates in the range
- Y-axis: Lead time (days before arrival when booking count was recorded)
- Z-axis: Room count or occupancy percentage
This visualizes how bookings accumulated over time for each arrival date.
"""
if metric not in ["rooms", "occupancy"]:
raise HTTPException(status_code=400, detail="Metric must be 'rooms' or 'occupancy'")
if consolidation not in ["day", "week"]:
raise HTTPException(status_code=400, detail="Consolidation must be 'day' or 'week'")
# Parse dates
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if start > end:
raise HTTPException(status_code=400, detail="Start date must be before end date")
# Define the lead times we want to show
# We'll show a reasonable subset to keep the visualization manageable
# Daily for d0-d30, weekly for d30-d90, monthly beyond that
lead_time_columns = [
# Daily (0-30 days)
'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7',
'd8', 'd9', 'd10', 'd11', 'd12', 'd13', 'd14',
'd15', 'd16', 'd17', 'd18', 'd19', 'd20', 'd21',
'd22', 'd23', 'd24', 'd25', 'd26', 'd27', 'd28', 'd29', 'd30',
# Weekly (37-93 days)
'd37', 'd44', 'd51', 'd58', 'd65', 'd72', 'd79', 'd86', 'd93',
# Further out (100+ days)
'd100', 'd107', 'd114', 'd121', 'd128', 'd135', 'd142', 'd149',
'd156', 'd163', 'd170', 'd177',
# Monthly intervals
'd210', 'd240', 'd270', 'd300', 'd330', 'd365',
]
# Build select clause for available columns
select_cols = ", ".join([f"COALESCE({col}, 0) as {col}" for col in lead_time_columns])
query = text(f"""
SELECT
arrival_date,
{select_cols}
FROM newbook_booking_pace
WHERE arrival_date >= :start_date AND arrival_date <= :end_date
ORDER BY arrival_date
""")
result = await db.execute(query, {"start_date": start, "end_date": end})
rows = result.fetchall()
# Generate all dates in range
all_dates = []
current = start
while current <= end:
all_dates.append(current.strftime("%Y-%m-%d"))
current += timedelta(days=1)
if not rows:
# Return empty structure if no data
lead_times = [int(col[1:]) for col in lead_time_columns]
return Pickup3DResponse(
start_date=start_date,
end_date=end_date,
metric=metric,
consolidation=consolidation,
arrival_dates=all_dates,
lead_times=lead_times,
surface_data=[[None] * len(all_dates) for _ in lead_times],
final_values=[None] * len(all_dates)
)
# Get total rooms for occupancy calculation
total_rooms = 27 # Hotel Number Four has 27 rooms
# Build the surface data
row_data_dict = {}
for row in rows:
arr_date = row.arrival_date.strftime("%Y-%m-%d")
row_data_dict[arr_date] = {col: getattr(row, col, 0) or 0 for col in lead_time_columns}
# Handle weekly consolidation
if consolidation == "week":
# Group dates by week (Monday start)
weekly_dates = []
weekly_data = {}
current = start
week_start = None
while current <= end:
# Get Monday of this week
days_since_monday = current.weekday()
monday = current - timedelta(days=days_since_monday)
week_label = monday.strftime("%Y-%m-%d")
if week_label not in weekly_data:
weekly_dates.append(week_label)
weekly_data[week_label] = {col: [] for col in lead_time_columns}
date_str = current.strftime("%Y-%m-%d")
if date_str in row_data_dict:
for col in lead_time_columns:
weekly_data[week_label][col].append(row_data_dict[date_str].get(col, 0) or 0)
current += timedelta(days=1)
# Average the weekly data
all_dates = weekly_dates
row_data_dict = {}
for week_label in weekly_dates:
row_data_dict[week_label] = {}
for col in lead_time_columns:
values = weekly_data[week_label][col]
if values:
row_data_dict[week_label][col] = sum(values) / len(values)
else:
row_data_dict[week_label][col] = 0
# Build surface_data: [lead_time_index][arrival_date_index]
lead_times = [int(col[1:]) for col in lead_time_columns]
surface_data = []
for col in lead_time_columns:
lead_row = []
for arr_date in all_dates:
if arr_date in row_data_dict:
value = row_data_dict[arr_date].get(col, 0) or 0
if metric == "occupancy":
# Convert to occupancy percentage
value = round((value / total_rooms) * 100, 1) if total_rooms > 0 else 0
lead_row.append(value)
else:
lead_row.append(None)
surface_data.append(lead_row)
# Get final values (d0) for each arrival date
final_values = []
for arr_date in all_dates:
if arr_date in row_data_dict:
value = row_data_dict[arr_date].get('d0', 0) or 0
if metric == "occupancy":
value = round((value / total_rooms) * 100, 1) if total_rooms > 0 else 0
final_values.append(value)
else:
final_values.append(None)
return Pickup3DResponse(
start_date=start_date,
end_date=end_date,
metric=metric,
consolidation=consolidation,
arrival_dates=all_dates,
lead_times=lead_times,
surface_data=surface_data,
final_values=final_values
)

718
backend/api/resos.py Normal file
View file

@ -0,0 +1,718 @@
"""
Resos Mapping API Endpoints
Handles Resos custom field and opening hours configuration.
"""
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
from services.resos_client import ResosClient, ResosAPIError
logger = logging.getLogger(__name__)
router = APIRouter()
# ============ Pydantic Schemas ============
class CustomFieldMappingItem(BaseModel):
field_id: str
field_name: str
field_type: str
maps_to: str # 'hotel_guest', 'dbb', 'booking_number', 'allergies', 'ignore'
value_for_true: Optional[str] = None # For radio/checkbox: which value means "yes"
class OpeningHoursMappingItem(BaseModel):
opening_hour_id: str
opening_hour_name: str
period_type: str # 'lunch', 'afternoon', 'dinner', 'ignore'
is_regular: bool = True
class MappingUpdate(BaseModel):
custom_fields: Optional[List[CustomFieldMappingItem]] = None
opening_hours: Optional[List[OpeningHoursMappingItem]] = None
# ============ Custom Fields Endpoints ============
@router.get("/custom-fields")
async def fetch_custom_fields(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Fetch custom field definitions from Resos API.
Returns fields that can be mapped to hotel_guest, dbb, etc.
"""
try:
async with await ResosClient.from_db(db) as client:
fields = await client.get_custom_field_definitions()
except ResosAPIError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to fetch Resos custom fields: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch custom fields from Resos")
# Return all custom fields (no filtering by type - let user decide what to map)
formatted_fields = [
{
"id": f.get("_id") or f.get("id"),
"name": f.get("name", ""),
"type": f.get("type", ""),
"values": f.get("choices", []) # For radio/dropdown fields
}
for f in fields
]
return formatted_fields
@router.get("/opening-hours")
async def fetch_opening_hours(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Fetch opening hours/service periods from Resos API.
Filters to regular (non-special) periods only.
"""
try:
async with await ResosClient.from_db(db) as client:
hours = await client.get_opening_hours()
except ResosAPIError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to fetch Resos opening hours: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch opening hours from Resos")
logger.info(f"Raw opening hours from Resos API: {len(hours)} periods")
# Filter out special/one-off periods - only return regular service periods
# special=True means one-off events, special=False means recurring
regular_hours = [h for h in hours if h.get('special') == False]
# Day of week mapping (Resos uses 1=Monday, 7=Sunday)
day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
# Transform and add helpful fields
formatted_hours = []
for hour in regular_hours:
hour_id = hour.get('_id') or hour.get('id', '')
hour_name = hour.get('name', '')
day_num = hour.get('day', 0)
# Convert open/close times from HHMM integers to HH:MM strings
start_time = None
end_time = None
if 'open' in hour:
open_val = hour['open']
hours_part = open_val // 100
mins_part = open_val % 100
start_time = f"{hours_part:02d}:{mins_part:02d}"
if 'close' in hour:
close_val = hour['close']
hours_part = close_val // 100
mins_part = close_val % 100
end_time = f"{hours_part:02d}:{mins_part:02d}"
formatted_hours.append({
"id": hour_id,
"name": hour_name,
"day": day_num,
"day_name": day_names[day_num] if 1 <= day_num <= 7 else "Unknown",
"start_time": start_time,
"end_time": end_time
})
# Sort by day of week first, then by open time
formatted_hours.sort(key=lambda h: (h.get('day', 0), h.get('start_time', '')))
logger.info(f"After filtering: {len(formatted_hours)} regular periods (filtered out {len(hours) - len(formatted_hours)} special periods)")
return formatted_hours
# ============ Mapping Storage Endpoints ============
@router.get("/custom-field-mapping")
async def get_custom_field_mappings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get saved custom field mappings."""
cf_result = await db.execute(text("""
SELECT field_id, field_name, field_type, maps_to, value_for_true
FROM resos_custom_field_mapping
ORDER BY field_name
"""))
custom_field_rows = cf_result.fetchall()
return [
{
"custom_field_id": row.field_id,
"mapping_type": row.maps_to,
"value_for_true": row.value_for_true
}
for row in custom_field_rows
]
@router.get("/opening-hours-mapping")
async def get_opening_hours_mappings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get saved opening hours mappings."""
oh_result = await db.execute(text("""
SELECT opening_hour_id, opening_hour_name, period_type, display_name, is_regular
FROM resos_opening_hours_mapping
ORDER BY opening_hour_name
"""))
opening_hour_rows = oh_result.fetchall()
return [
{
"opening_hour_id": row.opening_hour_id,
"opening_hour_name": row.opening_hour_name,
"period_type": row.period_type,
"display_name": row.display_name,
"is_regular": row.is_regular
}
for row in opening_hour_rows
]
@router.get("/manual-breakfast-periods")
async def get_manual_breakfast_periods(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get manual breakfast period configuration."""
# Get enabled flag
result = await db.execute(text("""
SELECT config_value FROM system_config WHERE config_key = 'resos_enable_manual_breakfast'
"""))
row = result.fetchone()
enabled = row.config_value.lower() == 'true' if row and row.config_value else False
# Get periods
result = await db.execute(text("""
SELECT day_of_week, start_time, end_time, is_active
FROM resos_manual_breakfast_periods
ORDER BY day_of_week
"""))
rows = result.fetchall()
periods = [
{
"day_of_week": row.day_of_week,
"start_time": str(row.start_time) if row.start_time else None,
"end_time": str(row.end_time) if row.end_time else None,
"is_active": row.is_active
}
for row in rows
]
return {
"enabled": enabled,
"periods": periods
}
@router.post("/custom-field-mapping")
async def save_custom_field_mappings(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save custom field mappings."""
mappings = data.get("mappings", [])
saved = 0
for mapping in mappings:
field_id = mapping.get("custom_field_id")
mapping_type = mapping.get("mapping_type")
value_for_true = mapping.get("value_for_true")
if not field_id or not mapping_type:
continue
await db.execute(text("""
INSERT INTO resos_custom_field_mapping
(field_id, field_name, field_type, maps_to, value_for_true, updated_at)
VALUES
(:field_id, '', '', :maps_to, :value_for_true, NOW())
ON CONFLICT (field_id) DO UPDATE SET
maps_to = EXCLUDED.maps_to,
value_for_true = EXCLUDED.value_for_true,
updated_at = NOW()
"""), {
"field_id": field_id,
"maps_to": mapping_type,
"value_for_true": value_for_true
})
saved += 1
await db.commit()
logger.info(f"Saved {saved} custom field mappings")
return {
"message": "Custom field mappings saved successfully",
"saved": saved
}
@router.post("/opening-hours-mapping")
async def save_opening_hours_mappings(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save opening hours mappings."""
mappings = data.get("mappings", [])
saved = 0
for mapping in mappings:
opening_hour_id = mapping.get("opening_hour_id")
period_type = mapping.get("period_type")
display_name = mapping.get("display_name")
if not opening_hour_id or not period_type:
continue
await db.execute(text("""
INSERT INTO resos_opening_hours_mapping
(opening_hour_id, opening_hour_name, period_type, display_name, is_regular, updated_at)
VALUES
(:opening_hour_id, '', :period_type, :display_name, TRUE, NOW())
ON CONFLICT (opening_hour_id) DO UPDATE SET
period_type = EXCLUDED.period_type,
display_name = EXCLUDED.display_name,
updated_at = NOW()
"""), {
"opening_hour_id": opening_hour_id,
"period_type": period_type,
"display_name": display_name
})
saved += 1
await db.commit()
logger.info(f"Saved {saved} opening hours mappings")
return {
"message": "Opening hours mappings saved successfully",
"saved": saved
}
@router.post("/manual-breakfast-periods")
async def save_manual_breakfast_periods(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save manual breakfast period configuration."""
from datetime import time as dt_time
enabled = data.get("enabled", False)
periods = data.get("periods", [])
# Save enabled flag to system_config
await db.execute(text("""
INSERT INTO system_config (config_key, config_value, description, updated_at)
VALUES ('resos_enable_manual_breakfast', :value, 'Enable manual breakfast configuration', NOW())
ON CONFLICT (config_key) DO UPDATE SET
config_value = EXCLUDED.config_value,
updated_at = NOW()
"""), {"value": str(enabled).lower()})
# Save periods
for period in periods:
# Convert time strings to Python time objects for asyncpg
start_time_str = period.get("start_time")
end_time_str = period.get("end_time")
start_time_obj = None
end_time_obj = None
if start_time_str:
hour, minute = map(int, start_time_str.split(':'))
start_time_obj = dt_time(hour, minute)
if end_time_str:
hour, minute = map(int, end_time_str.split(':'))
end_time_obj = dt_time(hour, minute)
await db.execute(text("""
INSERT INTO resos_manual_breakfast_periods
(day_of_week, start_time, end_time, is_active, updated_at)
VALUES
(:day_of_week, :start_time, :end_time, :is_active, NOW())
ON CONFLICT (day_of_week) DO UPDATE SET
start_time = EXCLUDED.start_time,
end_time = EXCLUDED.end_time,
is_active = EXCLUDED.is_active,
updated_at = NOW()
"""), {
"day_of_week": period["day_of_week"],
"start_time": start_time_obj,
"end_time": end_time_obj,
"is_active": period.get("is_active", True)
})
await db.commit()
return {"message": "Manual breakfast periods saved successfully"}
@router.get("/mapping")
async def get_mappings(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get saved custom field and opening hours mappings.
"""
# Get custom field mappings
cf_result = await db.execute(text("""
SELECT field_id, field_name, field_type, maps_to, value_for_true
FROM resos_custom_field_mapping
ORDER BY field_name
"""))
custom_field_rows = cf_result.fetchall()
# Get opening hours mappings
oh_result = await db.execute(text("""
SELECT opening_hour_id, opening_hour_name, period_type, is_regular
FROM resos_opening_hours_mapping
ORDER BY opening_hour_name
"""))
opening_hour_rows = oh_result.fetchall()
return {
"custom_fields": [
{
"field_id": row.field_id,
"field_name": row.field_name,
"field_type": row.field_type,
"maps_to": row.maps_to,
"value_for_true": row.value_for_true
}
for row in custom_field_rows
],
"opening_hours": [
{
"opening_hour_id": row.opening_hour_id,
"opening_hour_name": row.opening_hour_name,
"period_type": row.period_type,
"is_regular": row.is_regular
}
for row in opening_hour_rows
]
}
@router.post("/mapping")
async def save_mappings(
update: MappingUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Save custom field and opening hours mappings.
Uses upsert to update existing or insert new mappings.
"""
saved_cf = 0
saved_oh = 0
# Save custom field mappings
if update.custom_fields:
for cf in update.custom_fields:
await db.execute(text("""
INSERT INTO resos_custom_field_mapping
(field_id, field_name, field_type, maps_to, value_for_true, updated_at)
VALUES
(:field_id, :field_name, :field_type, :maps_to, :value_for_true, NOW())
ON CONFLICT (field_id) DO UPDATE SET
field_name = EXCLUDED.field_name,
field_type = EXCLUDED.field_type,
maps_to = EXCLUDED.maps_to,
value_for_true = EXCLUDED.value_for_true,
updated_at = NOW()
"""), {
"field_id": cf.field_id,
"field_name": cf.field_name,
"field_type": cf.field_type,
"maps_to": cf.maps_to,
"value_for_true": cf.value_for_true
})
saved_cf += 1
# Save opening hours mappings
if update.opening_hours:
for oh in update.opening_hours:
await db.execute(text("""
INSERT INTO resos_opening_hours_mapping
(opening_hour_id, opening_hour_name, period_type, is_regular, updated_at)
VALUES
(:opening_hour_id, :opening_hour_name, :period_type, :is_regular, NOW())
ON CONFLICT (opening_hour_id) DO UPDATE SET
opening_hour_name = EXCLUDED.opening_hour_name,
period_type = EXCLUDED.period_type,
is_regular = EXCLUDED.is_regular,
updated_at = NOW()
"""), {
"opening_hour_id": oh.opening_hour_id,
"opening_hour_name": oh.opening_hour_name,
"period_type": oh.period_type,
"is_regular": oh.is_regular
})
saved_oh += 1
await db.commit()
logger.info(f"Saved {saved_cf} custom field mappings, {saved_oh} opening hours mappings")
return {
"message": "Mappings saved successfully",
"custom_fields_saved": saved_cf,
"opening_hours_saved": saved_oh
}
@router.delete("/mapping/custom-field/{field_id}")
async def delete_custom_field_mapping(
field_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete a custom field mapping."""
await db.execute(text("""
DELETE FROM resos_custom_field_mapping WHERE field_id = :field_id
"""), {"field_id": field_id})
await db.commit()
return {"message": "Mapping deleted"}
@router.delete("/mapping/opening-hour/{opening_hour_id}")
async def delete_opening_hour_mapping(
opening_hour_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete an opening hours mapping."""
await db.execute(text("""
DELETE FROM resos_opening_hours_mapping WHERE opening_hour_id = :opening_hour_id
"""), {"opening_hour_id": opening_hour_id})
await db.commit()
return {"message": "Mapping deleted"}
# ============ Average Spend Settings Endpoints ============
@router.get("/average-spend")
async def get_average_spend(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get average spend settings for breakfast, lunch and dinner."""
# Get values from system_config
result = await db.execute(text("""
SELECT config_key, config_value FROM system_config
WHERE config_key IN (
'resos_breakfast_food_spend',
'resos_breakfast_drinks_spend',
'resos_lunch_food_spend',
'resos_lunch_drinks_spend',
'resos_dinner_food_spend',
'resos_dinner_drinks_spend'
)
"""))
rows = result.fetchall()
settings = {}
for row in rows:
key = row.config_key.replace('resos_', '')
try:
settings[key] = float(row.config_value) if row.config_value else 0
except (ValueError, TypeError):
settings[key] = 0
return {
"breakfast_food_spend": settings.get('breakfast_food_spend', 0),
"breakfast_drinks_spend": settings.get('breakfast_drinks_spend', 0),
"lunch_food_spend": settings.get('lunch_food_spend', 0),
"lunch_drinks_spend": settings.get('lunch_drinks_spend', 0),
"dinner_food_spend": settings.get('dinner_food_spend', 0),
"dinner_drinks_spend": settings.get('dinner_drinks_spend', 0)
}
@router.post("/average-spend")
async def save_average_spend(
data: dict,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Save average spend settings for breakfast, lunch and dinner."""
settings = [
('resos_breakfast_food_spend', data.get('breakfast_food_spend', 0), 'Average food spend per cover for breakfast'),
('resos_breakfast_drinks_spend', data.get('breakfast_drinks_spend', 0), 'Average drinks spend per cover for breakfast'),
('resos_lunch_food_spend', data.get('lunch_food_spend', 0), 'Average food spend per cover for lunch'),
('resos_lunch_drinks_spend', data.get('lunch_drinks_spend', 0), 'Average drinks spend per cover for lunch'),
('resos_dinner_food_spend', data.get('dinner_food_spend', 0), 'Average food spend per cover for dinner'),
('resos_dinner_drinks_spend', data.get('dinner_drinks_spend', 0), 'Average drinks spend per cover for dinner'),
]
for config_key, config_value, description in settings:
await db.execute(text("""
INSERT INTO system_config (config_key, config_value, description, updated_at)
VALUES (:config_key, :config_value, :description, NOW())
ON CONFLICT (config_key) DO UPDATE SET
config_value = EXCLUDED.config_value,
updated_at = NOW()
"""), {
"config_key": config_key,
"config_value": str(config_value),
"description": description
})
await db.commit()
logger.info("Saved average spend settings")
return {"message": "Average spend settings saved successfully"}
# ============ Pace Data Backfill Endpoint ============
@router.post("/backfill-pace")
async def backfill_pace_data(
start_date: str,
end_date: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Backfill resos_booking_pace table for historical dates.
This calculates pace snapshots using booking_placed timestamps
to reconstruct what was on the books at each lead time.
Args:
start_date: Start date (YYYY-MM-DD)
end_date: End date (YYYY-MM-DD)
"""
from datetime import datetime, timedelta
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
except ValueError:
return {"error": "Invalid date format. Use YYYY-MM-DD"}
if (end - start).days > 400:
return {"error": "Date range too large. Max 400 days at a time."}
# Valid statuses and pace intervals
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left')
PACE_INTERVALS = [
365, 330, 300, 270, 240, 210,
177, 170, 163, 156, 149, 142, 135, 128, 121, 114,
107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37,
30, 29, 28, 27, 26, 25, 24, 23, 22, 21,
20, 19, 18, 17, 16, 15, 14, 13, 12, 11,
10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0
]
dates_processed = 0
current = start
while current <= end:
for pace_type in ['total', 'resident', 'non_resident']:
pace_values = {}
for days_out in PACE_INTERVALS:
snapshot_date = current - timedelta(days=days_out)
# Build query based on pace_type
if pace_type == 'total':
result = await db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN ('approved', 'arrived', 'seated', 'left')
AND booking_placed <= :snapshot_date
"""),
{"target_date": current, "snapshot_date": snapshot_date}
)
elif pace_type == 'resident':
result = await db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN ('approved', 'arrived', 'seated', 'left')
AND booking_placed <= :snapshot_date
AND is_hotel_guest = true
"""),
{"target_date": current, "snapshot_date": snapshot_date}
)
else: # non_resident
result = await db.execute(
text("""
SELECT COALESCE(SUM(covers), 0) as total_covers
FROM resos_bookings_data
WHERE booking_date = :target_date
AND status IN ('approved', 'arrived', 'seated', 'left')
AND booking_placed <= :snapshot_date
AND (is_hotel_guest = false OR is_hotel_guest IS NULL)
"""),
{"target_date": current, "snapshot_date": snapshot_date}
)
row = result.fetchone()
pace_values[f"d{days_out}"] = row.total_covers if row else 0
# Upsert pace record
columns = ", ".join(pace_values.keys())
placeholders = ", ".join([f":{k}" for k in pace_values.keys()])
updates = ", ".join([f"{k} = :{k}" for k in pace_values.keys()])
await db.execute(
text(f"""
INSERT INTO resos_booking_pace (booking_date, pace_type, {columns}, updated_at)
VALUES (:booking_date, :pace_type, {placeholders}, NOW())
ON CONFLICT (booking_date, pace_type) DO UPDATE SET
{updates},
updated_at = NOW()
"""),
{"booking_date": current, "pace_type": pace_type, **pace_values}
)
dates_processed += 1
current += timedelta(days=1)
# Commit every 30 days to avoid large transactions
if dates_processed % 30 == 0:
await db.commit()
logger.info(f"Backfill progress: {dates_processed} dates processed")
await db.commit()
logger.info(f"Pace backfill completed: {dates_processed} dates from {start_date} to {end_date}")
return {
"message": f"Pace data backfilled successfully",
"dates_processed": dates_processed,
"start_date": start_date,
"end_date": end_date
}

244
backend/api/resos_sync.py Normal file
View file

@ -0,0 +1,244 @@
"""
Resos Bookings Sync API Endpoints
Pattern: Similar to sync_bookings.py but for Resos
"""
import logging
from datetime import date, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
class ResosSyncConfig(BaseModel):
"""Auto sync configuration for Resos bookings"""
auto_sync_enabled: bool
sync_time: str = "05:05" # HH:MM format
@router.get("/resos-bookings/status")
async def get_resos_sync_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get sync status for Resos bookings data including last sync info."""
# Get last successful sync
result = await db.execute(
text("""
SELECT id, sync_type, started_at, completed_at, status,
records_fetched, records_created, records_updated,
date_from, date_to, error_message, triggered_by
FROM sync_log
WHERE source = 'resos' AND sync_type = 'bookings_data'
AND status = 'success'
ORDER BY completed_at DESC
LIMIT 1
""")
)
last_success = result.fetchone()
# Get last sync (any status)
result = await db.execute(
text("""
SELECT id, sync_type, started_at, completed_at, status,
records_fetched, records_created, records_updated,
date_from, date_to, error_message, triggered_by
FROM sync_log
WHERE source = 'resos' AND sync_type = 'bookings_data'
ORDER BY started_at DESC
LIMIT 1
""")
)
last_sync = result.fetchone()
# Get auto sync config
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_enabled'")
)
row = result.fetchone()
auto_enabled = row.config_value.lower() == 'true' if row and row.config_value else False
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:05'
# Get total records
result = await db.execute(text("SELECT COUNT(*) as count FROM resos_bookings_data"))
total_records = result.fetchone().count
# Get date range
result = await db.execute(
text("SELECT MIN(booking_date) as min_date, MAX(booking_date) as max_date FROM resos_bookings_data")
)
date_range = result.fetchone()
return {
"last_successful_sync": {
"completed_at": last_success.completed_at.isoformat() if last_success and last_success.completed_at else None,
"records_fetched": last_success.records_fetched if last_success else None,
"records_created": last_success.records_created if last_success else None,
"records_updated": last_success.records_updated if last_success else None,
"date_from": last_success.date_from.isoformat() if last_success and last_success.date_from else None,
"date_to": last_success.date_to.isoformat() if last_success and last_success.date_to else None,
"triggered_by": last_success.triggered_by if last_success else None,
} if last_success else None,
"last_sync": {
"started_at": last_sync.started_at.isoformat() if last_sync and last_sync.started_at else None,
"completed_at": last_sync.completed_at.isoformat() if last_sync and last_sync.completed_at else None,
"status": last_sync.status if last_sync else None,
"records_fetched": last_sync.records_fetched if last_sync else None,
"date_from": last_sync.date_from.isoformat() if last_sync and last_sync.date_from else None,
"date_to": last_sync.date_to.isoformat() if last_sync and last_sync.date_to else None,
"error_message": last_sync.error_message if last_sync else None,
"triggered_by": last_sync.triggered_by if last_sync else None,
} if last_sync else None,
"auto_sync": {
"enabled": auto_enabled,
"time": sync_time
},
"total_records": total_records,
"data_range": {
"from": date_range.min_date.isoformat() if date_range and date_range.min_date else None,
"to": date_range.max_date.isoformat() if date_range and date_range.max_date else None
}
}
@router.post("/resos-bookings/sync")
async def trigger_resos_sync(
background_tasks: BackgroundTasks,
from_date: Optional[date] = Query(None, description="Start date (default: today - 365)"),
to_date: Optional[date] = Query(None, description="End date (default: today + 365)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger a Resos bookings sync.
Default: -365 to +365 days (historical + forecast window)
"""
if not from_date:
from_date = date.today() - timedelta(days=365)
if not to_date:
to_date = date.today() + timedelta(days=365)
# Queue background task
background_tasks.add_task(
run_resos_sync_task,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"from_date": from_date.isoformat(),
"to_date": to_date.isoformat(),
"message": f"Resos bookings sync started for {from_date} to {to_date}"
}
@router.get("/resos-bookings/config")
async def get_resos_sync_config(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Get auto sync configuration for Resos bookings."""
# Get enabled setting
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_enabled'")
)
row = result.fetchone()
enabled = row.config_value.lower() == 'true' if row and row.config_value else False
# Get sync time setting
result = await db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_time'")
)
row = result.fetchone()
sync_time = row.config_value if row and row.config_value else '05:05'
return {
"auto_sync_enabled": enabled,
"sync_time": sync_time
}
@router.post("/resos-bookings/config")
async def update_resos_sync_config(
config: ResosSyncConfig,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update auto sync configuration for Resos bookings."""
# Upsert enabled setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_resos_bookings_enabled', :value, 'Enable automatic Resos bookings sync', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": str(config.auto_sync_enabled).lower(), "user": current_user['username']}
)
# Upsert sync time setting
await db.execute(
text("""
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
VALUES ('sync_resos_bookings_time', :value, 'Resos bookings sync time (HH:MM)', NOW(), :user)
ON CONFLICT (config_key) DO UPDATE SET
config_value = :value,
updated_at = NOW(),
updated_by = :user
"""),
{"value": config.sync_time, "user": current_user['username']}
)
await db.commit()
return {
"status": "success",
"auto_sync_enabled": config.auto_sync_enabled,
"sync_time": config.sync_time
}
def run_resos_sync_task(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""Background task to run Resos sync."""
import sys
import asyncio
from jobs.resos_bookings_sync import sync_resos_bookings_data
print(f"[SYNC-RESOS] Starting sync ({from_date} to {to_date})", flush=True)
sys.stdout.flush()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
sync_resos_bookings_data(from_date, to_date, triggered_by)
)
print(f"[SYNC-RESOS] Sync completed", flush=True)
except Exception as e:
print(f"[SYNC-RESOS] FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
raise
finally:
loop.close()

View file

@ -0,0 +1,571 @@
"""
Special Dates API - Configure custom holidays/events for Prophet forecasting
"""
from datetime import date, datetime, timedelta
from typing import Optional, List
from enum import Enum
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
from database import get_db
from auth import get_current_user
router = APIRouter()
# ============================================
# MODELS
# ============================================
class DatePatternType(str, Enum):
FIXED = "fixed" # Fixed date each year (e.g., Feb 14)
NTH_WEEKDAY = "nth_weekday" # Nth weekday of month (e.g., 2nd Monday of Feb)
RELATIVE_TO_DATE = "relative_to_date" # Weekday before/after a fixed date
class SpecialDateBase(BaseModel):
name: str
pattern_type: DatePatternType
# For FIXED pattern
fixed_month: Optional[int] = None # 1-12
fixed_day: Optional[int] = None # 1-31
# For NTH_WEEKDAY pattern
nth_week: Optional[int] = None # 1-5 or -1 for last
weekday: Optional[int] = None # 0=Mon, 6=Sun
month: Optional[int] = None # 1-12
# For RELATIVE_TO_DATE pattern
relative_to_month: Optional[int] = None
relative_to_day: Optional[int] = None
relative_weekday: Optional[int] = None # Which weekday to find
relative_direction: Optional[str] = None # 'before' or 'after'
# Common fields
duration_days: int = 1
is_recurring: bool = True
one_off_year: Optional[int] = None # Only if is_recurring = False
is_active: bool = True
class SpecialDateCreate(SpecialDateBase):
pass
class SpecialDateUpdate(SpecialDateBase):
pass
class SpecialDateResponse(SpecialDateBase):
id: int
created_at: str
class ResolvedDate(BaseModel):
name: str
date: str
day_of_week: str
# ============================================
# TABLE CREATION
# ============================================
async def ensure_table_exists(db: AsyncSession):
"""Create the special_dates table if it doesn't exist"""
await db.execute(text("""
CREATE TABLE IF NOT EXISTS special_dates (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
pattern_type VARCHAR(20) NOT NULL,
fixed_month INTEGER,
fixed_day INTEGER,
nth_week INTEGER,
weekday INTEGER,
month INTEGER,
relative_to_month INTEGER,
relative_to_day INTEGER,
relative_weekday INTEGER,
relative_direction VARCHAR(10),
duration_days INTEGER DEFAULT 1,
is_recurring BOOLEAN DEFAULT TRUE,
one_off_year INTEGER,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
await db.commit()
# ============================================
# DATE RESOLUTION HELPERS
# ============================================
def get_nth_weekday_of_month(year: int, month: int, weekday: int, nth: int) -> Optional[date]:
"""
Get the nth occurrence of a weekday in a month.
nth: 1-5 for first through fifth, -1 for last
weekday: 0=Monday, 6=Sunday
"""
if nth == -1:
# Last occurrence - start from end of month
if month == 12:
next_month = date(year + 1, 1, 1)
else:
next_month = date(year, month + 1, 1)
last_day = next_month - timedelta(days=1)
# Find the last occurrence of the weekday
days_back = (last_day.weekday() - weekday) % 7
return last_day - timedelta(days=days_back)
else:
# Nth occurrence from start
first_of_month = date(year, month, 1)
# Find the first occurrence of the weekday
days_ahead = (weekday - first_of_month.weekday()) % 7
first_occurrence = first_of_month + timedelta(days=days_ahead)
# Add weeks to get to nth occurrence
result = first_occurrence + timedelta(weeks=nth - 1)
# Verify it's still in the same month
if result.month != month:
return None
return result
def get_weekday_relative_to_date(year: int, month: int, day: int,
target_weekday: int, direction: str) -> Optional[date]:
"""
Get a specific weekday before or after a fixed date.
target_weekday: 0=Monday, 6=Sunday
direction: 'before' or 'after'
"""
try:
base_date = date(year, month, day)
except ValueError:
return None
if direction == 'before':
# Find the weekday before (or on) the base date
days_back = (base_date.weekday() - target_weekday) % 7
if days_back == 0:
days_back = 7 # If same weekday, go back a week
return base_date - timedelta(days=days_back)
else: # after
# Find the weekday after the base date
days_ahead = (target_weekday - base_date.weekday()) % 7
if days_ahead == 0:
days_ahead = 7 # If same weekday, go forward a week
return base_date + timedelta(days=days_ahead)
def resolve_special_date(sd: dict, year: int) -> List[date]:
"""Resolve a special date pattern to actual dates for a given year"""
if not sd['is_recurring'] and sd.get('one_off_year') and sd['one_off_year'] != year:
return []
base_date = None
pattern_type = sd['pattern_type']
if pattern_type == 'fixed':
try:
base_date = date(year, sd['fixed_month'], sd['fixed_day'])
except (ValueError, TypeError):
return []
elif pattern_type == 'nth_weekday':
base_date = get_nth_weekday_of_month(
year, sd['month'], sd['weekday'], sd['nth_week']
)
elif pattern_type == 'relative_to_date':
base_date = get_weekday_relative_to_date(
year, sd['relative_to_month'], sd['relative_to_day'],
sd['relative_weekday'], sd['relative_direction']
)
if base_date is None:
return []
# Generate dates for duration
duration = sd.get('duration_days', 1) or 1
return [base_date + timedelta(days=i) for i in range(duration)]
# ============================================
# CRUD ENDPOINTS
# ============================================
@router.get("/special-dates", response_model=List[SpecialDateResponse])
async def list_special_dates(
active_only: bool = Query(False, description="Only return active dates"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""List all special date configurations"""
await ensure_table_exists(db)
query = "SELECT * FROM special_dates"
if active_only:
query += " WHERE is_active = TRUE"
query += " ORDER BY name"
result = await db.execute(text(query))
rows = result.fetchall()
return [
SpecialDateResponse(
id=row.id,
name=row.name,
pattern_type=row.pattern_type,
fixed_month=row.fixed_month,
fixed_day=row.fixed_day,
nth_week=row.nth_week,
weekday=row.weekday,
month=row.month,
relative_to_month=row.relative_to_month,
relative_to_day=row.relative_to_day,
relative_weekday=row.relative_weekday,
relative_direction=row.relative_direction,
duration_days=row.duration_days or 1,
is_recurring=row.is_recurring,
one_off_year=row.one_off_year,
is_active=row.is_active,
created_at=str(row.created_at)
)
for row in rows
]
@router.post("/special-dates", response_model=SpecialDateResponse)
async def create_special_date(
data: SpecialDateCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Create a new special date configuration"""
await ensure_table_exists(db)
result = await db.execute(text("""
INSERT INTO special_dates (
name, pattern_type, fixed_month, fixed_day, nth_week, weekday, month,
relative_to_month, relative_to_day, relative_weekday, relative_direction,
duration_days, is_recurring, one_off_year, is_active
) VALUES (
:name, :pattern_type, :fixed_month, :fixed_day, :nth_week, :weekday, :month,
:relative_to_month, :relative_to_day, :relative_weekday, :relative_direction,
:duration_days, :is_recurring, :one_off_year, :is_active
) RETURNING id, created_at
"""), {
"name": data.name,
"pattern_type": data.pattern_type.value,
"fixed_month": data.fixed_month,
"fixed_day": data.fixed_day,
"nth_week": data.nth_week,
"weekday": data.weekday,
"month": data.month,
"relative_to_month": data.relative_to_month,
"relative_to_day": data.relative_to_day,
"relative_weekday": data.relative_weekday,
"relative_direction": data.relative_direction,
"duration_days": data.duration_days,
"is_recurring": data.is_recurring,
"one_off_year": data.one_off_year,
"is_active": data.is_active
})
row = result.fetchone()
await db.commit()
return SpecialDateResponse(
id=row.id,
created_at=str(row.created_at),
**data.dict()
)
@router.put("/special-dates/{date_id}", response_model=SpecialDateResponse)
async def update_special_date(
date_id: int,
data: SpecialDateUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Update a special date configuration"""
result = await db.execute(text("""
UPDATE special_dates SET
name = :name,
pattern_type = :pattern_type,
fixed_month = :fixed_month,
fixed_day = :fixed_day,
nth_week = :nth_week,
weekday = :weekday,
month = :month,
relative_to_month = :relative_to_month,
relative_to_day = :relative_to_day,
relative_weekday = :relative_weekday,
relative_direction = :relative_direction,
duration_days = :duration_days,
is_recurring = :is_recurring,
one_off_year = :one_off_year,
is_active = :is_active,
updated_at = NOW()
WHERE id = :id
RETURNING id, created_at
"""), {
"id": date_id,
"name": data.name,
"pattern_type": data.pattern_type.value,
"fixed_month": data.fixed_month,
"fixed_day": data.fixed_day,
"nth_week": data.nth_week,
"weekday": data.weekday,
"month": data.month,
"relative_to_month": data.relative_to_month,
"relative_to_day": data.relative_to_day,
"relative_weekday": data.relative_weekday,
"relative_direction": data.relative_direction,
"duration_days": data.duration_days,
"is_recurring": data.is_recurring,
"one_off_year": data.one_off_year,
"is_active": data.is_active
})
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Special date not found")
await db.commit()
return SpecialDateResponse(
id=row.id,
created_at=str(row.created_at),
**data.dict()
)
@router.delete("/special-dates/{date_id}")
async def delete_special_date(
date_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Delete a special date configuration"""
result = await db.execute(
text("DELETE FROM special_dates WHERE id = :id RETURNING id"),
{"id": date_id}
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Special date not found")
await db.commit()
return {"message": "Special date deleted successfully"}
@router.get("/special-dates/preview", response_model=List[ResolvedDate])
async def preview_special_dates(
year: int = Query(..., description="Year to preview dates for"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Preview resolved dates for a given year"""
await ensure_table_exists(db)
result = await db.execute(text(
"SELECT * FROM special_dates WHERE is_active = TRUE ORDER BY name"
))
rows = result.fetchall()
resolved = []
for row in rows:
sd = {
'pattern_type': row.pattern_type,
'fixed_month': row.fixed_month,
'fixed_day': row.fixed_day,
'nth_week': row.nth_week,
'weekday': row.weekday,
'month': row.month,
'relative_to_month': row.relative_to_month,
'relative_to_day': row.relative_to_day,
'relative_weekday': row.relative_weekday,
'relative_direction': row.relative_direction,
'duration_days': row.duration_days,
'is_recurring': row.is_recurring,
'one_off_year': row.one_off_year
}
dates = resolve_special_date(sd, year)
for d in dates:
resolved.append(ResolvedDate(
name=row.name,
date=str(d),
day_of_week=d.strftime("%a")
))
# Sort by date
resolved.sort(key=lambda x: x.date)
return resolved
@router.post("/special-dates/seed-defaults")
async def seed_default_dates(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""Seed default special dates (Valentine's, Christmas Eve, NYE, Bank Holiday weekends)"""
await ensure_table_exists(db)
# Check if already seeded
result = await db.execute(text("SELECT COUNT(*) as count FROM special_dates"))
if result.fetchone().count > 0:
return {"message": "Special dates already exist. Delete existing to re-seed."}
defaults = [
# Valentine's Day
{
"name": "Valentine's Day",
"pattern_type": "fixed",
"fixed_month": 2,
"fixed_day": 14,
"duration_days": 1
},
# Christmas Eve
{
"name": "Christmas Eve",
"pattern_type": "fixed",
"fixed_month": 12,
"fixed_day": 24,
"duration_days": 1
},
# New Year's Eve
{
"name": "New Year's Eve",
"pattern_type": "fixed",
"fixed_month": 12,
"fixed_day": 31,
"duration_days": 1
},
# Friday before Christmas (if Christmas is Sat-Tue)
{
"name": "Friday Before Christmas",
"pattern_type": "relative_to_date",
"relative_to_month": 12,
"relative_to_day": 25,
"relative_weekday": 4, # Friday
"relative_direction": "before",
"duration_days": 1
},
# Saturday before Christmas
{
"name": "Saturday Before Christmas",
"pattern_type": "relative_to_date",
"relative_to_month": 12,
"relative_to_day": 25,
"relative_weekday": 5, # Saturday
"relative_direction": "before",
"duration_days": 1
},
# Early May Bank Holiday Weekend (Fri-Sat of first Monday in May)
{
"name": "Early May BH Friday",
"pattern_type": "nth_weekday",
"month": 5,
"nth_week": 1,
"weekday": 4, # Friday (before the Monday)
"duration_days": 1
},
# Spring Bank Holiday Weekend (last Monday of May)
{
"name": "Spring BH Weekend",
"pattern_type": "nth_weekday",
"month": 5,
"nth_week": -1, # Last week
"weekday": 5, # Saturday
"duration_days": 2 # Sat + Sun (Mon is the bank holiday itself)
},
# August Bank Holiday Weekend (last Monday of August)
{
"name": "August BH Weekend",
"pattern_type": "nth_weekday",
"month": 8,
"nth_week": -1,
"weekday": 5, # Saturday
"duration_days": 2
},
]
for d in defaults:
await db.execute(text("""
INSERT INTO special_dates (
name, pattern_type, fixed_month, fixed_day, nth_week, weekday, month,
relative_to_month, relative_to_day, relative_weekday, relative_direction,
duration_days, is_recurring, is_active
) VALUES (
:name, :pattern_type, :fixed_month, :fixed_day, :nth_week, :weekday, :month,
:relative_to_month, :relative_to_day, :relative_weekday, :relative_direction,
:duration_days, TRUE, TRUE
)
"""), {
"name": d["name"],
"pattern_type": d["pattern_type"],
"fixed_month": d.get("fixed_month"),
"fixed_day": d.get("fixed_day"),
"nth_week": d.get("nth_week"),
"weekday": d.get("weekday"),
"month": d.get("month"),
"relative_to_month": d.get("relative_to_month"),
"relative_to_day": d.get("relative_to_day"),
"relative_weekday": d.get("relative_weekday"),
"relative_direction": d.get("relative_direction"),
"duration_days": d.get("duration_days", 1)
})
await db.commit()
return {"message": f"Seeded {len(defaults)} default special dates"}
# ============================================
# HELPER FOR PROPHET INTEGRATION
# ============================================
async def get_special_dates_for_prophet(db: AsyncSession, start_year: int, end_year: int) -> List[dict]:
"""
Get all special dates resolved for a range of years, formatted for Prophet.
Returns list of dicts with 'ds' (date) and 'holiday' (name) columns.
"""
await ensure_table_exists(db)
result = await db.execute(text(
"SELECT * FROM special_dates WHERE is_active = TRUE"
))
rows = result.fetchall()
prophet_holidays = []
for year in range(start_year, end_year + 1):
for row in rows:
sd = {
'pattern_type': row.pattern_type,
'fixed_month': row.fixed_month,
'fixed_day': row.fixed_day,
'nth_week': row.nth_week,
'weekday': row.weekday,
'month': row.month,
'relative_to_month': row.relative_to_month,
'relative_to_day': row.relative_to_day,
'relative_weekday': row.relative_weekday,
'relative_direction': row.relative_direction,
'duration_days': row.duration_days,
'is_recurring': row.is_recurring,
'one_off_year': row.one_off_year
}
dates = resolve_special_date(sd, year)
for d in dates:
# Prophet expects datetime objects, not date objects
prophet_holidays.append({
'ds': datetime.combine(d, datetime.min.time()),
'holiday': row.name
})
return prophet_holidays

888
backend/api/sync.py Normal file
View file

@ -0,0 +1,888 @@
"""
Data Sync API endpoints
"""
import uuid
import logging
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from pydantic import BaseModel
from database import get_db, SyncSessionLocal
from auth import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
class BackfillRequest(BaseModel):
"""Request model for backfill job"""
source: str # 'newbook', 'resos', or 'all'
from_date: date
to_date: date
chunk_months: int = 1 # Process in monthly chunks to avoid timeouts
@router.get("/status")
async def get_sync_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get last sync times and status for all data sources and sync types.
Returns dict keyed by "{source}_{sync_type}" (e.g., "newbook_bookings", "newbook_earned_revenue")
"""
query = """
SELECT DISTINCT ON (source, sync_type)
source,
sync_type,
completed_at as last_sync,
status as last_status,
records_fetched as last_records,
records_created as last_created,
date_from,
date_to
FROM sync_log
ORDER BY source, sync_type, completed_at DESC
"""
result = await db.execute(text(query))
rows = result.fetchall()
response = {}
for row in rows:
# Create key like "newbook_bookings" or "newbook_earned_revenue"
key = f"{row.source}_{row.sync_type}" if row.sync_type else row.source
response[key] = {
"source": row.source,
"sync_type": row.sync_type,
"last_sync": row.last_sync,
"status": row.last_status,
"records_fetched": row.last_records,
"records_created": row.last_created,
"date_from": row.date_from,
"date_to": row.date_to
}
return response
@router.post("/newbook")
async def trigger_newbook_sync(
background_tasks: BackgroundTasks,
full_sync: bool = Query(False, description="If True, fetches all bookings. If False, only fetches since last sync."),
from_date: Optional[date] = Query(None, description="Start date for stay period (filters by arrival/stay dates)"),
to_date: Optional[date] = Query(None, description="End date for stay period (filters by arrival/stay dates)"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger manual Newbook data sync.
Runs in background to avoid timeout.
- full_sync=False (default): Incremental sync - only bookings modified since last successful sync
- full_sync=True: Full sync - fetches entire booking database
- from_date/to_date: If provided, fetches bookings staying during this period (overrides full_sync)
"""
from jobs.data_sync import sync_newbook_data
# Queue background task
background_tasks.add_task(
sync_newbook_data,
full_sync=full_sync,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
msg = "Newbook sync started in background"
if from_date and to_date:
msg = f"Newbook sync for {from_date} to {to_date} started in background"
elif full_sync:
msg = "Newbook full sync started in background"
else:
msg = "Newbook incremental sync started in background"
return {
"status": "started",
"source": "newbook",
"full_sync": full_sync,
"from_date": from_date,
"to_date": to_date,
"message": msg
}
@router.post("/resos")
async def trigger_resos_sync(
background_tasks: BackgroundTasks,
from_date: Optional[date] = Query(None, description="Start date for sync"),
to_date: Optional[date] = Query(None, description="End date for sync"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger manual Resos data sync.
Runs in background to avoid timeout.
"""
from jobs.data_sync import sync_resos_data
if from_date is None:
from_date = date.today() - timedelta(days=7)
if to_date is None:
to_date = date.today() + timedelta(days=365)
# Queue background task
background_tasks.add_task(
sync_resos_data,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"source": "resos",
"from_date": from_date,
"to_date": to_date,
"message": "Resos sync started in background"
}
@router.post("/newbook/occupancy-report")
async def trigger_occupancy_report_sync(
background_tasks: BackgroundTasks,
from_date: Optional[date] = Query(None, description="Start date for report"),
to_date: Optional[date] = Query(None, description="End date for report"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger Newbook occupancy report sync.
This fetches Newbook's official occupancy report which provides:
- Available rooms (accounting for maintenance/offline)
- Official occupied room counts
- Maintenance/offline room counts
- Official revenue figures (gross and net)
Use this to ensure accurate occupancy % calculations when rooms
have been taken offline for maintenance.
"""
from jobs.data_sync import sync_newbook_occupancy_report
if from_date is None:
from_date = date.today() - timedelta(days=90)
if to_date is None:
to_date = date.today() + timedelta(days=30)
background_tasks.add_task(
sync_newbook_occupancy_report,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"source": "newbook_occupancy_report",
"from_date": from_date,
"to_date": to_date,
"message": f"Newbook occupancy report sync started for {from_date} to {to_date}"
}
@router.post("/newbook/earned-revenue")
async def trigger_earned_revenue_sync(
background_tasks: BackgroundTasks,
from_date: Optional[date] = Query(None, description="Start date for revenue"),
to_date: Optional[date] = Query(None, description="End date for revenue"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger Newbook earned revenue sync.
This fetches official financial figures by GL account from Newbook's
report_earned_revenue endpoint. Uses accommodation_gl_codes config
to identify which GL accounts are room revenue.
Defaults to last 7 days if no dates specified (catches adjustments).
For historical backfill, specify a wider date range.
"""
from jobs.data_sync import sync_newbook_earned_revenue
if from_date is None:
from_date = date.today() - timedelta(days=7)
if to_date is None:
to_date = date.today()
background_tasks.add_task(
sync_newbook_earned_revenue,
from_date=from_date,
to_date=to_date,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"source": "newbook_earned_revenue",
"from_date": from_date,
"to_date": to_date,
"message": f"Newbook earned revenue sync started for {from_date} to {to_date}"
}
@router.post("/full")
async def trigger_full_sync(
background_tasks: BackgroundTasks,
full_sync: bool = Query(False, description="If True, fetches all bookings from both sources"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger full sync from all sources (Newbook bookings, Newbook occupancy report, Resos).
"""
from jobs.data_sync import run_data_sync
background_tasks.add_task(
run_data_sync,
full_sync=full_sync,
triggered_by=f"user:{current_user['username']}"
)
return {
"status": "started",
"sources": ["newbook", "newbook_occupancy_report", "resos"],
"full_sync": full_sync,
"message": f"Full {'complete' if full_sync else 'incremental'} sync started in background"
}
@router.post("/aggregate")
async def trigger_aggregation(
background_tasks: BackgroundTasks,
source: Optional[str] = Query(None, description="Filter by source: newbook, resos. Leave empty for all."),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Trigger manual aggregation of pending dates.
Processes the aggregation queue and updates daily_occupancy/daily_covers tables.
"""
from jobs.aggregation import run_aggregation
if source and source not in ['newbook', 'resos']:
raise HTTPException(status_code=400, detail="Source must be 'newbook', 'resos', or omitted for all")
background_tasks.add_task(
run_aggregation,
source=source
)
return {
"status": "started",
"source": source or "all",
"message": f"Aggregation started for {source or 'all sources'}"
}
@router.post("/aggregate/requeue")
async def requeue_for_aggregation(
background_tasks: BackgroundTasks,
source: str = Query(..., description="Source to requeue: newbook or resos"),
from_date: Optional[date] = Query(None, description="Start date (optional, defaults to all)"),
to_date: Optional[date] = Query(None, description="End date (optional, defaults to all)"),
run_aggregation_after: bool = Query(True, description="Automatically run aggregation after queuing"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Re-queue dates from raw data for aggregation.
Useful when you want to re-aggregate existing data (e.g., after changing
mappings or fixing bugs in aggregation logic).
"""
if source not in ['newbook', 'resos']:
raise HTTPException(status_code=400, detail="Source must be 'newbook' or 'resos'")
# Get distinct dates from raw data
if source == 'resos':
date_query = "SELECT DISTINCT booking_date as date FROM resos_bookings WHERE 1=1"
else: # newbook
date_query = """
SELECT DISTINCT stay_date as date
FROM newbook_booking_nights bn
JOIN newbook_bookings b ON bn.booking_id = b.id
WHERE 1=1
"""
params = {}
if from_date:
if source == 'resos':
date_query += " AND booking_date >= :from_date"
else:
date_query += " AND stay_date >= :from_date"
params["from_date"] = from_date
if to_date:
if source == 'resos':
date_query += " AND booking_date <= :to_date"
else:
date_query += " AND stay_date <= :to_date"
params["to_date"] = to_date
date_query += " ORDER BY date"
result = await db.execute(text(date_query), params)
dates = [row.date for row in result.fetchall()]
if not dates:
return {
"status": "no_data",
"message": f"No dates found in {source} raw data for the specified range"
}
# Insert dates into queue (delete existing pending entries first, then insert)
# Clear any existing pending entries for these dates
await db.execute(
text("""
DELETE FROM aggregation_queue
WHERE source = :source
AND aggregated_at IS NULL
"""),
{"source": source}
)
# Insert all dates
for d in dates:
await db.execute(
text("""
INSERT INTO aggregation_queue (date, source, reason, queued_at)
VALUES (:date, :source, 'manual_requeue', NOW())
"""),
{"date": d, "source": source}
)
await db.commit()
# Optionally trigger aggregation
if run_aggregation_after:
from jobs.aggregation import run_aggregation as do_aggregation
background_tasks.add_task(do_aggregation, source=source)
return {
"status": "queued",
"source": source,
"dates_queued": len(dates),
"date_range": f"{min(dates)} to {max(dates)}",
"aggregation_started": run_aggregation_after,
"message": f"Queued {len(dates)} dates for {source} aggregation"
}
@router.get("/aggregate/status")
async def get_aggregation_status(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get aggregation status summary including pending counts and totals.
"""
# Get pending counts by source
pending_query = """
SELECT
source,
COUNT(*) as pending_count,
MIN(date) as earliest_date,
MAX(date) as latest_date
FROM aggregation_queue
WHERE aggregated_at IS NULL
GROUP BY source
"""
result = await db.execute(text(pending_query))
pending_rows = result.fetchall()
pending_by_source = {
row.source: {
"count": row.pending_count,
"earliest": row.earliest_date,
"latest": row.latest_date
}
for row in pending_rows
}
# Get total pending count
total_pending_result = await db.execute(
text("SELECT COUNT(*) as total FROM aggregation_queue WHERE aggregated_at IS NULL")
)
total_pending = total_pending_result.fetchone().total
# Get aggregated totals
occupancy_result = await db.execute(
text("SELECT COUNT(*) as count, MIN(date) as earliest, MAX(date) as latest FROM daily_occupancy")
)
occupancy_row = occupancy_result.fetchone()
covers_result = await db.execute(
text("SELECT COUNT(*) as count, MIN(date) as earliest, MAX(date) as latest FROM daily_covers")
)
covers_row = covers_result.fetchone()
# Get last aggregation timestamp (from most recent processed queue entry)
last_agg_result = await db.execute(
text("SELECT MAX(aggregated_at) as last_run FROM aggregation_queue WHERE aggregated_at IS NOT NULL")
)
last_agg_row = last_agg_result.fetchone()
return {
"pending": {
"total": total_pending,
"by_source": pending_by_source
},
"aggregated": {
"daily_occupancy": {
"count": occupancy_row.count if occupancy_row else 0,
"earliest": occupancy_row.earliest if occupancy_row else None,
"latest": occupancy_row.latest if occupancy_row else None
},
"daily_covers": {
"count": covers_row.count if covers_row else 0,
"earliest": covers_row.earliest if covers_row else None,
"latest": covers_row.latest if covers_row else None
}
},
"last_aggregation": last_agg_row.last_run if last_agg_row else None
}
@router.get("/aggregate/queue")
async def get_aggregation_queue(
source: Optional[str] = Query(None, description="Filter by source"),
pending_only: bool = Query(True, description="Only show pending (un-aggregated) entries"),
limit: int = Query(100, description="Max entries to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
View the aggregation queue status.
"""
query = """
SELECT date, source, reason, booking_id, queued_at, aggregated_at
FROM aggregation_queue
WHERE 1=1
"""
params = {"limit": limit}
if source:
query += " AND source = :source"
params["source"] = source
if pending_only:
query += " AND aggregated_at IS NULL"
query += " ORDER BY date, source LIMIT :limit"
result = await db.execute(text(query), params)
rows = result.fetchall()
return {
"count": len(rows),
"entries": [
{
"date": row.date,
"source": row.source,
"reason": row.reason,
"booking_id": row.booking_id,
"queued_at": row.queued_at,
"aggregated_at": row.aggregated_at
}
for row in rows
]
}
@router.get("/logs")
async def get_sync_logs(
source: Optional[str] = Query(None, description="Filter by source: newbook, resos"),
limit: int = Query(20, description="Number of logs to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get recent sync logs for monitoring.
"""
query = """
SELECT
id,
sync_type,
source,
started_at,
completed_at,
status,
records_fetched,
records_created,
records_updated,
date_from,
date_to,
error_message,
triggered_by
FROM sync_log
"""
params = {"limit": limit}
if source:
query += " WHERE source = :source"
params["source"] = source
query += " ORDER BY started_at DESC LIMIT :limit"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"id": row.id,
"sync_type": row.sync_type,
"source": row.source,
"started_at": row.started_at,
"completed_at": row.completed_at,
"status": row.status,
"records_fetched": row.records_fetched,
"records_created": row.records_created,
"records_updated": row.records_updated,
"date_range": f"{row.date_from} to {row.date_to}" if row.date_from else None,
"error_message": row.error_message,
"triggered_by": row.triggered_by
}
for row in rows
]
# ============================================
# HISTORICAL BACKFILL ENDPOINTS
# ============================================
@router.post("/backfill")
async def start_backfill(
request: BackfillRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Start a historical data backfill job.
Processes data in monthly chunks to avoid API timeouts and rate limits.
Progress can be monitored via GET /sync/backfill/status/{job_id}
"""
if request.source not in ['newbook', 'resos', 'all']:
raise HTTPException(status_code=400, detail="Source must be 'newbook', 'resos', or 'all'")
if request.from_date >= request.to_date:
raise HTTPException(status_code=400, detail="from_date must be before to_date")
# Calculate number of chunks
total_months = (request.to_date.year - request.from_date.year) * 12 + \
(request.to_date.month - request.from_date.month) + 1
chunks_total = (total_months + request.chunk_months - 1) // request.chunk_months
# Create backfill job record
job_id = str(uuid.uuid4())
await db.execute(
text("""
INSERT INTO backfill_jobs (
job_id, source, from_date, to_date, chunk_months,
status, chunks_total, triggered_by, created_at
) VALUES (
:job_id, :source, :from_date, :to_date, :chunk_months,
'pending', :chunks_total, :triggered_by, NOW()
)
"""),
{
"job_id": job_id,
"source": request.source,
"from_date": request.from_date,
"to_date": request.to_date,
"chunk_months": request.chunk_months,
"chunks_total": chunks_total,
"triggered_by": f"user:{current_user['username']}"
}
)
await db.commit()
# Queue background task
background_tasks.add_task(
run_backfill_job,
job_id=job_id,
source=request.source,
from_date=request.from_date,
to_date=request.to_date,
chunk_months=request.chunk_months
)
return {
"status": "started",
"job_id": job_id,
"source": request.source,
"from_date": request.from_date,
"to_date": request.to_date,
"chunk_months": request.chunk_months,
"chunks_total": chunks_total,
"message": f"Backfill job started. Monitor progress at /sync/backfill/status/{job_id}"
}
@router.get("/backfill/status/{job_id}")
async def get_backfill_status(
job_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
Get status of a backfill job.
"""
result = await db.execute(
text("""
SELECT
job_id, source, from_date, to_date, chunk_months,
status, current_chunk_start, current_chunk_end,
chunks_total, chunks_completed, records_total,
error_message, started_at, completed_at, triggered_by
FROM backfill_jobs
WHERE job_id = :job_id
"""),
{"job_id": job_id}
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Backfill job not found")
progress_pct = 0
if row.chunks_total and row.chunks_total > 0:
progress_pct = round((row.chunks_completed / row.chunks_total) * 100, 1)
return {
"job_id": row.job_id,
"source": row.source,
"date_range": f"{row.from_date} to {row.to_date}",
"chunk_months": row.chunk_months,
"status": row.status,
"progress": {
"current_chunk": f"{row.current_chunk_start} to {row.current_chunk_end}" if row.current_chunk_start else None,
"chunks_completed": row.chunks_completed,
"chunks_total": row.chunks_total,
"percent_complete": progress_pct,
"records_synced": row.records_total
},
"error_message": row.error_message,
"started_at": row.started_at,
"completed_at": row.completed_at,
"triggered_by": row.triggered_by
}
@router.get("/backfill/jobs")
async def list_backfill_jobs(
status: Optional[str] = Query(None, description="Filter by status: pending, running, completed, failed"),
limit: int = Query(20, description="Number of jobs to return"),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user)
):
"""
List recent backfill jobs.
"""
query = """
SELECT
job_id, source, from_date, to_date, status,
chunks_completed, chunks_total, records_total,
started_at, completed_at
FROM backfill_jobs
"""
params = {"limit": limit}
if status:
query += " WHERE status = :status"
params["status"] = status
query += " ORDER BY created_at DESC LIMIT :limit"
result = await db.execute(text(query), params)
rows = result.fetchall()
return [
{
"job_id": row.job_id,
"source": row.source,
"date_range": f"{row.from_date} to {row.to_date}",
"status": row.status,
"progress": f"{row.chunks_completed}/{row.chunks_total} chunks",
"records_synced": row.records_total,
"started_at": row.started_at,
"completed_at": row.completed_at
}
for row in rows
]
async def run_backfill_job(
job_id: str,
source: str,
from_date: date,
to_date: date,
chunk_months: int
):
"""
Background task to run backfill in chunks.
For Newbook: Uses modified_since/modified_until to backfill booking data by modification date.
For Resos: Uses from_date/to_date to backfill by booking date.
"""
from jobs.data_sync import sync_resos_data
from services.newbook_client import NewbookClient
import json
db = SyncSessionLocal()
try:
# Mark job as running
db.execute(
text("""
UPDATE backfill_jobs
SET status = 'running', started_at = NOW()
WHERE job_id = :job_id
"""),
{"job_id": job_id}
)
db.commit()
# Process in chunks
current_start = from_date
chunks_completed = 0
total_records = 0
while current_start <= to_date:
# Calculate chunk end date
current_end = current_start + relativedelta(months=chunk_months) - timedelta(days=1)
if current_end > to_date:
current_end = to_date
logger.info(f"Backfill {job_id}: Processing {current_start} to {current_end}")
# Update current chunk in job
db.execute(
text("""
UPDATE backfill_jobs
SET current_chunk_start = :start, current_chunk_end = :end
WHERE job_id = :job_id
"""),
{"job_id": job_id, "start": current_start, "end": current_end}
)
db.commit()
# Sync data for this chunk
try:
if source in ['newbook', 'all']:
# For backfill, do a full sync (no modified_since filter)
# This pulls all bookings - the sync job handles deduplication via upserts
from jobs.data_sync import sync_newbook_data, sync_newbook_occupancy_report
await sync_newbook_data(
full_sync=True,
triggered_by=f"backfill:{job_id}"
)
# Also backfill occupancy report for this chunk
# This provides available rooms, maintenance, official occupancy figures
await sync_newbook_occupancy_report(
from_date=current_start,
to_date=current_end,
triggered_by=f"backfill:{job_id}"
)
# Backfill earned revenue for this chunk (historical dates only)
# This provides official financial figures by GL account
from jobs.data_sync import sync_newbook_earned_revenue
# Only sync earned revenue for historical dates (not future)
earned_rev_end = min(current_end, date.today())
if current_start <= earned_rev_end:
await sync_newbook_earned_revenue(
from_date=current_start,
to_date=earned_rev_end,
triggered_by=f"backfill:{job_id}"
)
if source in ['resos', 'all']:
await sync_resos_data(current_start, current_end, f"backfill:{job_id}")
except Exception as chunk_error:
logger.error(f"Backfill chunk error: {chunk_error}")
# Continue with next chunk instead of failing entire job
chunks_completed += 1
# Get records synced for this chunk from sync_log
result = db.execute(
text("""
SELECT COALESCE(SUM(records_fetched), 0) as total
FROM sync_log
WHERE triggered_by = :triggered_by
"""),
{"triggered_by": f"backfill:{job_id}"}
)
row = result.fetchone()
total_records = row.total if row else 0
# Update progress
db.execute(
text("""
UPDATE backfill_jobs
SET chunks_completed = :completed, records_total = :records
WHERE job_id = :job_id
"""),
{"job_id": job_id, "completed": chunks_completed, "records": total_records}
)
db.commit()
# Move to next chunk
current_start = current_end + timedelta(days=1)
# For Newbook full sync, we only need to run once (not chunked)
if source == 'newbook':
break
# Run aggregation after backfill
from jobs.aggregation import run_aggregation
await run_aggregation()
# Mark job as completed
db.execute(
text("""
UPDATE backfill_jobs
SET status = 'completed', completed_at = NOW()
WHERE job_id = :job_id
"""),
{"job_id": job_id}
)
db.commit()
logger.info(f"Backfill {job_id} completed: {total_records} records synced")
except Exception as e:
logger.error(f"Backfill {job_id} failed: {e}")
db.execute(
text("""
UPDATE backfill_jobs
SET status = 'failed', error_message = :error, completed_at = NOW()
WHERE job_id = :job_id
"""),
{"job_id": job_id, "error": str(e)}
)
db.commit()
raise
finally:
db.close()

1426
backend/api/sync_bookings.py Normal file

File diff suppressed because it is too large Load diff