From 75d2c1fa9db1bd0e0a7df0e29d6493ea7e8efad1 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Sat, 4 Jul 2026 18:49:34 +0000 Subject: [PATCH] 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 --- .gitignore | 13 + backend/Dockerfile | 32 + backend/api/__init__.py | 1 + backend/api/accuracy.py | 457 + backend/api/ai_insights.py | 177 + backend/api/backtest.py | 1580 +++ backend/api/backup.py | 303 + backend/api/bookability.py | 598 + backend/api/budget.py | 559 + backend/api/competitor_rates.py | 940 ++ backend/api/config.py | 1293 +++ backend/api/crossref.py | 300 + backend/api/evolution.py | 292 + backend/api/explain.py | 328 + backend/api/export.py | 237 + backend/api/forecast.py | 3727 ++++++ backend/api/historical.py | 221 + backend/api/public.py | 537 + backend/api/reconciliation.py | 1281 +++ backend/api/reports.py | 927 ++ backend/api/resos.py | 718 ++ backend/api/resos_sync.py | 244 + backend/api/special_dates.py | 571 + backend/api/sync.py | 888 ++ backend/api/sync_bookings.py | 1426 +++ backend/auth.py | 68 + backend/database.py | 42 + backend/jobs/__init__.py | 1 + backend/jobs/accuracy_calc.py | 182 + backend/jobs/aggregation.py | 765 ++ backend/jobs/ai_insights.py | 456 + backend/jobs/batch_backtest.py | 1647 +++ backend/jobs/bookings_aggregation.py | 847 ++ backend/jobs/data_sync.py | 1252 +++ backend/jobs/fetch_current_rates.py | 359 + backend/jobs/forecast_daily.py | 276 + backend/jobs/metrics_aggregation.py | 150 + backend/jobs/pace_snapshot_v2.py | 439 + backend/jobs/pickup_snapshot.py | 208 + backend/jobs/resos_aggregation.py | 476 + backend/jobs/resos_bookings_sync.py | 397 + backend/jobs/revenue_aggregation.py | 153 + backend/jobs/scrape_booking_rates.py | 165 + backend/jobs/weekly_forecast_snapshot.py | 197 + backend/main.py | 138 + backend/requirements.txt | 24 + backend/scheduler.py | 366 + backend/schema.sql | 870 ++ backend/services/__init__.py | 1 + backend/services/backup_service.py | 539 + backend/services/booking_scraper.py | 829 ++ backend/services/forecasting/__init__.py | 1 + backend/services/forecasting/backtest.py | 401 + backend/services/forecasting/blended_model.py | 159 + backend/services/forecasting/blended_tuned.py | 164 + .../forecasting/blended_tuned_weighted.py | 336 + .../services/forecasting/budget_service.py | 254 + .../services/forecasting/catboost_model.py | 450 + .../services/forecasting/catboost_tuned.py | 449 + backend/services/forecasting/covers_model.py | 866 ++ .../forecasting/historical_forecast.py | 659 ++ backend/services/forecasting/pickup_model.py | 332 + backend/services/forecasting/pickup_tuned.py | 177 + .../services/forecasting/pickup_v2_model.py | 1367 +++ backend/services/forecasting/prophet_model.py | 195 + backend/services/forecasting/prophet_tuned.py | 281 + backend/services/forecasting/xgboost_model.py | 300 + backend/services/forecasting/xgboost_tuned.py | 446 + backend/services/newbook_client.py | 443 + backend/services/newbook_rates_client.py | 855 ++ backend/services/reconciliation_service.py | 531 + backend/services/resos_client.py | 177 + backend/services/scraper_backends/__init__.py | 20 + backend/services/scraper_backends/base.py | 152 + .../scraper_backends/playwright_local.py | 401 + backend/utils/__init__.py | 22 + backend/utils/capacity.py | 116 + backend/utils/time_alignment.py | 161 + db/init_clean.sql | 872 ++ docker-compose.yml | 45 + frontend/Dockerfile | 15 + frontend/index.html | 13 + frontend/nginx.conf | 35 + frontend/package-lock.json | 5509 +++++++++ frontend/package.json | 30 + frontend/src/App.tsx | 34 + frontend/src/api.ts | 20 + frontend/src/components/AuthGate.tsx | 47 + frontend/src/components/Layout.tsx | 59 + frontend/src/index.css | 361 + frontend/src/main.tsx | 20 + frontend/src/pages/Accuracy.tsx | 1350 +++ frontend/src/pages/Bookability.tsx | 1074 ++ frontend/src/pages/CompetitorRates.tsx | 1827 +++ frontend/src/pages/Dashboard.tsx | 133 + frontend/src/pages/Forecasts.tsx | 9973 +++++++++++++++++ frontend/src/pages/History.tsx | 3133 ++++++ frontend/src/pages/Settings.tsx | 7469 ++++++++++++ frontend/src/types.ts | 11 + frontend/src/vite-env.d.ts | 9 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 7 + seed-app.js | 38 + 103 files changed, 70316 insertions(+) create mode 100644 .gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/api/__init__.py create mode 100644 backend/api/accuracy.py create mode 100644 backend/api/ai_insights.py create mode 100644 backend/api/backtest.py create mode 100644 backend/api/backup.py create mode 100644 backend/api/bookability.py create mode 100644 backend/api/budget.py create mode 100644 backend/api/competitor_rates.py create mode 100644 backend/api/config.py create mode 100644 backend/api/crossref.py create mode 100644 backend/api/evolution.py create mode 100644 backend/api/explain.py create mode 100644 backend/api/export.py create mode 100644 backend/api/forecast.py create mode 100644 backend/api/historical.py create mode 100644 backend/api/public.py create mode 100644 backend/api/reconciliation.py create mode 100644 backend/api/reports.py create mode 100644 backend/api/resos.py create mode 100644 backend/api/resos_sync.py create mode 100644 backend/api/special_dates.py create mode 100644 backend/api/sync.py create mode 100644 backend/api/sync_bookings.py create mode 100644 backend/auth.py create mode 100644 backend/database.py create mode 100644 backend/jobs/__init__.py create mode 100644 backend/jobs/accuracy_calc.py create mode 100644 backend/jobs/aggregation.py create mode 100644 backend/jobs/ai_insights.py create mode 100644 backend/jobs/batch_backtest.py create mode 100644 backend/jobs/bookings_aggregation.py create mode 100644 backend/jobs/data_sync.py create mode 100644 backend/jobs/fetch_current_rates.py create mode 100644 backend/jobs/forecast_daily.py create mode 100644 backend/jobs/metrics_aggregation.py create mode 100644 backend/jobs/pace_snapshot_v2.py create mode 100644 backend/jobs/pickup_snapshot.py create mode 100644 backend/jobs/resos_aggregation.py create mode 100644 backend/jobs/resos_bookings_sync.py create mode 100644 backend/jobs/revenue_aggregation.py create mode 100644 backend/jobs/scrape_booking_rates.py create mode 100644 backend/jobs/weekly_forecast_snapshot.py create mode 100644 backend/main.py create mode 100644 backend/requirements.txt create mode 100644 backend/scheduler.py create mode 100644 backend/schema.sql create mode 100644 backend/services/__init__.py create mode 100644 backend/services/backup_service.py create mode 100644 backend/services/booking_scraper.py create mode 100644 backend/services/forecasting/__init__.py create mode 100644 backend/services/forecasting/backtest.py create mode 100644 backend/services/forecasting/blended_model.py create mode 100644 backend/services/forecasting/blended_tuned.py create mode 100644 backend/services/forecasting/blended_tuned_weighted.py create mode 100644 backend/services/forecasting/budget_service.py create mode 100644 backend/services/forecasting/catboost_model.py create mode 100644 backend/services/forecasting/catboost_tuned.py create mode 100644 backend/services/forecasting/covers_model.py create mode 100644 backend/services/forecasting/historical_forecast.py create mode 100644 backend/services/forecasting/pickup_model.py create mode 100644 backend/services/forecasting/pickup_tuned.py create mode 100644 backend/services/forecasting/pickup_v2_model.py create mode 100644 backend/services/forecasting/prophet_model.py create mode 100644 backend/services/forecasting/prophet_tuned.py create mode 100644 backend/services/forecasting/xgboost_model.py create mode 100644 backend/services/forecasting/xgboost_tuned.py create mode 100644 backend/services/newbook_client.py create mode 100644 backend/services/newbook_rates_client.py create mode 100644 backend/services/reconciliation_service.py create mode 100644 backend/services/resos_client.py create mode 100644 backend/services/scraper_backends/__init__.py create mode 100644 backend/services/scraper_backends/base.py create mode 100644 backend/services/scraper_backends/playwright_local.py create mode 100644 backend/utils/__init__.py create mode 100644 backend/utils/capacity.py create mode 100644 backend/utils/time_alignment.py create mode 100644 db/init_clean.sql create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/AuthGate.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Accuracy.tsx create mode 100644 frontend/src/pages/Bookability.tsx create mode 100644 frontend/src/pages/CompetitorRates.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Forecasts.tsx create mode 100644 frontend/src/pages/History.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 seed-app.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c89421 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +__pycache__/ +*.pyc +*.pyo +.env +.env.local +.DS_Store +*.egg-info/ +.eggs/ +build/ +.venv/ +venv/ diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6b41c19 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + postgresql-client \ + g++ \ + make \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Playwright + Chromium for Booking.com scraper +RUN playwright install chromium --with-deps + +# CmdStan for Prophet +RUN python -m cmdstanpy.install_cmdstan --cores 2 + +# Fix Prophet cmdstan path symlink +RUN CMDSTAN_VERSION=$(ls /root/.cmdstan/ | head -1) && \ + rm -rf /usr/local/lib/python3.11/site-packages/prophet/stan_model/cmdstan-* && \ + ln -s /root/.cmdstan/$CMDSTAN_VERSION /usr/local/lib/python3.11/site-packages/prophet/stan_model/cmdstan-2.31.0 + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..df5374a --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ +# API routers diff --git a/backend/api/accuracy.py b/backend/api/accuracy.py new file mode 100644 index 0000000..55e2b69 --- /dev/null +++ b/backend/api/accuracy.py @@ -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 + ] diff --git a/backend/api/ai_insights.py b/backend/api/ai_insights.py new file mode 100644 index 0000000..9db2bc6 --- /dev/null +++ b/backend/api/ai_insights.py @@ -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") + ) diff --git a/backend/api/backtest.py b/backend/api/backtest.py new file mode 100644 index 0000000..1510c1d --- /dev/null +++ b/backend/api/backtest.py @@ -0,0 +1,1580 @@ +""" +Backtesting API endpoints for model accuracy evaluation. + +Run backtests to evaluate how well models would have performed +on historical data using only information available at the time. +""" +from datetime import date, timedelta +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query, BackgroundTasks, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text + +from database import get_db +from auth import get_current_user +from utils.time_alignment import get_prior_year_daily + +router = APIRouter() + + +@router.post("/run") +async def run_backtest( + metric_code: str = Query(..., description="Metric to backtest"), + from_date: date = Query(..., description="Start of backtest period"), + to_date: date = Query(..., description="End of backtest period"), + lead_times: str = Query("7,14,21,28", description="Comma-separated lead times in days"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Run backtest for a metric over a date range. + + For each historical date, simulates what the forecast would have been + at various lead times using only data available at that time. + + Uses booking_placed timestamps from newbook_bookings to reconstruct + what OTB values would have been at each simulated date. + + Returns accuracy metrics comparing predicted vs actual values. + """ + lead_time_list = [int(x.strip()) for x in lead_times.split(",")] + + results = [] + total_rooms = 25 + + # Overflow room category (category_id=5) is used for chargeable no-shows/cancellations + # and should be excluded from room night counts + overflow_category_id = '5' + + # Get room capacity (SUM across all room categories for a single date) + if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + rooms_result = await db.execute( + text(""" + SELECT COALESCE(SUM(available), 25) as total_rooms + FROM newbook_occupancy_report + WHERE date = ( + SELECT MAX(date) FROM newbook_occupancy_report + WHERE date <= :from_date + ) + """), + {"from_date": from_date} + ) + rooms_row = rooms_result.fetchone() + if rooms_row and rooms_row.total_rooms: + total_rooms = int(rooms_row.total_rooms) + + # Process each date + current_date = from_date + while current_date <= to_date: + # Get actual value for this date + actual_result = await db.execute( + text(""" + SELECT actual_value + FROM daily_metrics + WHERE date = :target_date AND metric_code = :metric + """), + {"target_date": current_date, "metric": metric_code} + ) + actual_row = actual_result.fetchone() + actual_value = float(actual_row.actual_value) if actual_row and actual_row.actual_value else None + + if actual_value is None: + current_date += timedelta(days=1) + continue + + # For each lead time, simulate the forecast + for lead_time in lead_time_list: + simulated_today = current_date - timedelta(days=lead_time) + + # First try to get OTB from snapshots + otb_result = await db.execute( + text(""" + SELECT otb_value, snapshot_date, days_out + FROM pickup_snapshots + WHERE stay_date = :target_date + AND metric_type = :metric + AND snapshot_date <= :simulated_today + ORDER BY snapshot_date DESC + LIMIT 1 + """), + { + "target_date": current_date, + "metric": metric_code, + "simulated_today": simulated_today + } + ) + otb_row = otb_result.fetchone() + + current_otb = None + actual_lead_time = lead_time + + if otb_row: + # Use 'is not None' - 0 is valid OTB data + current_otb = float(otb_row.otb_value) if otb_row.otb_value is not None else 0 + actual_lead_time = otb_row.days_out or lead_time + else: + # Reconstruct OTB from booking data using booking_placed timestamps + # EXCLUDES overflow category + if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + # Count bookings that were placed before simulated_today + # and cover the target date + otb_recon_result = await db.execute( + text(""" + SELECT COUNT(DISTINCT newbook_id) as otb_count + FROM newbook_bookings + WHERE arrival_date <= :target_date + AND departure_date > :target_date + AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist') + AND (raw_json->>'booking_placed')::timestamp <= :simulated_today::timestamp + AND (category_id IS NULL OR category_id != :overflow_cat) + """), + { + "target_date": current_date, + "simulated_today": simulated_today, + "overflow_cat": overflow_category_id + } + ) + otb_recon_row = otb_recon_result.fetchone() + if otb_recon_row: + otb_count = otb_recon_row.otb_count or 0 + if metric_code == 'hotel_occupancy_pct': + current_otb = (otb_count / total_rooms) * 100 if total_rooms > 0 else 0 + else: + current_otb = otb_count + + if current_otb is None: + continue + + # Get prior year comparison + prior_year_date = get_prior_year_daily(current_date) + prior_year_simulated_today = get_prior_year_daily(simulated_today) + + # Get prior year OTB - first try snapshots, then reconstruct from bookings + prior_otb_result = await db.execute( + text(""" + SELECT otb_value + FROM pickup_snapshots + WHERE stay_date = :prior_date + AND metric_type = :metric + AND snapshot_date <= :prior_simulated_today + ORDER BY snapshot_date DESC + LIMIT 1 + """), + { + "prior_date": prior_year_date, + "metric": metric_code, + "prior_simulated_today": prior_year_simulated_today + } + ) + prior_otb_row = prior_otb_result.fetchone() + # Use 'is not None' - 0 is valid OTB data + prior_otb = float(prior_otb_row.otb_value) if prior_otb_row and prior_otb_row.otb_value is not None else None + + # If no snapshot, reconstruct prior year OTB from booking data + # EXCLUDES overflow category + if prior_otb is None and metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + prior_otb_recon_result = await db.execute( + text(""" + SELECT COUNT(DISTINCT newbook_id) as otb_count + FROM newbook_bookings + WHERE arrival_date <= :prior_date + AND departure_date > :prior_date + AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist') + AND (raw_json->>'booking_placed')::timestamp <= :prior_simulated_today::timestamp + AND (category_id IS NULL OR category_id != :overflow_cat) + """), + { + "prior_date": prior_year_date, + "prior_simulated_today": prior_year_simulated_today, + "overflow_cat": overflow_category_id + } + ) + prior_otb_recon_row = prior_otb_recon_result.fetchone() + if prior_otb_recon_row: + prior_otb_count = prior_otb_recon_row.otb_count or 0 + if metric_code == 'hotel_occupancy_pct': + prior_otb = (prior_otb_count / total_rooms) * 100 if total_rooms > 0 else 0 + else: + prior_otb = prior_otb_count + + # Get prior year final + prior_final_result = await db.execute( + text(""" + SELECT actual_value + FROM daily_metrics + WHERE date = :prior_date AND metric_code = :metric + """), + {"prior_date": prior_year_date, "metric": metric_code} + ) + prior_final_row = prior_final_result.fetchone() + prior_final = float(prior_final_row.actual_value) if prior_final_row and prior_final_row.actual_value else None + + # Calculate forecast using ADDITIVE method + projected_value = current_otb + projection_method = 'current_otb' + + if prior_otb is not None and prior_final is not None: + prior_pickup = prior_final - prior_otb + projected_value = current_otb + prior_pickup + + if projected_value < current_otb: + projected_value = current_otb + projection_method = 'additive_floor' + else: + projection_method = 'additive' + + # Apply caps + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + if metric_code == 'hotel_room_nights' and projected_value > total_rooms: + projected_value = total_rooms + + elif prior_final is not None and prior_final > 0: + # Implied additive + if lead_time >= 28: + estimated_pct = 0.35 + elif lead_time >= 14: + estimated_pct = 0.55 + elif lead_time >= 7: + estimated_pct = 0.75 + else: + estimated_pct = 0.90 + + implied_prior_otb = prior_final * estimated_pct + implied_pickup = prior_final - implied_prior_otb + projected_value = current_otb + implied_pickup + projected_value = max(projected_value, current_otb) + projection_method = 'implied_additive' + + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + if metric_code == 'hotel_room_nights' and projected_value > total_rooms: + projected_value = total_rooms + + # Calculate errors + error = projected_value - actual_value + abs_error = abs(error) + pct_error = (error / actual_value * 100) if actual_value != 0 else None + abs_pct_error = abs(pct_error) if pct_error is not None else None + + result_record = { + "target_date": str(current_date), + "lead_time": lead_time, + "actual_lead_time": actual_lead_time, + "simulated_today": str(simulated_today), + "current_otb": round(current_otb, 2), + "prior_otb": round(prior_otb, 2) if prior_otb is not None else None, + "prior_final": round(prior_final, 2) if prior_final is not None else None, + "projected_value": round(projected_value, 2), + "actual_value": round(actual_value, 2), + "error": round(error, 2), + "abs_error": round(abs_error, 2), + "pct_error": round(pct_error, 2) if pct_error is not None else None, + "abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None, + "projection_method": projection_method + } + results.append(result_record) + + # Store result + try: + await db.execute( + text(""" + INSERT INTO backtest_results ( + target_date, metric_code, lead_time, simulated_today, + current_otb, prior_otb, prior_final, + projected_value, actual_value, + error, abs_error, pct_error, abs_pct_error, + projection_method, created_at + ) VALUES ( + :target_date, :metric, :lead_time, :simulated_today, + :current_otb, :prior_otb, :prior_final, + :projected_value, :actual_value, + :error, :abs_error, :pct_error, :abs_pct_error, + :projection_method, NOW() + ) + ON CONFLICT (target_date, metric_code, lead_time) DO UPDATE SET + projected_value = :projected_value, + actual_value = :actual_value, + error = :error, + abs_error = :abs_error, + pct_error = :pct_error, + abs_pct_error = :abs_pct_error, + projection_method = :projection_method, + created_at = NOW() + """), + { + "target_date": current_date, + "metric": metric_code, + "lead_time": lead_time, + "simulated_today": simulated_today, + "current_otb": round(current_otb, 2), + "prior_otb": round(prior_otb, 2) if prior_otb is not None else None, + "prior_final": round(prior_final, 2) if prior_final is not None else None, + "projected_value": round(projected_value, 2), + "actual_value": round(actual_value, 2), + "error": round(error, 2), + "abs_error": round(abs_error, 2), + "pct_error": round(pct_error, 2) if pct_error is not None else None, + "abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None, + "projection_method": projection_method + } + ) + except Exception: + pass # Continue on storage errors + + current_date += timedelta(days=1) + + await db.commit() + + # Calculate summary statistics + summary = _calculate_summary(results, lead_time_list) + + return { + "metric_code": metric_code, + "backtest_from": str(from_date), + "backtest_to": str(to_date), + "lead_times": lead_time_list, + "total_forecasts": len(results), + "summary": summary, + "results": results + } + + +def _calculate_summary(results: List[dict], lead_times: List[int]) -> dict: + """Calculate summary statistics from backtest results.""" + if not results: + return {} + + summary = {"overall": {}, "by_lead_time": {}, "by_method": {}} + + # Overall + all_errors = [r['abs_error'] for r in results if r['abs_error'] is not None] + all_pct_errors = [r['abs_pct_error'] for r in results if r['abs_pct_error'] is not None] + + if all_errors: + summary["overall"] = { + "mae": round(sum(all_errors) / len(all_errors), 2), + "mape": round(sum(all_pct_errors) / len(all_pct_errors), 2) if all_pct_errors else None, + "count": len(all_errors) + } + + # By lead time + for lt in lead_times: + lt_results = [r for r in results if r['lead_time'] == lt] + lt_errors = [r['abs_error'] for r in lt_results if r['abs_error'] is not None] + lt_pct_errors = [r['abs_pct_error'] for r in lt_results if r['abs_pct_error'] is not None] + + if lt_errors: + summary["by_lead_time"][str(lt)] = { + "mae": round(sum(lt_errors) / len(lt_errors), 2), + "mape": round(sum(lt_pct_errors) / len(lt_pct_errors), 2) if lt_pct_errors else None, + "count": len(lt_errors) + } + + # By method + methods = set(r['projection_method'] for r in results) + for method in methods: + method_results = [r for r in results if r['projection_method'] == method] + method_errors = [r['abs_error'] for r in method_results if r['abs_error'] is not None] + method_pct_errors = [r['abs_pct_error'] for r in method_results if r['abs_pct_error'] is not None] + + if method_errors: + summary["by_method"][method] = { + "mae": round(sum(method_errors) / len(method_errors), 2), + "mape": round(sum(method_pct_errors) / len(method_pct_errors), 2) if method_pct_errors else None, + "count": len(method_errors) + } + + return summary + + +@router.get("/results") +async def get_backtest_results( + metric_code: str = Query(..., description="Metric code"), + from_date: Optional[date] = Query(None), + to_date: Optional[date] = Query(None), + lead_time: Optional[int] = Query(None, description="Filter by specific lead time"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Retrieve stored backtest results. + """ + query = """ + SELECT + target_date, metric_code, lead_time, simulated_today, + current_otb, prior_otb, prior_final, + projected_value, actual_value, + error, abs_error, pct_error, abs_pct_error, + projection_method, created_at + FROM backtest_results + WHERE metric_code = :metric + """ + params = {"metric": metric_code} + + if from_date: + query += " AND target_date >= :from_date" + params["from_date"] = from_date + + if to_date: + query += " AND target_date <= :to_date" + params["to_date"] = to_date + + if lead_time: + query += " AND lead_time = :lead_time" + params["lead_time"] = lead_time + + query += " ORDER BY target_date, lead_time" + + result = await db.execute(text(query), params) + rows = result.fetchall() + + return [ + { + "target_date": str(row.target_date), + "metric_code": row.metric_code, + "lead_time": row.lead_time, + "simulated_today": str(row.simulated_today) if row.simulated_today else None, + "current_otb": float(row.current_otb) if row.current_otb is not None else None, + "prior_otb": float(row.prior_otb) if row.prior_otb is not None else None, + "prior_final": float(row.prior_final) if row.prior_final is not None else None, + "projected_value": float(row.projected_value) if row.projected_value is not None else None, + "actual_value": float(row.actual_value) if row.actual_value is not None else None, + "error": float(row.error) if row.error is not None else None, + "abs_error": float(row.abs_error) if row.abs_error is not None else None, + "pct_error": float(row.pct_error) if row.pct_error is not None else None, + "abs_pct_error": float(row.abs_pct_error) if row.abs_pct_error is not None else None, + "projection_method": row.projection_method + } + for row in rows + ] + + +@router.get("/summary") +async def get_backtest_summary( + metric_code: str = Query(..., description="Metric code"), + 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 summary accuracy metrics from backtest results. + """ + base_filter = "WHERE metric_code = :metric" + params = {"metric": metric_code} + + if from_date: + base_filter += " AND target_date >= :from_date" + params["from_date"] = from_date + if to_date: + base_filter += " AND target_date <= :to_date" + params["to_date"] = to_date + + # Overall summary + overall_query = f""" + SELECT + COUNT(*) as count, + AVG(abs_error) as mae, + AVG(abs_pct_error) as mape, + MIN(target_date) as min_date, + MAX(target_date) as max_date + FROM backtest_results + {base_filter} + """ + overall_result = await db.execute(text(overall_query), params) + overall = overall_result.fetchone() + + # By lead time + lead_query = f""" + SELECT + lead_time, + COUNT(*) as count, + AVG(abs_error) as mae, + AVG(abs_pct_error) as mape + FROM backtest_results + {base_filter} + GROUP BY lead_time + ORDER BY lead_time + """ + lead_result = await db.execute(text(lead_query), params) + lead_rows = lead_result.fetchall() + + # By projection method + method_query = f""" + SELECT + projection_method, + COUNT(*) as count, + AVG(abs_error) as mae, + AVG(abs_pct_error) as mape + FROM backtest_results + {base_filter} + GROUP BY projection_method + ORDER BY count DESC + """ + method_result = await db.execute(text(method_query), params) + method_rows = method_result.fetchall() + + return { + "metric_code": metric_code, + "overall": { + "count": overall.count if overall else 0, + "mae": round(float(overall.mae), 2) if overall and overall.mae else None, + "mape": round(float(overall.mape), 2) if overall and overall.mape else None, + "date_range": { + "from": str(overall.min_date) if overall and overall.min_date else None, + "to": str(overall.max_date) if overall and overall.max_date else None + } + }, + "by_lead_time": [ + { + "lead_time": row.lead_time, + "count": row.count, + "mae": round(float(row.mae), 2) if row.mae else None, + "mape": round(float(row.mape), 2) if row.mape else None + } + for row in lead_rows + ], + "by_method": [ + { + "method": row.projection_method, + "count": row.count, + "mae": round(float(row.mae), 2) if row.mae else None, + "mape": round(float(row.mape), 2) if row.mape else None + } + for row in method_rows + ] + } + + +@router.post("/historical-forecast") +async def run_historical_forecasts( + simulated_dates: str = Query(..., description="Comma-separated dates to simulate (YYYY-MM-DD)"), + metrics: str = Query("hotel_room_nights,hotel_occupancy_pct,resos_dinner_covers,resos_lunch_covers", description="Comma-separated metrics"), + models: str = Query("prophet,xgboost,pickup", description="Comma-separated models to run"), + forecast_days: int = Query(60, description="Days to forecast from each simulated date"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Run all forecast models as if it were specific historical dates. + + This populates the forecasts table with historical predictions + that can then be evaluated against actual outcomes using the + existing accuracy tracking pages. + + Example: Run forecasts as if it were April 1, 2025: + - Prophet, XGBoost, and Pickup models will only use data before April 1 + - Forecasts will be generated for April 2 - May 31 (60 days) + - Results stored with generated_at = 2025-04-01 + + Use this to backtest all models and populate the forecast evaluation pages. + """ + from services.forecasting.historical_forecast import run_historical_forecast + + # Parse inputs + date_list = [date.fromisoformat(d.strip()) for d in simulated_dates.split(",")] + metric_list = [m.strip() for m in metrics.split(",")] + model_list = [m.strip() for m in models.split(",")] + + all_results = [] + + for sim_date in date_list: + try: + result = await run_historical_forecast( + db=db, + simulated_today=sim_date, + metric_codes=metric_list, + models=model_list, + forecast_days=forecast_days + ) + all_results.append(result) + except Exception as e: + all_results.append({ + "simulated_today": str(sim_date), + "error": str(e) + }) + + return { + "status": "complete", + "dates_processed": len(date_list), + "results": all_results + } + + +@router.get("/historical-forecast/status") +async def get_historical_forecast_status( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get summary of historical forecasts that have been generated. + + Shows which dates have been used as "simulated today" and + how many forecasts exist for each. + """ + query = """ + SELECT + DATE(generated_at) as simulated_date, + model_type, + forecast_type, + COUNT(*) as forecast_count, + MIN(forecast_date) as forecast_from, + MAX(forecast_date) as forecast_to + FROM forecasts + WHERE DATE(generated_at) < CURRENT_DATE - INTERVAL '7 days' + GROUP BY DATE(generated_at), model_type, forecast_type + ORDER BY DATE(generated_at) DESC, model_type, forecast_type + LIMIT 100 + """ + + result = await db.execute(text(query)) + rows = result.fetchall() + + return [ + { + "simulated_date": str(row.simulated_date), + "model": row.model_type, + "metric": row.forecast_type, + "count": row.forecast_count, + "forecast_range": f"{row.forecast_from} to {row.forecast_to}" + } + for row in rows + ] + + +# ============================================ +# BATCH BACKTEST FOR MODEL ACCURACY & WEIGHTING +# ============================================ + +@router.post("/batch") +async def run_batch_backtest_endpoint( + background_tasks: BackgroundTasks, + start_perception: date = Query(..., description="First Monday to use as perception date"), + end_perception: date = Query(..., description="Last Monday to use as perception date"), + forecast_days: int = Query(365, description="Days ahead to forecast from each perception date"), + metric: str = Query("occupancy", description="Metric to backtest"), + model: str = Query("xgboost", description="Model to backtest: xgboost, prophet, pickup, or catboost"), + exclude_covid: bool = Query(False, description="Exclude pre-COVID data (train from May 2021+ only)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Run batch backtests from multiple perception dates (every Monday in range). + + Stores forecasts in forecast_snapshots table for accuracy analysis. + Use this to generate data for model weighting by lead time bracket. + + Specify model to run one at a time - allows adding new model backtests + without re-running existing ones. + + Metrics: + - occupancy, rooms: All models supported (uses booking pace data) + - guests, ave_guest_rate, arr, net_accom, net_dry, net_wet: Prophet only (no pace data) + + Set exclude_covid=true to train models only on post-COVID data (May 2021+). + Results will be stored with '_postcovid' suffix (e.g., 'xgboost_postcovid') + so you can compare accuracy with vs without COVID-era training data. + + Example: Run XGBoost for all Mondays of 2024: + - start_perception: 2024-01-01 + - end_perception: 2024-12-31 + - forecast_days: 365 + - model: xgboost + + Results can be analyzed via /backtest/accuracy-by-bracket endpoint. + """ + from jobs.batch_backtest import run_batch_backtest + + valid_models = ['xgboost', 'prophet', 'pickup', 'pickup_avg', 'catboost', 'blended'] + valid_metrics = ['occupancy', 'rooms', 'guests', 'ave_guest_rate', 'arr', 'net_accom', 'net_dry', 'net_wet'] + # Pickup models require pace data - only work with occupancy and rooms + pace_only_metrics = ['occupancy', 'rooms'] + pickup_models = ['pickup', 'pickup_avg'] + # Blended model requires existing prophet, xgboost, catboost forecasts + derived_models = ['blended'] + + if model not in valid_models: + raise HTTPException(status_code=400, detail=f"Invalid model. Must be one of: {valid_models}") + + if metric not in valid_metrics: + raise HTTPException(status_code=400, detail=f"Invalid metric. Must be one of: {valid_metrics}") + + # Pickup models require pace data + if model in pickup_models and metric not in pace_only_metrics: + raise HTTPException( + status_code=400, + detail=f"Model '{model}' only supports occupancy and rooms metrics (requires booking pace data). " + f"Use XGBoost, CatBoost, or Prophet for {metric}." + ) + + # Blended model requires existing prophet, xgboost, catboost forecasts + if model in derived_models: + # Note: blended model averages existing forecasts, doesn't train from scratch + pass + + # Training cutoff for post-COVID: May 1, 2021 + training_start = date(2021, 5, 1) if exclude_covid else None + + # Run in background + background_tasks.add_task( + run_batch_backtest, + start_perception, + end_perception, + forecast_days, + metric, + [model], # Single model at a time + training_start # Training cutoff date + ) + + return { + "status": "started", + "message": f"Batch backtest for {model}{' (post-COVID)' if exclude_covid else ''} running in background", + "params": { + "start_perception": str(start_perception), + "end_perception": str(end_perception), + "forecast_days": forecast_days, + "metric": metric, + "model": model, + "exclude_covid": exclude_covid, + "training_start": str(training_start) if training_start else None + } + } + + +@router.get("/batch/status") +async def get_batch_backtest_status( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get status of batch backtests - snapshot counts per model/metric and perception date range. + """ + result = await db.execute(text(""" + SELECT + model, + metric_code, + COUNT(*) as total_snapshots, + COUNT(actual_value) as with_actuals, + MIN(perception_date) as first_perception, + MAX(perception_date) as last_perception, + COUNT(DISTINCT perception_date) as perception_dates + FROM forecast_snapshots + GROUP BY model, metric_code + ORDER BY metric_code, model + """)) + rows = result.fetchall() + + return [ + { + "model": row.model, + "metric_code": row.metric_code, + "total_snapshots": row.total_snapshots, + "with_actuals": row.with_actuals, + "first_perception": str(row.first_perception), + "last_perception": str(row.last_perception), + "perception_dates": row.perception_dates + } + for row in rows + ] + + +@router.get("/snapshots") +async def get_forecast_snapshots( + perception_date: Optional[date] = Query(None, description="Filter by perception date"), + target_date: Optional[date] = Query(None, description="Filter by target date"), + model: Optional[str] = Query(None, description="Filter by model"), + metric_code: str = Query("occupancy", description="Metric code"), + limit: int = Query(100, description="Max rows to return"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get stored forecast snapshots.""" + query = """ + SELECT perception_date, target_date, model, metric_code, days_out, + forecast_value, actual_value, created_at + FROM forecast_snapshots + WHERE metric_code = :metric_code + """ + params = {"metric_code": metric_code} + + if perception_date: + query += " AND perception_date = :perception_date" + params["perception_date"] = perception_date + + if target_date: + query += " AND target_date = :target_date" + params["target_date"] = target_date + + if model: + query += " AND model = :model" + params["model"] = model + + query += " ORDER BY perception_date DESC, target_date LIMIT :limit" + params["limit"] = limit + + result = await db.execute(text(query), params) + rows = result.fetchall() + + return [ + { + "perception_date": str(row.perception_date), + "target_date": str(row.target_date), + "model": row.model, + "metric_code": row.metric_code, + "days_out": row.days_out, + "forecast_value": float(row.forecast_value) if row.forecast_value else None, + "actual_value": float(row.actual_value) if row.actual_value else None + } + for row in rows + ] + + +@router.get("/accuracy-by-bracket") +async def get_accuracy_by_bracket( + metric_code: str = Query("occupancy", description="Metric code"), + model: Optional[str] = Query(None, description="Filter by model (default: all)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get model accuracy (MAE, MAPE) by lead time bracket. + + Returns accuracy metrics grouped by: + - 0-7 days out + - 8-14 days out + - 15-30 days out + - 31-60 days out + - 61-90 days out + - 90+ days out + + Use this to derive weights for ensemble forecasting. + """ + model_filter = "AND model = :model" if model else "" + params = {"metric_code": metric_code} + if model: + params["model"] = model + + query = f""" + SELECT + model, + CASE + WHEN days_out BETWEEN 0 AND 7 THEN '0-7' + WHEN days_out BETWEEN 8 AND 14 THEN '8-14' + WHEN days_out BETWEEN 15 AND 30 THEN '15-30' + WHEN days_out BETWEEN 31 AND 60 THEN '31-60' + WHEN days_out BETWEEN 61 AND 90 THEN '61-90' + ELSE '90+' + END as lead_bracket, + CASE + WHEN days_out BETWEEN 0 AND 7 THEN 1 + WHEN days_out BETWEEN 8 AND 14 THEN 2 + WHEN days_out BETWEEN 15 AND 30 THEN 3 + WHEN days_out BETWEEN 31 AND 60 THEN 4 + WHEN days_out BETWEEN 61 AND 90 THEN 5 + ELSE 6 + END as sort_order, + COUNT(*) as n, + AVG(ABS(forecast_value - actual_value)) as mae, + AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape + FROM forecast_snapshots + WHERE actual_value IS NOT NULL + AND metric_code = :metric_code + {model_filter} + GROUP BY model, + CASE + WHEN days_out BETWEEN 0 AND 7 THEN '0-7' + WHEN days_out BETWEEN 8 AND 14 THEN '8-14' + WHEN days_out BETWEEN 15 AND 30 THEN '15-30' + WHEN days_out BETWEEN 31 AND 60 THEN '31-60' + WHEN days_out BETWEEN 61 AND 90 THEN '61-90' + ELSE '90+' + END, + CASE + WHEN days_out BETWEEN 0 AND 7 THEN 1 + WHEN days_out BETWEEN 8 AND 14 THEN 2 + WHEN days_out BETWEEN 15 AND 30 THEN 3 + WHEN days_out BETWEEN 31 AND 60 THEN 4 + WHEN days_out BETWEEN 61 AND 90 THEN 5 + ELSE 6 + END + ORDER BY model, sort_order + """ + + result = await db.execute(text(query), params) + rows = result.fetchall() + + return [ + { + "model": row.model, + "lead_bracket": row.lead_bracket, + "n": row.n, + "mae": round(float(row.mae), 2) if row.mae else None, + "mape": round(float(row.mape), 2) if row.mape else None + } + for row in rows + ] + + +@router.get("/accuracy-by-day-of-week") +async def get_accuracy_by_day_of_week( + metric_code: str = Query("occupancy", description="Metric code"), + model: Optional[str] = Query(None, description="Filter by model (default: all)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get model accuracy (MAE, MAPE) by day of week. + + Returns accuracy metrics grouped by: + - Sunday (0) + - Monday (1) + - Tuesday (2) + - Wednesday (3) + - Thursday (4) + - Friday (5) + - Saturday (6) + + Useful for identifying if models perform better on certain days. + """ + model_filter = "AND model = :model" if model else "" + params = {"metric_code": metric_code} + if model: + params["model"] = model + + query = f""" + SELECT + model, + EXTRACT(DOW FROM target_date)::int as dow_num, + CASE EXTRACT(DOW FROM target_date)::int + WHEN 0 THEN 'Sunday' + WHEN 1 THEN 'Monday' + WHEN 2 THEN 'Tuesday' + WHEN 3 THEN 'Wednesday' + WHEN 4 THEN 'Thursday' + WHEN 5 THEN 'Friday' + WHEN 6 THEN 'Saturday' + END as day_name, + COUNT(*) as n, + AVG(ABS(forecast_value - actual_value)) as mae, + AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape + FROM forecast_snapshots + WHERE actual_value IS NOT NULL + AND metric_code = :metric_code + {model_filter} + GROUP BY model, + EXTRACT(DOW FROM target_date)::int, + CASE EXTRACT(DOW FROM target_date)::int + WHEN 0 THEN 'Sunday' + WHEN 1 THEN 'Monday' + WHEN 2 THEN 'Tuesday' + WHEN 3 THEN 'Wednesday' + WHEN 4 THEN 'Thursday' + WHEN 5 THEN 'Friday' + WHEN 6 THEN 'Saturday' + END + ORDER BY model, dow_num + """ + + result = await db.execute(text(query), params) + rows = result.fetchall() + + return [ + { + "model": row.model, + "dow_num": row.dow_num, + "day_name": row.day_name, + "n": row.n, + "mae": round(float(row.mae), 2) if row.mae else None, + "mape": round(float(row.mape), 2) if row.mape else None + } + for row in rows + ] + + +@router.get("/accuracy-by-month") +async def get_accuracy_by_month( + metric_code: str = Query("occupancy", description="Metric code"), + model: Optional[str] = Query(None, description="Filter by model (default: all)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get model accuracy (MAE, MAPE) by month of year. + + Returns accuracy metrics grouped by month (January through December). + + Useful for identifying seasonal patterns in model accuracy. + """ + model_filter = "AND model = :model" if model else "" + params = {"metric_code": metric_code} + if model: + params["model"] = model + + query = f""" + SELECT + model, + EXTRACT(MONTH FROM target_date)::int as month_num, + CASE EXTRACT(MONTH FROM target_date)::int + WHEN 1 THEN 'January' + WHEN 2 THEN 'February' + WHEN 3 THEN 'March' + WHEN 4 THEN 'April' + WHEN 5 THEN 'May' + WHEN 6 THEN 'June' + WHEN 7 THEN 'July' + WHEN 8 THEN 'August' + WHEN 9 THEN 'September' + WHEN 10 THEN 'October' + WHEN 11 THEN 'November' + WHEN 12 THEN 'December' + END as month_name, + COUNT(*) as n, + AVG(ABS(forecast_value - actual_value)) as mae, + AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape + FROM forecast_snapshots + WHERE actual_value IS NOT NULL + AND metric_code = :metric_code + {model_filter} + GROUP BY model, + EXTRACT(MONTH FROM target_date)::int, + CASE EXTRACT(MONTH FROM target_date)::int + WHEN 1 THEN 'January' + WHEN 2 THEN 'February' + WHEN 3 THEN 'March' + WHEN 4 THEN 'April' + WHEN 5 THEN 'May' + WHEN 6 THEN 'June' + WHEN 7 THEN 'July' + WHEN 8 THEN 'August' + WHEN 9 THEN 'September' + WHEN 10 THEN 'October' + WHEN 11 THEN 'November' + WHEN 12 THEN 'December' + END + ORDER BY model, month_num + """ + + result = await db.execute(text(query), params) + rows = result.fetchall() + + return [ + { + "model": row.model, + "month_num": row.month_num, + "month_name": row.month_name, + "n": row.n, + "mae": round(float(row.mae), 2) if row.mae else None, + "mape": round(float(row.mape), 2) if row.mape else None + } + for row in rows + ] + + +@router.get("/model-weights") +async def get_model_weights( + metric_code: str = Query("occupancy", description="Metric code"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Calculate model weights based on inverse MAPE by lead time bracket. + + Lower MAPE = higher weight. + Weights are normalized to sum to 1.0 within each bracket. + + Use these weights for ensemble forecasting. + """ + query = """ + WITH accuracy AS ( + SELECT + model, + CASE + WHEN days_out BETWEEN 0 AND 7 THEN '0-7' + WHEN days_out BETWEEN 8 AND 14 THEN '8-14' + WHEN days_out BETWEEN 15 AND 30 THEN '15-30' + WHEN days_out BETWEEN 31 AND 60 THEN '31-60' + WHEN days_out BETWEEN 61 AND 90 THEN '61-90' + ELSE '90+' + END as lead_bracket, + AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape + FROM forecast_snapshots + WHERE actual_value IS NOT NULL + AND metric_code = :metric_code + GROUP BY model, + CASE + WHEN days_out BETWEEN 0 AND 7 THEN '0-7' + WHEN days_out BETWEEN 8 AND 14 THEN '8-14' + WHEN days_out BETWEEN 15 AND 30 THEN '15-30' + WHEN days_out BETWEEN 31 AND 60 THEN '31-60' + WHEN days_out BETWEEN 61 AND 90 THEN '61-90' + ELSE '90+' + END + ), + inverse_mape AS ( + SELECT + model, + lead_bracket, + mape, + CASE WHEN mape > 0 THEN 1.0 / mape ELSE 0 END as inv_mape + FROM accuracy + ), + bracket_totals AS ( + SELECT lead_bracket, SUM(inv_mape) as total_inv + FROM inverse_mape + GROUP BY lead_bracket + ) + SELECT + i.model, + i.lead_bracket, + i.mape, + CASE WHEN b.total_inv > 0 THEN i.inv_mape / b.total_inv ELSE 0 END as weight + FROM inverse_mape i + JOIN bracket_totals b ON i.lead_bracket = b.lead_bracket + ORDER BY + i.lead_bracket, + weight DESC + """ + + result = await db.execute(text(query), {"metric_code": metric_code}) + rows = result.fetchall() + + return [ + { + "model": row.model, + "lead_bracket": row.lead_bracket, + "mape": round(float(row.mape), 2) if row.mape else None, + "weight": round(float(row.weight), 4) if row.weight else 0 + } + for row in rows + ] + + +@router.post("/backfill-actuals") +async def backfill_snapshot_actuals( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Backfill actual_value in forecast_snapshots from newbook_bookings_stats and newbook_net_revenue_data. + + Run this after target dates have passed to populate actual values + for accuracy analysis. + + Handles all metrics: + - occupancy: booking_count / bookable_count * 100 + - rooms: booking_count + - guests: guests_count + - ave_guest_rate: guest_rate_total / booking_count + - arr: accommodation / booking_count (from revenue data) + - net_accom, net_dry, net_wet: from revenue data + """ + # First, update stats-based metrics (occupancy, rooms, guests, ave_guest_rate) + result1 = await db.execute(text(""" + UPDATE forecast_snapshots fs + SET actual_value = CASE + WHEN fs.metric_code = 'occupancy' THEN + (s.booking_count::decimal / NULLIF(s.bookable_count, 0)) * 100 + WHEN fs.metric_code = 'rooms' THEN + s.booking_count + WHEN fs.metric_code = 'guests' THEN + s.guests_count + WHEN fs.metric_code = 'ave_guest_rate' THEN + s.guest_rate_total / NULLIF(s.booking_count, 0) + ELSE NULL + END + FROM newbook_bookings_stats s + WHERE fs.target_date = s.date + AND fs.actual_value IS NULL + AND fs.target_date < CURRENT_DATE + AND fs.metric_code IN ('occupancy', 'rooms', 'guests', 'ave_guest_rate') + AND s.booking_count IS NOT NULL + """)) + + # Then, update revenue-based metrics (arr, net_accom, net_dry, net_wet) + result2 = await db.execute(text(""" + UPDATE forecast_snapshots fs + SET actual_value = CASE + WHEN fs.metric_code = 'arr' THEN + r.accommodation / NULLIF(s.booking_count, 0) + WHEN fs.metric_code = 'net_accom' THEN + r.accommodation + WHEN fs.metric_code = 'net_dry' THEN + r.dry + WHEN fs.metric_code = 'net_wet' THEN + r.wet + ELSE NULL + END + FROM newbook_net_revenue_data r + JOIN newbook_bookings_stats s ON r.date = s.date + WHERE fs.target_date = r.date + AND fs.actual_value IS NULL + AND fs.target_date < CURRENT_DATE + AND fs.metric_code IN ('arr', 'net_accom', 'net_dry', 'net_wet') + """)) + + await db.commit() + + return { + "status": "complete", + "rows_updated": result1.rowcount + result2.rowcount + } + + +@router.delete("/snapshots/{model}") +async def delete_model_snapshots( + model: str, + metric_code: Optional[str] = Query(None, description="Optional: Only delete for this metric"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Delete all backtest snapshots for a specific model. + + Use this to remove backtest data for models you want to exclude from + accuracy analysis (e.g., models affected by COVID training data). + + The model name must match exactly (e.g., 'xgboost', 'prophet', 'pickup_postcovid'). + """ + query = "DELETE FROM forecast_snapshots WHERE model = :model" + params = {"model": model} + + if metric_code: + query += " AND metric_code = :metric_code" + params["metric_code"] = metric_code + + result = await db.execute(text(query), params) + await db.commit() + + return { + "status": "complete", + "model": model, + "metric_code": metric_code, + "rows_deleted": result.rowcount + } + + +@router.post("/fill-to-today") +async def fill_backtests_to_today( + background_tasks: BackgroundTasks, + metric: str = Query("occupancy", description="Metric to backtest"), + models: str = Query("prophet,xgboost,catboost,blended", description="Comma-separated models to run (blended runs last)"), + forecast_days: int = Query(365, description="Days ahead to forecast from each perception date"), + exclude_covid: bool = Query(True, description="Exclude pre-COVID data (train from May 2021+ only)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Fill in backtests from earliest available data up to today. + + Finds missing perception dates (Mondays) and runs backtests for each model. + Use this to backfill historical forecast data for accuracy analysis and 3D visualization. + + The 'blended' model averages prophet, xgboost, and catboost - those must run first. + If included, blended is automatically moved to run last. + """ + from jobs.batch_backtest import run_batch_backtest + + model_list = [m.strip() for m in models.split(",")] + valid_models = ['xgboost', 'prophet', 'pickup', 'pickup_avg', 'catboost', 'blended'] + + for m in model_list: + if m not in valid_models: + raise HTTPException(status_code=400, detail=f"Invalid model: {m}") + + # Ensure blended runs last (it depends on other model outputs) + if 'blended' in model_list: + model_list.remove('blended') + model_list.append('blended') + + # Find earliest historical data date + earliest_result = await db.execute(text(""" + SELECT MIN(date) as earliest + FROM newbook_bookings_stats + WHERE booking_count IS NOT NULL + """)) + earliest_row = earliest_result.fetchone() + earliest_data = earliest_row.earliest if earliest_row else None + + if not earliest_data: + raise HTTPException(status_code=400, detail="No historical data available") + + # Start from 2 years after earliest data (need training history) + start_date = earliest_data + timedelta(days=730) + + # Find most recent Monday before today + today = date.today() + days_since_monday = today.weekday() # Monday=0 + last_monday = today - timedelta(days=days_since_monday) + + # Move start_date to first Monday + while start_date.weekday() != 0: + start_date += timedelta(days=1) + + # Training cutoff for post-COVID: May 1, 2021 + training_start = date(2021, 5, 1) if exclude_covid else None + + # Check existing perception dates + existing_result = await db.execute(text(""" + SELECT DISTINCT perception_date + FROM forecast_snapshots + WHERE metric_code = :metric + AND model = :first_model + ORDER BY perception_date + """), {"metric": metric, "first_model": f"{model_list[0]}{'_postcovid' if exclude_covid else ''}"}) + existing_dates = {row.perception_date for row in existing_result.fetchall()} + + # Count how many Mondays need processing + check_date = start_date + missing_count = 0 + while check_date <= last_monday: + if check_date not in existing_dates: + missing_count += 1 + check_date += timedelta(days=7) + + # Run in background + background_tasks.add_task( + run_batch_backtest, + start_date, + last_monday, + forecast_days, + metric, + model_list, + training_start + ) + + return { + "status": "started", + "message": f"Filling backtests from {start_date} to {last_monday}", + "params": { + "start_perception": str(start_date), + "end_perception": str(last_monday), + "forecast_days": forecast_days, + "metric": metric, + "models": model_list, + "exclude_covid": exclude_covid, + "existing_perception_dates": len(existing_dates), + "missing_perception_dates": missing_count + } + } + + +@router.get("/3d-data") +async def get_3d_forecast_data( + metric_code: str = Query("occupancy", description="Metric code"), + target_date: date = Query(..., description="Target date to analyze"), + model: str = Query("blended", description="Model to show"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get forecast evolution data for 3D visualization. + + Returns all forecasts for a specific target date from different perception dates, + showing how the forecast changed as the target date approached. + + Data structure for 3D chart: + - X axis: perception_date (when forecast was made) + - Y axis: days_out (lead time) + - Z axis: forecast_value + + This shows how forecast accuracy improves as lead time decreases. + """ + query = """ + SELECT + perception_date, + target_date, + days_out, + forecast_value, + actual_value + FROM forecast_snapshots + WHERE target_date = :target_date + AND metric_code = :metric_code + AND model = :model + ORDER BY perception_date + """ + + result = await db.execute(text(query), { + "target_date": target_date, + "metric_code": metric_code, + "model": model + }) + rows = result.fetchall() + + return { + "target_date": str(target_date), + "metric_code": metric_code, + "model": model, + "actual_value": float(rows[0].actual_value) if rows and rows[0].actual_value else None, + "snapshots": [ + { + "perception_date": str(row.perception_date), + "days_out": row.days_out, + "forecast_value": float(row.forecast_value) if row.forecast_value else None + } + for row in rows + ] + } + + +@router.get("/3d-surface") +async def get_3d_surface_data( + metric_code: str = Query("occupancy", description="Metric code"), + from_date: date = Query(..., description="Start of target date range"), + to_date: date = Query(..., description="End of target date range"), + model: str = Query("blended", description="Model to show"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get forecast surface data for 3D visualization across multiple target dates. + + Returns a grid of forecasts where: + - Rows = target dates + - Columns = days_out (lead time brackets) + - Values = forecast values + + This creates a surface showing forecast evolution across both time and lead time. + """ + query = """ + SELECT + target_date, + days_out, + AVG(forecast_value) as avg_forecast, + AVG(actual_value) as avg_actual + FROM forecast_snapshots + WHERE target_date BETWEEN :from_date AND :to_date + AND metric_code = :metric_code + AND model = :model + GROUP BY target_date, days_out + ORDER BY target_date, days_out + """ + + result = await db.execute(text(query), { + "from_date": from_date, + "to_date": to_date, + "metric_code": metric_code, + "model": model + }) + rows = result.fetchall() + + # Organize into grid format + grid_data = {} + for row in rows: + date_str = str(row.target_date) + if date_str not in grid_data: + grid_data[date_str] = {"actual": float(row.avg_actual) if row.avg_actual else None} + grid_data[date_str][row.days_out] = float(row.avg_forecast) if row.avg_forecast else None + + return { + "metric_code": metric_code, + "model": model, + "from_date": str(from_date), + "to_date": str(to_date), + "data": grid_data + } + + +@router.get("/3d-monthly-progress") +async def get_3d_monthly_progress( + metric_code: str = Query("occupancy", description="Metric code"), + year: int = Query(..., description="Year (e.g., 2025)"), + month: int = Query(..., description="Month (1-12)"), + model: str = Query("blended", description="Model to show"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get forecast progress data for 3D visualization of a specific month. + + Shows how forecasts for a given month evolved over time as perception dates + approached the target dates. + + Returns: + - target_dates: Array of dates in the selected month (X axis) + - perception_dates: Array of unique perception dates (Y axis) + - surface_data: 2D array [perception_idx][target_idx] of forecast values (Z axis) + - actuals: Array of actual values for each target date + + This visualization shows how forecast accuracy improved over time. + """ + from calendar import monthrange + + # Calculate date range for the selected month + _, last_day = monthrange(year, month) + from_date = date(year, month, 1) + to_date = date(year, month, last_day) + + query = """ + SELECT + perception_date, + target_date, + forecast_value, + actual_value + FROM forecast_snapshots + WHERE target_date BETWEEN :from_date AND :to_date + AND metric_code = :metric_code + AND model = :model + ORDER BY perception_date, target_date + """ + + result = await db.execute(text(query), { + "from_date": from_date, + "to_date": to_date, + "metric_code": metric_code, + "model": model + }) + rows = result.fetchall() + + if not rows: + return { + "metric_code": metric_code, + "model": model, + "year": year, + "month": month, + "target_dates": [], + "perception_dates": [], + "surface_data": [], + "actuals": [] + } + + # Extract unique dates and build lookup + target_dates_set = set() + perception_dates_set = set() + forecasts = {} # (perception_date, target_date) -> forecast_value + actuals_map = {} # target_date -> actual_value + + for row in rows: + target_dates_set.add(row.target_date) + perception_dates_set.add(row.perception_date) + forecasts[(row.perception_date, row.target_date)] = row.forecast_value + if row.actual_value is not None: + actuals_map[row.target_date] = row.actual_value + + # Sort dates + target_dates = sorted(target_dates_set) + perception_dates = sorted(perception_dates_set) + + # Build surface data: surface_data[perception_idx][target_idx] + surface_data = [] + for p_date in perception_dates: + row_data = [] + for t_date in target_dates: + val = forecasts.get((p_date, t_date)) + row_data.append(float(val) if val is not None else None) + surface_data.append(row_data) + + # Actuals array aligned with target_dates + actuals = [float(actuals_map.get(t)) if t in actuals_map else None for t in target_dates] + + return { + "metric_code": metric_code, + "model": model, + "year": year, + "month": month, + "target_dates": [str(d) for d in target_dates], + "perception_dates": [str(d) for d in perception_dates], + "surface_data": surface_data, + "actuals": actuals + } diff --git a/backend/api/backup.py b/backend/api/backup.py new file mode 100644 index 0000000..baf798c --- /dev/null +++ b/backend/api/backup.py @@ -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)) diff --git a/backend/api/bookability.py b/backend/api/bookability.py new file mode 100644 index 0000000..c94d452 --- /dev/null +++ b/backend/api/bookability.py @@ -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}"} diff --git a/backend/api/budget.py b/backend/api/budget.py new file mode 100644 index 0000000..6daca34 --- /dev/null +++ b/backend/api/budget.py @@ -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" + } + ) diff --git a/backend/api/competitor_rates.py b/backend/api/competitor_rates.py new file mode 100644 index 0000000..12fffa6 --- /dev/null +++ b/backend/api/competitor_rates.py @@ -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() + ] diff --git a/backend/api/config.py b/backend/api/config.py new file mode 100644 index 0000000..d5c0dc1 --- /dev/null +++ b/backend/api/config.py @@ -0,0 +1,1293 @@ +""" +Configuration API endpoints +Manage system configuration including API credentials +""" +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, UploadFile, File, Form +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from pydantic import BaseModel +import base64 +import io +import logging + +from database import get_db +from auth import get_current_user, get_all_api_keys, create_api_key, revoke_api_key, delete_api_key + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ============================================ +# NEWBOOK SETTINGS ENDPOINTS +# ============================================ + +class NewbookSettingsResponse(BaseModel): + newbook_api_key: Optional[str] = None + newbook_api_key_set: bool = False + newbook_username: Optional[str] = None + newbook_password_set: bool = False + newbook_region: Optional[str] = None + + +class NewbookSettingsUpdate(BaseModel): + newbook_api_key: Optional[str] = None + newbook_username: Optional[str] = None + newbook_password: Optional[str] = None + newbook_region: Optional[str] = None + + +@router.get("/settings/newbook", response_model=NewbookSettingsResponse) +async def get_newbook_settings( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get all Newbook settings in one response""" + result = await db.execute( + text(""" + SELECT config_key, config_value, is_encrypted + FROM system_config + WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region') + """) + ) + rows = result.fetchall() + + settings = {} + for row in rows: + key = row.config_key + value = row.config_value + is_encrypted = row.is_encrypted + + if key == 'newbook_api_key': + settings['newbook_api_key'] = None + settings['newbook_api_key_set'] = bool(value) + elif key == 'newbook_username': + settings['newbook_username'] = value + elif key == 'newbook_password': + settings['newbook_password_set'] = bool(value) + elif key == 'newbook_region': + settings['newbook_region'] = value + + return NewbookSettingsResponse(**settings) + + +@router.post("/settings/newbook") +async def update_newbook_settings( + settings: NewbookSettingsUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Update Newbook settings""" + username = current_user.get("username") + + # Only update fields that are provided (not None) + if settings.newbook_api_key is not None: + encrypted_value = simple_encrypt(settings.newbook_api_key) + await db.execute( + text(""" + UPDATE system_config + SET config_value = :value, is_encrypted = true, updated_at = NOW(), updated_by = :username + WHERE config_key = 'newbook_api_key' + """), + {"value": encrypted_value, "username": username} + ) + + if settings.newbook_username is not None: + await db.execute( + text(""" + UPDATE system_config + SET config_value = :value, updated_at = NOW(), updated_by = :username + WHERE config_key = 'newbook_username' + """), + {"value": settings.newbook_username, "username": username} + ) + + if settings.newbook_password is not None: + encrypted_value = simple_encrypt(settings.newbook_password) + await db.execute( + text(""" + UPDATE system_config + SET config_value = :value, is_encrypted = true, updated_at = NOW(), updated_by = :username + WHERE config_key = 'newbook_password' + """), + {"value": encrypted_value, "username": username} + ) + + if settings.newbook_region is not None: + await db.execute( + text(""" + UPDATE system_config + SET config_value = :value, updated_at = NOW(), updated_by = :username + WHERE config_key = 'newbook_region' + """), + {"value": settings.newbook_region, "username": username} + ) + + await db.commit() + return {"status": "saved", "message": "Newbook settings updated"} + + +@router.post("/settings/newbook/test") +async def test_newbook_settings( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Test Newbook connection with current settings""" + return await _test_newbook(db) + + +# ============================================ +# RESOS SETTINGS ENDPOINTS +# ============================================ + +class ResosSettingsResponse(BaseModel): + resos_api_key_set: bool = False + + +class ResosSettingsUpdate(BaseModel): + resos_api_key: Optional[str] = None + + +@router.get("/settings/resos", response_model=ResosSettingsResponse) +async def get_resos_settings( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get Resos settings""" + result = await db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'resos_api_key'") + ) + row = result.fetchone() + + resos_api_key_set = bool(row and row.config_value) + + return ResosSettingsResponse(resos_api_key_set=resos_api_key_set) + + +@router.post("/settings/resos") +async def update_resos_settings( + settings: ResosSettingsUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Update Resos settings""" + # Update Resos API key (encrypted) + if settings.resos_api_key: + encrypted_key = base64.b64encode(settings.resos_api_key.encode()).decode() + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, is_encrypted, updated_at, updated_by) + VALUES ('resos_api_key', :value, true, NOW(), :user) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :value, + is_encrypted = true, + updated_at = NOW(), + updated_by = :user + """), + {"value": encrypted_key, "user": current_user['username']} + ) + + await db.commit() + return {"status": "saved", "message": "Resos settings updated"} + + +@router.post("/settings/resos/test") +async def test_resos_settings( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Test Resos connection with current settings""" + try: + from services.resos_client import ResosClient + async with await ResosClient.from_db(db) as client: + if not client.api_key: + raise HTTPException(status_code=400, detail="Resos API key not configured") + success = await client.test_connection() + if success: + return {"status": "success", "message": "Connected to Resos API successfully"} + else: + raise HTTPException(status_code=400, detail="Connection failed - check API key") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Connection test failed: {str(e)}") + + +# ============================================ +# ROOM CATEGORIES ENDPOINTS +# ============================================ + +from typing import List + +class RoomCategoryResponse(BaseModel): + id: int + site_id: str + site_name: str + site_type: Optional[str] = None + room_count: int = 0 + is_included: bool = True + display_order: int = 0 + + class Config: + from_attributes = True + + +class RoomCategoryUpdate(BaseModel): + id: int + is_included: Optional[bool] = None + display_order: Optional[int] = None + + +class RoomCategoryBulkUpdate(BaseModel): + updates: List[RoomCategoryUpdate] + + +@router.get("/room-categories", response_model=List[RoomCategoryResponse]) +async def get_room_categories( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get all room categories""" + result = await db.execute( + text(""" + SELECT id, site_id, site_name, site_type, room_count, is_included, display_order + FROM newbook_room_categories + ORDER BY display_order, site_name + """) + ) + rows = result.fetchall() + + return [ + { + "id": row.id, + "site_id": row.site_id, + "site_name": row.site_name, + "site_type": row.site_type, + "room_count": row.room_count, + "is_included": row.is_included, + "display_order": row.display_order + } + for row in rows + ] + + +@router.post("/room-categories/fetch") +async def fetch_room_categories( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Fetch room categories from Newbook API site_list endpoint""" + import httpx + + api_key = await _get_config_value(db, "newbook_api_key") + username = await _get_config_value(db, "newbook_username") + password = await _get_config_value(db, "newbook_password") + region = await _get_config_value(db, "newbook_region") + + if not all([api_key, username, password, region]): + raise HTTPException(status_code=400, detail="Newbook credentials not configured") + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + "https://api.newbook.cloud/rest/site_list", + json={ + "region": region, + "api_key": api_key + }, + auth=(username, password), + headers={"Content-Type": "application/json"} + ) + + if response.status_code != 200: + raise HTTPException(status_code=400, detail=f"Newbook API error: {response.status_code}") + + data = response.json() + if not data.get("success"): + raise HTTPException(status_code=400, detail=data.get("message", "API request failed")) + + sites = data.get("data", []) + + # Aggregate by category_id - Newbook returns: + # - category_id: numeric category ID (e.g., "56") + # - category_name: category name (e.g., "Holiday Rentals") + # - site_id: individual room ID (not what we want) + # - site_name: individual room name (not what we want) + room_categories = {} + for site in sites: + cat_id = site.get("category_id") + cat_name = site.get("category_name") or "Unknown" + + if not cat_id: + continue + + cat_id_str = str(cat_id) + if cat_id_str not in room_categories: + room_categories[cat_id_str] = { + "site_id": cat_id_str, + "site_name": cat_name, + "room_count": 0 + } + room_categories[cat_id_str]["room_count"] += 1 + + # Upsert room categories - create new ones if they don't exist + updated = 0 + created = 0 + for cat_id, cat in room_categories.items(): + result = await db.execute( + text(""" + INSERT INTO newbook_room_categories (site_id, site_name, site_type, room_count, fetched_at) + VALUES (:site_id, :site_name, :site_name, :room_count, NOW()) + ON CONFLICT (site_id) DO UPDATE SET + site_name = EXCLUDED.site_name, + room_count = EXCLUDED.room_count, + fetched_at = NOW() + """), + cat + ) + if result.rowcount > 0: + updated += 1 + + await db.commit() + + return { + "status": "success", + "count": updated, + "message": f"Updated {updated} room categories ({len(sites)} total rooms from API)" + } + + except httpx.TimeoutException: + raise HTTPException(status_code=400, detail="Connection timed out") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.patch("/room-categories/bulk-update") +async def bulk_update_room_categories( + request: RoomCategoryBulkUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Bulk update room category is_included flag and/or display_order""" + updated = 0 + for upd in request.updates: + # Build dynamic update based on provided fields + set_clauses = [] + params = {"id": upd.id} + + if upd.is_included is not None: + set_clauses.append("is_included = :is_included") + params["is_included"] = upd.is_included + + if upd.display_order is not None: + set_clauses.append("display_order = :display_order") + params["display_order"] = upd.display_order + + if not set_clauses: + continue # Nothing to update + + query = f""" + UPDATE newbook_room_categories + SET {', '.join(set_clauses)} + WHERE id = :id + """ + result = await db.execute(text(query), params) + if result.rowcount > 0: + updated += 1 + + await db.commit() + return {"status": "success", "updated": updated} + + +# ============================================ +# GL ACCOUNT ENDPOINTS +# ============================================ + +class GLAccountResponse(BaseModel): + id: int + gl_account_id: str + gl_code: Optional[str] = None + gl_name: Optional[str] = None + gl_group_id: Optional[str] = None + gl_group_name: Optional[str] = None + department: Optional[str] = None + is_active: bool = True + + class Config: + from_attributes = True + + +class GLAccountDepartmentUpdate(BaseModel): + id: int + department: Optional[str] = None # 'accommodation', 'dry', 'wet', or null + + +class GLAccountBulkDepartmentUpdate(BaseModel): + updates: List[GLAccountDepartmentUpdate] + + +@router.get("/gl-accounts", response_model=List[GLAccountResponse]) +async def get_gl_accounts( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get all GL accounts sorted by group then name""" + result = await db.execute( + text(""" + SELECT id, gl_account_id, gl_code, gl_name, gl_group_id, gl_group_name, department, is_active + FROM newbook_gl_accounts + WHERE is_active = TRUE + ORDER BY gl_group_name NULLS LAST, gl_name + """) + ) + rows = result.fetchall() + + return [ + { + "id": row.id, + "gl_account_id": row.gl_account_id, + "gl_code": row.gl_code, + "gl_name": row.gl_name, + "gl_group_id": row.gl_group_id, + "gl_group_name": row.gl_group_name, + "department": row.department, + "is_active": row.is_active + } + for row in rows + ] + + +@router.post("/gl-accounts/fetch") +async def fetch_gl_accounts( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Fetch GL accounts from Newbook API gl_account_list endpoint""" + import httpx + + api_key = await _get_config_value(db, "newbook_api_key") + username = await _get_config_value(db, "newbook_username") + password = await _get_config_value(db, "newbook_password") + region = await _get_config_value(db, "newbook_region") + + if not all([api_key, username, password, region]): + raise HTTPException(status_code=400, detail="Newbook credentials not configured") + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + "https://api.newbook.cloud/rest/gl_account_list", + json={ + "region": region, + "api_key": api_key + }, + auth=(username, password), + headers={"Content-Type": "application/json"} + ) + + if response.status_code != 200: + raise HTTPException(status_code=400, detail=f"Newbook API error: {response.status_code}") + + data = response.json() + if not data.get("success"): + raise HTTPException(status_code=400, detail=data.get("message", "API request failed")) + + accounts = data.get("data", []) + + # Debug: Log first account to see actual field names + import logging + logger = logging.getLogger(__name__) + if accounts and len(accounts) > 0: + logger.info(f"GL Account first item keys: {list(accounts[0].keys())}") + logger.info(f"GL Account first item: {accounts[0]}") + + # Upsert GL accounts - preserve existing department mappings + # Newbook API field names: gl_account_id, gl_account_name, gl_account_code, gl_group_id, gl_group_name + for acc in accounts: + # Get account ID - prefer gl_account_id, fall back to id + gl_account_id = str(acc.get("gl_account_id") or acc.get("id") or "") + if not gl_account_id: + continue + + # Get account name - Newbook uses gl_account_name + gl_name = acc.get("gl_account_name") or acc.get("name") or "" + + # Get account code - Newbook uses gl_account_code + gl_code = acc.get("gl_account_code") or acc.get("code") or "" + # Extract code from name if not available (format: "4100 - Room Revenue") + if not gl_code and " - " in gl_name: + gl_code = gl_name.split(" - ")[0].strip() + + # Get group info - Newbook uses gl_group_id and gl_group_name directly + gl_group_id = str(acc.get("gl_group_id") or "") + gl_group_name = acc.get("gl_group_name") or "" + + await db.execute( + text(""" + INSERT INTO newbook_gl_accounts (gl_account_id, gl_code, gl_name, gl_group_id, gl_group_name, fetched_at) + VALUES (:gl_account_id, :gl_code, :gl_name, :gl_group_id, :gl_group_name, NOW()) + ON CONFLICT (gl_account_id) DO UPDATE SET + gl_code = EXCLUDED.gl_code, + gl_name = EXCLUDED.gl_name, + gl_group_id = EXCLUDED.gl_group_id, + gl_group_name = EXCLUDED.gl_group_name, + fetched_at = NOW() + """), + { + "gl_account_id": gl_account_id, + "gl_code": gl_code, + "gl_name": gl_name, + "gl_group_id": gl_group_id, + "gl_group_name": gl_group_name + } + ) + + await db.commit() + + return { + "status": "success", + "count": len(accounts), + "message": f"Fetched {len(accounts)} GL accounts" + } + + except httpx.TimeoutException: + raise HTTPException(status_code=400, detail="Connection timed out") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.patch("/gl-accounts/department") +async def update_gl_account_departments( + request: GLAccountBulkDepartmentUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Bulk update GL account department mappings""" + updated = 0 + for upd in request.updates: + # Validate department value + if upd.department and upd.department not in ('accommodation', 'dry', 'wet'): + raise HTTPException(status_code=400, detail=f"Invalid department: {upd.department}") + + result = await db.execute( + text(""" + UPDATE newbook_gl_accounts + SET department = :department + WHERE id = :id + """), + {"id": upd.id, "department": upd.department} + ) + if result.rowcount > 0: + updated += 1 + + await db.commit() + return {"status": "success", "updated": updated} + + +# ============================================ +# GENERIC CONFIG ENDPOINTS +# ============================================ + +class ConfigValue(BaseModel): + key: str + value: str + is_encrypted: bool = False + + +class ConfigResponse(BaseModel): + key: str + value: Optional[str] + description: Optional[str] + + +def simple_encrypt(value: str) -> str: + """Simple obfuscation for sensitive values (use proper encryption in production)""" + return base64.b64encode(value.encode()).decode() + + +def simple_decrypt(value: str) -> str: + """Decrypt obfuscated values""" + try: + return base64.b64decode(value.encode()).decode() + except: + return value + + +@router.get("/gl-accounts") +async def get_gl_accounts( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get cached GL accounts from Newbook (for reference when configuring GL code mapping). + """ + result = await db.execute( + text("SELECT gl_account_id, gl_code, gl_name, is_active FROM newbook_gl_accounts ORDER BY gl_code") + ) + rows = result.fetchall() + + return [ + { + "gl_account_id": row.gl_account_id, + "gl_code": row.gl_code, + "gl_name": row.gl_name, + "is_active": row.is_active + } + for row in rows + ] + + +@router.get("/") +async def list_config( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + List all configuration keys with masked values for encrypted fields. + """ + result = await db.execute( + text("SELECT config_key, config_value, is_encrypted, description, updated_at FROM system_config ORDER BY config_key") + ) + rows = result.fetchall() + + return [ + { + "key": row.config_key, + "value": "********" if row.is_encrypted and row.config_value else row.config_value, + "is_encrypted": row.is_encrypted, + "description": row.description, + "updated_at": row.updated_at, + "is_set": row.config_value is not None and row.config_value != "" + } + for row in rows + ] + + +@router.post("/") +async def set_config( + config: ConfigValue, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Set a configuration value. + Sensitive values (API keys, passwords) are encrypted before storage. + """ + value = config.value + if config.is_encrypted and value: + value = simple_encrypt(value) + + result = await db.execute( + text(""" + UPDATE system_config + SET config_value = :value, + is_encrypted = :is_encrypted, + updated_at = NOW(), + updated_by = :username + WHERE config_key = :key + RETURNING config_key + """), + { + "key": config.key, + "value": value, + "is_encrypted": config.is_encrypted, + "username": current_user.get("username") + } + ) + await db.commit() + + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail=f"Config key not found: {config.key}") + + return {"status": "saved", "key": config.key} + + +@router.post("/test/{api}") +async def test_api_connection( + api: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Test connection to an external API using stored credentials. + """ + if api == "newbook": + return await _test_newbook(db) + elif api == "resos": + return await _test_resos(db) + else: + raise HTTPException(status_code=400, detail=f"Unknown API: {api}") + + +async def _get_config_value(db: AsyncSession, key: str) -> Optional[str]: + """Get a config value, decrypting if necessary""" + result = await db.execute( + text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + if not row or not row.config_value: + return None + + if row.is_encrypted: + return simple_decrypt(row.config_value) + return row.config_value + + +async def _test_newbook(db: AsyncSession): + """Test Newbook API connection with full credentials""" + import httpx + import logging + + logger = logging.getLogger(__name__) + + api_key = await _get_config_value(db, "newbook_api_key") + username = await _get_config_value(db, "newbook_username") + password = await _get_config_value(db, "newbook_password") + region = await _get_config_value(db, "newbook_region") + + # Log what we have (masked) + logger.info(f"Testing Newbook: api_key={'set' if api_key else 'empty'}, username={username}, region={region}") + + # Check we have all required credentials + missing = [] + if not api_key: + missing.append("API Key") + if not username: + missing.append("Username") + if not password: + missing.append("Password") + if not region: + missing.append("Region") + + if missing: + raise HTTPException( + status_code=400, + detail=f"Missing required credentials: {', '.join(missing)}" + ) + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + # Test with api_keys endpoint - uses Basic Auth + JSON body + response = await client.post( + "https://api.newbook.cloud/rest/api_keys", + json={ + "region": region, + "api_key": api_key, + "list_type": "inhouse" + }, + auth=(username, password), + headers={"Content-Type": "application/json"} + ) + + logger.info(f"Newbook test response status: {response.status_code}") + + if response.status_code == 200: + data = response.json() + if data.get("success"): + return { + "status": "connected", + "message": "Newbook connection successful!" + } + else: + error_msg = data.get("message", "Authentication failed") + raise HTTPException(status_code=400, detail=f"Newbook API error: {error_msg}") + else: + try: + error_data = response.json() + error_msg = error_data.get("message", str(error_data)) + except: + error_msg = response.text[:200] if response.text else "No details" + + raise HTTPException( + status_code=400, + detail=f"Newbook authentication failed ({response.status_code}): {error_msg}" + ) + + except httpx.TimeoutException: + raise HTTPException(status_code=400, detail="Connection timed out") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +async def _test_resos(db: AsyncSession): + """Test Resos API connection""" + import httpx + + api_key = await _get_config_value(db, "resos_api_key") + + if not api_key: + raise HTTPException(status_code=400, detail="Resos API key not configured") + + try: + auth_header = f"Basic {base64.b64encode(f'{api_key}:'.encode()).decode()}" + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get( + "https://api.resos.com/v1/openingHours", + headers={"Authorization": auth_header} + ) + + if response.status_code == 200: + return {"status": "connected", "message": "Resos connection successful"} + elif response.status_code == 401: + raise HTTPException(status_code=400, detail="Invalid API key") + else: + raise HTTPException(status_code=400, detail=f"Resos returned status {response.status_code}") + + except httpx.TimeoutException: + raise HTTPException(status_code=400, detail="Connection timed out") + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +# ============================================ +# TAX RATES ENDPOINTS +# ============================================ + +class TaxRateCreate(BaseModel): + tax_type: str + rate: float # e.g., 0.20 for 20% + effective_from: str # Date string YYYY-MM-DD + + +class TaxRateResponse(BaseModel): + id: int + tax_type: str + rate: float + effective_from: str + created_at: str + + +@router.get("/tax-rates") +async def get_tax_rates( + tax_type: Optional[str] = None, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get all tax rates, optionally filtered by type. + Returns rates ordered by effective_from date descending. + """ + query = """ + SELECT id, tax_type, rate, effective_from, created_at + FROM tax_rates + """ + params = {} + + if tax_type: + query += " WHERE tax_type = :tax_type" + params["tax_type"] = tax_type + + query += " ORDER BY tax_type, effective_from DESC" + + result = await db.execute(text(query), params) + rows = result.fetchall() + + return [ + { + "id": row.id, + "tax_type": row.tax_type, + "rate": float(row.rate), + "effective_from": str(row.effective_from), + "created_at": str(row.created_at) if row.created_at else None + } + for row in rows + ] + + +@router.get("/tax-rates/effective") +async def get_effective_tax_rate( + tax_type: str, + as_of_date: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get the effective tax rate for a given type and date. + Returns the rate that was in effect on the specified date. + """ + result = await db.execute( + text(""" + SELECT id, tax_type, rate, effective_from + FROM tax_rates + WHERE tax_type = :tax_type + AND effective_from <= :as_of_date + ORDER BY effective_from DESC + LIMIT 1 + """), + {"tax_type": tax_type, "as_of_date": as_of_date} + ) + row = result.fetchone() + + if not row: + raise HTTPException( + status_code=404, + detail=f"No tax rate found for {tax_type} as of {as_of_date}" + ) + + return { + "tax_type": row.tax_type, + "rate": float(row.rate), + "effective_from": str(row.effective_from) + } + + +@router.post("/tax-rates") +async def create_tax_rate( + tax_rate: TaxRateCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Create a new tax rate entry. + Each entry has an effective_from date - the rate applies from that date + until a newer rate entry takes effect. + """ + try: + result = await db.execute( + text(""" + INSERT INTO tax_rates (tax_type, rate, effective_from) + VALUES (:tax_type, :rate, :effective_from) + RETURNING id, tax_type, rate, effective_from, created_at + """), + { + "tax_type": tax_rate.tax_type, + "rate": tax_rate.rate, + "effective_from": tax_rate.effective_from + } + ) + await db.commit() + row = result.fetchone() + + return { + "status": "created", + "tax_rate": { + "id": row.id, + "tax_type": row.tax_type, + "rate": float(row.rate), + "effective_from": str(row.effective_from), + "created_at": str(row.created_at) + } + } + except Exception as e: + await db.rollback() + if "unique constraint" in str(e).lower(): + raise HTTPException( + status_code=400, + detail=f"Tax rate for {tax_rate.tax_type} already exists for date {tax_rate.effective_from}" + ) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/tax-rates/{rate_id}") +async def delete_tax_rate( + rate_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Delete a tax rate entry by ID.""" + result = await db.execute( + text("DELETE FROM tax_rates WHERE id = :id RETURNING id"), + {"id": rate_id} + ) + await db.commit() + + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail=f"Tax rate {rate_id} not found") + + return {"status": "deleted", "id": rate_id} + + +# ============================================ +# FORECAST SNAPSHOT SETTINGS +# ============================================ + +class ForecastSnapshotSettings(BaseModel): + """Forecast snapshot automation settings""" + enabled: bool = False + time: str = "06:00" + models: str = "prophet,xgboost,catboost,blended" + days_ahead: int = 90 + + +@router.get("/settings/forecast-snapshot", response_model=ForecastSnapshotSettings) +async def get_forecast_snapshot_settings( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get forecast snapshot automation settings.""" + result = await db.execute( + text(""" + SELECT config_key, config_value + FROM system_config + WHERE config_key IN ( + 'forecast_snapshot_enabled', + 'forecast_snapshot_time', + 'forecast_snapshot_models', + 'forecast_snapshot_days_ahead' + ) + """) + ) + rows = result.fetchall() + + config_dict = {row.config_key: row.config_value for row in rows} + + return ForecastSnapshotSettings( + enabled=config_dict.get('forecast_snapshot_enabled', 'false').lower() in ('true', '1', 'yes', 'enabled'), + time=config_dict.get('forecast_snapshot_time', '06:00'), + models=config_dict.get('forecast_snapshot_models', 'prophet,xgboost,catboost,blended'), + days_ahead=int(config_dict.get('forecast_snapshot_days_ahead', '90')) + ) + + +@router.post("/settings/forecast-snapshot") +async def update_forecast_snapshot_settings( + settings: ForecastSnapshotSettings, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Update forecast snapshot automation settings.""" + # Update or insert each setting + config_updates = { + 'forecast_snapshot_enabled': 'true' if settings.enabled else 'false', + 'forecast_snapshot_time': settings.time, + 'forecast_snapshot_models': settings.models, + 'forecast_snapshot_days_ahead': str(settings.days_ahead) + } + + for key, value in config_updates.items(): + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, updated_at) + VALUES (:key, :value, NOW()) + ON CONFLICT (config_key) + DO UPDATE SET config_value = :value, updated_at = NOW() + """), + {"key": key, "value": value} + ) + + await db.commit() + + return {"status": "updated", "message": "Forecast snapshot settings saved successfully"} + + +@router.post("/settings/forecast-snapshot/test") +async def test_forecast_snapshot( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Manually trigger a forecast snapshot for testing.""" + from jobs.weekly_forecast_snapshot import run_weekly_forecast_snapshot + + try: + await run_weekly_forecast_snapshot() + return {"status": "success", "message": "Forecast snapshot completed successfully"} + except Exception as e: + logger.error(f"Manual forecast snapshot failed: {e}") + raise HTTPException(status_code=500, detail=f"Forecast snapshot failed: {str(e)}") + + +# ============================================ +# API KEY MANAGEMENT ENDPOINTS +# ============================================ + +class ApiKeyCreate(BaseModel): + name: str + + +@router.get("/api-keys") +async def list_api_keys( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + List all API keys (without showing the actual key values). + Returns key prefix, name, status, and usage info. + """ + keys = await get_all_api_keys(db) + return {"keys": keys} + + +@router.post("/api-keys") +async def create_new_api_key( + request: ApiKeyCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Generate a new API key. + IMPORTANT: The full key is only returned ONCE in this response. + Store it securely - it cannot be retrieved again. + """ + if not request.name or len(request.name.strip()) < 2: + raise HTTPException(status_code=400, detail="Name must be at least 2 characters") + + key_data = await create_api_key( + db, + name=request.name.strip(), + created_by=current_user.get("username", "unknown") + ) + + return { + "status": "created", + "message": "API key created. Copy the key now - it will not be shown again!", + "key": key_data["key"], # Full key - only time it's shown! + "id": key_data["id"], + "name": key_data["name"], + "key_prefix": key_data["key_prefix"], + "created_at": key_data["created_at"] + } + + +@router.post("/api-keys/{key_id}/revoke") +async def revoke_key( + key_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Revoke (deactivate) an API key. + The key will no longer work but record is kept for audit purposes. + """ + await revoke_api_key(db, key_id) + return {"status": "revoked", "message": "API key has been revoked"} + + +@router.delete("/api-keys/{key_id}") +async def delete_key( + key_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Permanently delete an API key. + """ + await delete_api_key(db, key_id) + return {"status": "deleted", "message": "API key has been deleted"} + + +# ============================================ +# AI INSIGHTS SETTINGS +# ============================================ + +class AIInsightsSettingsResponse(BaseModel): + enabled: bool = False + api_key_set: bool = False + model: str = "claude-haiku-4-5-20251001" + schedule_time: str = "07:15" + daily_token_budget: int = 5000 + + +class AIInsightsSettingsUpdate(BaseModel): + enabled: Optional[bool] = None + api_key: Optional[str] = None + model: Optional[str] = None + schedule_time: Optional[str] = None + daily_token_budget: Optional[int] = None + + +@router.get("/settings/ai-insights", response_model=AIInsightsSettingsResponse) +async def get_ai_insights_settings( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get AI Insights configuration.""" + result = await db.execute( + text(""" + SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted + FROM system_config + WHERE config_key LIKE 'ai_insights_%' + """) + ) + config = {} + for row in result.fetchall(): + config[row.config_key] = row.config_value + if row.config_key == 'ai_insights_api_key': + config['_api_key_set'] = bool(row.config_value) + + return AIInsightsSettingsResponse( + enabled=config.get('ai_insights_enabled', 'false').lower() in ('true', '1', 'yes'), + api_key_set=config.get('_api_key_set', False), + model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'), + schedule_time=config.get('ai_insights_schedule_time', '07:15'), + daily_token_budget=int(config.get('ai_insights_daily_token_budget', '5000')), + ) + + +@router.post("/settings/ai-insights") +async def update_ai_insights_settings( + settings: AIInsightsSettingsUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Update AI Insights configuration.""" + updates = {} + if settings.enabled is not None: + updates['ai_insights_enabled'] = ('true' if settings.enabled else 'false', False) + if settings.api_key is not None and settings.api_key.strip(): + updates['ai_insights_api_key'] = (simple_encrypt(settings.api_key.strip()), True) + if settings.model is not None: + updates['ai_insights_model'] = (settings.model, False) + if settings.schedule_time is not None: + updates['ai_insights_schedule_time'] = (settings.schedule_time, False) + if settings.daily_token_budget is not None: + updates['ai_insights_daily_token_budget'] = (str(settings.daily_token_budget), False) + + for key, (value, encrypted) in updates.items(): + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, is_encrypted, updated_at) + VALUES (:key, :value, :encrypted, NOW()) + ON CONFLICT (config_key) DO UPDATE + SET config_value = :value, is_encrypted = :encrypted, updated_at = NOW() + """), + {"key": key, "value": value, "encrypted": encrypted} + ) + await db.commit() + + return {"status": "saved", "keys_updated": list(updates.keys())} + + +@router.post("/settings/ai-insights/test") +async def test_ai_insights_connection( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Test Anthropic API connection with stored API key.""" + api_key = await _get_config_value(db, "ai_insights_api_key") + if not api_key: + raise HTTPException(status_code=400, detail="No API key configured") + + model = await _get_config_value(db, "ai_insights_model") or "claude-haiku-4-5-20251001" + + try: + import anthropic + client = anthropic.AsyncAnthropic(api_key=api_key) + try: + response = await client.messages.create( + model=model, + max_tokens=10, + messages=[{"role": "user", "content": "Say 'connected' in one word."}] + ) + return { + "status": "connected", + "message": f"Successfully connected to {model}", + "response": response.content[0].text if response.content else "", + } + finally: + await client.close() + except anthropic.AuthenticationError: + raise HTTPException(status_code=401, detail="Invalid API key") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Connection failed: {str(e)}") + diff --git a/backend/api/crossref.py b/backend/api/crossref.py new file mode 100644 index 0000000..4bc54d3 --- /dev/null +++ b/backend/api/crossref.py @@ -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"} + } diff --git a/backend/api/evolution.py b/backend/api/evolution.py new file mode 100644 index 0000000..8d032b0 --- /dev/null +++ b/backend/api/evolution.py @@ -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 + ] diff --git a/backend/api/explain.py b/backend/api/explain.py new file mode 100644 index 0000000..73915a9 --- /dev/null +++ b/backend/api/explain.py @@ -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 + })() + ) + + diff --git a/backend/api/export.py b/backend/api/export.py new file mode 100644 index 0000000..c15ae68 --- /dev/null +++ b/backend/api/export.py @@ -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}"} + ) diff --git a/backend/api/forecast.py b/backend/api/forecast.py new file mode 100644 index 0000000..e6c5175 --- /dev/null +++ b/backend/api/forecast.py @@ -0,0 +1,3727 @@ +""" +Forecast API endpoints +""" +import asyncio +import math +from datetime import date, timedelta +from typing import Optional, List + +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 +from api.special_dates import resolve_special_date +from utils.capacity import get_bookable_cap + +router = APIRouter() + + +# Metric column mapping - defines how to get historical data for each metric +# Each entry: (column_expression, needs_revenue_join, is_percentage) +METRIC_COLUMN_MAP = { + 'occupancy': ('s.total_occupancy_pct', False, True), + 'rooms': ('s.booking_count', False, False), + 'guests': ('s.guests_count', False, False), + 'ave_guest_rate': ('s.guest_rate_total / NULLIF(s.booking_count, 0)', False, False), + 'arr': ('r.accommodation / NULLIF(s.booking_count, 0)', True, False), + 'net_accom': ('r.accommodation', True, False), + 'net_dry': ('r.dry', True, False), + 'net_wet': ('r.wet', True, False), + 'total_rev': ('COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0)', True, False), +} + + +def get_metric_query_parts(metric: str) -> tuple: + """ + Get SQL query parts for a metric. + Returns: (column_expr, from_clause, is_percentage) + """ + if metric not in METRIC_COLUMN_MAP: + # Default to rooms if unknown metric + metric = 'rooms' + + col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric] + + if needs_revenue: + from_clause = """ + FROM newbook_bookings_stats s + LEFT JOIN newbook_net_revenue_data r ON s.date = r.date + """ + else: + from_clause = "FROM newbook_bookings_stats s" + + return col_expr, from_clause, is_pct + + +def round_towards_reference(value: float, reference: Optional[float]) -> int: + """ + Round a forecast value towards a reference value (prior year actual). + + - If forecast < reference: round up (ceil) towards reference + - If forecast > reference: round down (floor) towards reference + - If no reference: use standard rounding + + Examples: + - 24.2 with prior year 25 → 25 (ceil towards reference) + - 22.8 with prior year 20 → 22 (floor towards reference) + """ + if reference is None: + return round(value) + + if value < reference: + return math.ceil(value) + else: + return math.floor(value) + + +class ForecastResponse(BaseModel): + date: date + metric_code: str + metric_name: str + prophet_value: Optional[float] + prophet_lower: Optional[float] + prophet_upper: Optional[float] + xgboost_value: Optional[float] + pickup_value: Optional[float] + current_otb: Optional[float] + budget_value: Optional[float] + + +class DailyForecastSummary(BaseModel): + date: date + day_of_week: str + hotel_occupancy_pct: Optional[float] + hotel_guests: Optional[float] + hotel_arrivals: Optional[float] + hotel_adr: Optional[float] + resos_lunch_covers: Optional[float] + resos_dinner_covers: Optional[float] + model_used: str + + +@router.get("/daily") +async def get_daily_forecasts( + from_date: Optional[date] = Query(None, description="Start date (default: today)"), + to_date: Optional[date] = Query(None, description="End date (default: +14 days)"), + metric: Optional[str] = Query(None, description="Filter by metric code"), + model: Optional[str] = Query(None, description="Filter by model type: prophet, xgboost, pickup, all"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get daily forecasts with all models side-by-side. + Returns forecasts for each date with Prophet, XGBoost, Pickup values and confidence intervals. + """ + if from_date is None: + from_date = date.today() + if to_date is None: + to_date = from_date + timedelta(days=14) + + query = """ + SELECT + f.forecast_date, + f.forecast_type as metric_code, + fm.metric_name, + MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as prophet_value, + 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_value, + MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as pickup_value, + ps.otb_value as current_otb, + db.budget_value + FROM forecasts f + LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.metric_code + LEFT JOIN pickup_snapshots ps ON f.forecast_date = ps.stay_date + AND f.forecast_type = ps.metric_type + AND ps.snapshot_date = CURRENT_DATE + 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 + """ + + params = {"from_date": from_date, "to_date": to_date} + + if metric: + query += " AND f.forecast_type = :metric" + params["metric"] = metric + + query += """ + GROUP BY f.forecast_date, f.forecast_type, fm.metric_name, ps.otb_value, db.budget_value + ORDER BY f.forecast_date, fm.display_order + """ + + result = await db.execute(text(query), params) + rows = result.fetchall() + + return [ + { + "date": row.forecast_date, + "metric_code": row.metric_code, + "metric_name": row.metric_name, + "prophet_value": row.prophet_value, + "prophet_lower": row.prophet_lower, + "prophet_upper": row.prophet_upper, + "xgboost_value": row.xgboost_value, + "pickup_value": row.pickup_value, + "current_otb": row.current_otb, + "budget_value": row.budget_value + } + for row in rows + ] + + +@router.get("/weekly") +async def get_weekly_summary( + weeks: int = Query(8, description="Number of weeks to forecast"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get weekly summary forecast for the next N weeks. + Aggregates daily forecasts into weekly totals/averages. + """ + from_date = date.today() + to_date = from_date + timedelta(weeks=weeks) + + query = """ + WITH weekly_data AS ( + SELECT + DATE_TRUNC('week', f.forecast_date) as week_start, + f.forecast_type, + fm.metric_name, + fm.unit, + AVG(f.predicted_value) as avg_value, + SUM(f.predicted_value) as sum_value, + AVG(db.budget_value) as avg_budget, + SUM(db.budget_value) as sum_budget + FROM forecasts f + LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.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.model_type = 'prophet' + GROUP BY DATE_TRUNC('week', f.forecast_date), f.forecast_type, fm.metric_name, fm.unit + ) + SELECT + week_start, + forecast_type, + metric_name, + unit, + CASE + WHEN unit = 'percent' THEN avg_value + WHEN unit = 'decimal' THEN avg_value + ELSE sum_value + END as forecast_value, + CASE + WHEN unit = 'percent' THEN avg_budget + WHEN unit = 'decimal' THEN avg_budget + ELSE sum_budget + END as budget_value + FROM weekly_data + ORDER BY week_start, forecast_type + """ + + result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date}) + rows = result.fetchall() + + return [ + { + "week_start": row.week_start, + "metric_code": row.forecast_type, + "metric_name": row.metric_name, + "unit": row.unit, + "forecast_value": row.forecast_value, + "budget_value": row.budget_value, + "variance": (row.forecast_value - row.budget_value) if row.budget_value else None, + "variance_pct": ((row.forecast_value - row.budget_value) / row.budget_value * 100) if row.budget_value and row.budget_value != 0 else None + } + for row in rows + ] + + +@router.get("/comparison") +async def get_model_comparison( + from_date: Optional[date] = Query(None), + to_date: Optional[date] = Query(None), + metric: str = Query(..., description="Metric code to compare"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get side-by-side comparison of all forecasting models for a specific metric. + Includes prior year actual for the full date range (both actuals and forecasts). + Prior year uses 364-day offset (52 weeks) for day-of-week alignment. + """ + if from_date is None: + from_date = date.today() + if to_date is None: + to_date = from_date + timedelta(days=28) + + # Build comparison dict with all dates in range + # Generate dates in Python to avoid asyncpg parameter issues with generate_series + comparison = {} + current_date = from_date + while current_date <= to_date: + comparison[str(current_date)] = { + "date": current_date, + "actual": None, + "current_otb": None, + "budget": None, + "prior_year_actual": None, + "prior_year_otb": None, + "models": {} + } + current_date += timedelta(days=1) + + # Get actuals, OTB, and budget for dates with data + dates_query = """ + SELECT + dm.date as forecast_date, + dm.actual_value, + ps.otb_value as current_otb, + db.budget_value + FROM daily_metrics dm + LEFT JOIN pickup_snapshots ps ON dm.date = ps.stay_date + AND ps.metric_type = dm.metric_code + AND ps.snapshot_date = CURRENT_DATE + LEFT JOIN daily_budgets db ON dm.date = db.date AND db.budget_type = dm.metric_code + WHERE dm.date BETWEEN :from_date AND :to_date + AND dm.metric_code = :metric + ORDER BY dm.date + """ + + dates_result = await db.execute(text(dates_query), { + "from_date": from_date, + "to_date": to_date, + "metric": metric + }) + date_rows = dates_result.fetchall() + + # Update comparison dict with actual data + for row in date_rows: + date_str = str(row.forecast_date) + if date_str in comparison: + # Use 'is not None' - 0 is valid data + comparison[date_str]["actual"] = float(row.actual_value) if row.actual_value is not None else None + comparison[date_str]["current_otb"] = float(row.current_otb) if row.current_otb is not None else None + comparison[date_str]["budget"] = float(row.budget_value) if row.budget_value is not None else None + + # Get prior year actuals for ALL dates in the range + # Calculate prior year date range in Python (364 days = 52 weeks for DOW alignment) + prior_from = from_date - timedelta(days=364) + prior_to = to_date - timedelta(days=364) + + prior_year_query = """ + SELECT + dm.date as prior_date, + dm.actual_value as prior_year_actual + FROM daily_metrics dm + WHERE dm.date BETWEEN :prior_from AND :prior_to + AND dm.metric_code = :metric + """ + + prior_result = await db.execute(text(prior_year_query), { + "prior_from": prior_from, + "prior_to": prior_to, + "metric": metric + }) + prior_rows = prior_result.fetchall() + + # Map prior year dates to current year dates (+364 days) + for row in prior_rows: + target_date = row.prior_date + timedelta(days=364) + date_str = str(target_date) + if date_str in comparison: + comparison[date_str]["prior_year_actual"] = float(row.prior_year_actual) if row.prior_year_actual is not None else None + + # Get OTB, prior year OTB, and budget for future dates + future_data_query = """ + SELECT + ps.stay_date as forecast_date, + ps.otb_value as current_otb, + ps.prior_year_otb, + ps.prior_year_final, + db.budget_value + FROM pickup_snapshots ps + LEFT JOIN daily_budgets db ON ps.stay_date = db.date AND db.budget_type = ps.metric_type + WHERE ps.stay_date BETWEEN :from_date AND :to_date + AND ps.metric_type = :metric + AND ps.snapshot_date = CURRENT_DATE + """ + + future_result = await db.execute(text(future_data_query), { + "from_date": from_date, + "to_date": to_date, + "metric": metric + }) + future_rows = future_result.fetchall() + + for row in future_rows: + date_str = str(row.forecast_date) + if date_str in comparison: + if comparison[date_str]["current_otb"] is None: + comparison[date_str]["current_otb"] = float(row.current_otb) if row.current_otb is not None else None + if comparison[date_str]["budget"] is None: + comparison[date_str]["budget"] = float(row.budget_value) if row.budget_value is not None else None + # Add prior year OTB for pace comparison (0 is valid - means no bookings at that lead time) + comparison[date_str]["prior_year_otb"] = float(row.prior_year_otb) if row.prior_year_otb is not None else None + # Prior year final is the actual from 52 weeks ago + if comparison[date_str]["prior_year_actual"] is None and row.prior_year_final is not None: + comparison[date_str]["prior_year_actual"] = float(row.prior_year_final) + + # Now get forecasts to overlay + forecasts_query = """ + SELECT + f.forecast_date, + f.model_type, + f.predicted_value, + f.lower_bound, + f.upper_bound + FROM forecasts f + WHERE f.forecast_date BETWEEN :from_date AND :to_date + AND f.forecast_type = :metric + ORDER BY f.forecast_date, f.model_type + """ + + forecasts_result = await db.execute(text(forecasts_query), { + "from_date": from_date, + "to_date": to_date, + "metric": metric + }) + forecast_rows = forecasts_result.fetchall() + + for row in forecast_rows: + date_str = str(row.forecast_date) + if date_str in comparison: + comparison[date_str]["models"][row.model_type] = { + "value": float(row.predicted_value) if row.predicted_value else None, + "lower": float(row.lower_bound) if row.lower_bound else None, + "upper": float(row.upper_bound) if row.upper_bound else None + } + + return list(comparison.values()) + + +async def _run_forecast_in_background( + horizon_days: int, + start_days: int, + models: List[str], + triggered_by: str +): + """Background task to run forecast generation""" + from jobs.forecast_daily import run_daily_forecast + await run_daily_forecast( + horizon_days=horizon_days, + start_days=start_days, + models=models, + triggered_by=triggered_by + ) + + +@router.post("/regenerate") +async def regenerate_forecasts( + background_tasks: BackgroundTasks, + from_date: Optional[date] = Query(None), + to_date: Optional[date] = Query(None), + models: Optional[List[str]] = Query(None, description="Models to run: prophet, xgboost, pickup, catboost"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Force regenerate forecasts for a date range. + Triggers an immediate forecast run outside the schedule. + """ + if from_date is None: + from_date = date.today() + if to_date is None: + to_date = from_date + timedelta(days=14) + + start_days = (from_date - date.today()).days + horizon_days = (to_date - date.today()).days + models_to_run = models or ['prophet', 'xgboost', 'pickup', 'catboost'] + + # Run forecast in background + background_tasks.add_task( + _run_forecast_in_background, + horizon_days=horizon_days, + start_days=start_days, + models=models_to_run, + triggered_by=f"api:manual:{current_user.get('username', 'unknown')}" + ) + + return { + "status": "triggered", + "from_date": from_date, + "to_date": to_date, + "models": models_to_run, + "message": "Forecast regeneration started in background" + } + + +@router.get("/metrics") +async def get_forecast_metrics( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get list of all available forecast metrics with their configuration. + """ + query = """ + SELECT + metric_code, + metric_name, + category, + unit, + use_prophet, + use_xgboost, + use_pickup, + is_derived, + display_order, + show_in_dashboard, + decimal_places + FROM forecast_metrics + WHERE is_active = TRUE + ORDER BY display_order + """ + + result = await db.execute(text(query)) + rows = result.fetchall() + + return [ + { + "metric_code": row.metric_code, + "metric_name": row.metric_name, + "category": row.category, + "unit": row.unit, + "models": { + "prophet": row.use_prophet, + "xgboost": row.use_xgboost, + "pickup": row.use_pickup + }, + "is_derived": row.is_derived, + "display_order": row.display_order, + "show_in_dashboard": row.show_in_dashboard, + "decimal_places": row.decimal_places + } + for row in rows + ] + + +# ============================================ +# LIVE PREVIEW ENDPOINTS (No Logging) +# ============================================ + +# ============================================ +# LIVE PROPHET ENDPOINT +# ============================================ + +class ProphetDataPoint(BaseModel): + date: str + day_of_week: str + current_otb: Optional[float] + prior_year_otb: Optional[float] + forecast: Optional[float] + forecast_lower: Optional[float] + forecast_upper: Optional[float] + prior_year_final: Optional[float] + + +class ProphetSummary(BaseModel): + otb_total: float + prior_otb_total: float + forecast_total: float + prior_final_total: float + days_count: int + days_forecasting_more: int + days_forecasting_less: int + + +class ProphetResponse(BaseModel): + data: List[ProphetDataPoint] + summary: ProphetSummary + + +@router.get("/prophet-preview", response_model=ProphetResponse) +async def get_prophet_preview( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + metric: str = Query("occupancy", description="Metric: occupancy or rooms"), + perception_date: Optional[str] = Query(None, description="Optional: Generate forecast as if it was this date (YYYY-MM-DD) for backtesting"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Live forecast using Prophet model. + Trains on historical d0 (final) values and forecasts future dates. + No logging or persistence - pure read-only preview. + + If perception_date is provided, generates forecast as if it was that date, + training only on data available at that time (for backtesting). + """ + from datetime import datetime + from prophet import Prophet + import pandas as pd + import warnings + warnings.filterwarnings('ignore') + + # 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") + + # Use perception_date if provided, otherwise use actual today + actual_today = date.today() + if perception_date: + try: + today = datetime.strptime(perception_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid perception_date format. Use YYYY-MM-DD") + else: + today = actual_today + + is_backtest = perception_date is not None + + # Get default bookable cap (used as fallback for dates without specific data) + default_bookable_cap = await get_bookable_cap(db) + + # Get metric column and query parts + col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric) + + # Get historical data for Prophet training (past 2 years) + history_start = today - timedelta(days=730) + history_query = f""" + SELECT s.date as ds, {col_expr} as y + {from_clause} + WHERE s.date >= :history_start + AND s.date < :today + AND {col_expr} IS NOT NULL + ORDER BY s.date + """ + history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today}) + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + raise HTTPException(status_code=400, detail="Insufficient historical data for Prophet model") + + # Build training dataframe + df = pd.DataFrame([{"ds": row.ds, "y": float(row.y) if row.y is not None else 0} for row in history_rows]) + + # Set floor/cap based on metric type + if is_pct_metric: + # Percentage metrics (occupancy) + training_cap = 100 + elif metric == 'rooms': + # Room counts - cap at bookable rooms + training_cap = default_bookable_cap + elif metric == 'guests': + # Guests can exceed rooms (multiple per room) - use historical max * 1.5 + training_cap = df["y"].max() * 1.5 if len(df) > 0 and df["y"].max() > 0 else default_bookable_cap * 3 + else: + # Revenue/rate metrics - use percentile-based cap + training_cap = df["y"].quantile(0.99) * 1.5 if len(df) > 0 and df["y"].quantile(0.99) > 0 else 10000 + + df["floor"] = 0 + df["cap"] = training_cap + + # Train Prophet model with logistic growth (respects floor/cap) + model = Prophet( + growth='logistic', + yearly_seasonality=True, + weekly_seasonality=True, + daily_seasonality=False, + interval_width=0.8, + changepoint_prior_scale=0.05 + ) + + # Add UK holidays + model.add_country_holidays(country_name='UK') + + # Add custom special dates from settings + try: + from api.special_dates import get_special_dates_for_prophet + # Get special dates for training period + forecast period + min_year = history_start.year + max_year = end.year + 1 + custom_holidays = await get_special_dates_for_prophet(db, min_year, max_year) + + if custom_holidays: + # Create holidays dataframe for Prophet + holidays_df = pd.DataFrame(custom_holidays) + # Group by holiday name and add lower/upper windows + for holiday_name in holidays_df['holiday'].unique(): + holiday_dates = holidays_df[holidays_df['holiday'] == holiday_name][['ds', 'holiday']] + holiday_dates = holiday_dates.copy() + holiday_dates['lower_window'] = 0 + holiday_dates['upper_window'] = 0 + model.holidays = pd.concat([model.holidays, holiday_dates]) if model.holidays is not None else holiday_dates + except Exception as e: + # Log but don't fail if special dates can't be loaded + import logging + logging.warning(f"Could not load special dates for Prophet: {e}") + + model.fit(df) + + # Create future dataframe for forecast period + future_dates = [] + current_date = start + while current_date <= end: + if (current_date - today).days >= 0: + future_dates.append({"ds": current_date}) + current_date += timedelta(days=1) + + if not future_dates: + return ProphetResponse( + data=[], + summary=ProphetSummary( + otb_total=0, + forecast_total=0, + prior_final_total=0, + days_count=0 + ) + ) + + future_df = pd.DataFrame(future_dates) + + # Add floor/cap for logistic growth predictions (must match training cap) + future_df["floor"] = 0 + future_df["cap"] = training_cap + + forecast = model.predict(future_df) + + # Get current OTB and prior year data for each date + data_points = [] + otb_total = 0.0 + prior_otb_total = 0.0 + forecast_total = 0.0 + prior_final_total = 0.0 + days_forecasting_more = 0 + days_forecasting_less = 0 + + for _, row in forecast.iterrows(): + forecast_date = row["ds"].date() + lead_days = (forecast_date - today).days + lead_col = get_lead_time_column(lead_days) + prior_year_date = forecast_date - timedelta(days=364) + day_of_week = forecast_date.strftime("%a") + + # OTB only applies to room-based metrics (occupancy, rooms, guests) + is_room_based = metric in ('occupancy', 'rooms') + + # Get current OTB (only for room-based metrics) + current_otb = None + prior_otb = None + if is_room_based: + if is_backtest: + # In backtest mode, get "current" OTB from booking_pace at that lead time + current_otb_query = text(f""" + SELECT {lead_col} as current_otb + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """) + current_result = await db.execute(current_otb_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + else: + # Normal mode: get current OTB from bookings_stats + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + + # Get prior year OTB from booking_pace + prior_year_for_otb = forecast_date - timedelta(days=364) + prior_otb_query = text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb}) + prior_otb_row = prior_otb_result.fetchone() + + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None + + # Get prior year final using metric mapping + prior_query = f""" + SELECT {col_expr} as prior_final + {from_clause} + WHERE s.date = :prior_date + """ + prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date}) + prior_row = prior_result.fetchone() + prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0 + + # Get per-date bookable cap for room-based metrics + date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap) + + # Convert to occupancy if needed + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + if prior_otb is not None: + prior_otb = (prior_otb / date_bookable_cap) * 100 + + # Get Prophet forecast values + yhat = row["yhat"] + yhat_lower = row["yhat_lower"] + yhat_upper = row["yhat_upper"] + + # Cap at max capacity based on metric type (uses per-date bookable cap) + if is_pct_metric: + yhat = min(yhat, 100.0) + yhat_upper = min(yhat_upper, 100.0) + elif metric == 'rooms': + yhat = min(yhat, float(date_bookable_cap)) + yhat_upper = min(yhat_upper, float(date_bookable_cap)) + # Guests and revenue/rate metrics don't have a hard cap + + # Floor forecast to current OTB if we have it (room-based metrics only) + # But never exceed the bookable capacity (e.g., closed/maintenance periods) + if is_room_based and current_otb is not None and yhat < current_otb: + yhat = min(current_otb, float(date_bookable_cap)) + yhat_lower = min(current_otb, float(date_bookable_cap)) + + if current_otb is not None: + otb_total += current_otb + if prior_otb is not None: + prior_otb_total += prior_otb + forecast_total += yhat + if prior_final is not None: + prior_final_total += prior_final + # Count days forecasting more/less vs prior year final + if yhat > prior_final: + days_forecasting_more += 1 + elif yhat < prior_final: + days_forecasting_less += 1 + + data_points.append(ProphetDataPoint( + date=str(forecast_date), + day_of_week=day_of_week, + current_otb=round(current_otb, 1) if current_otb is not None else None, + prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None, + forecast=round(yhat, 1), + forecast_lower=round(yhat_lower, 1), + forecast_upper=round(yhat_upper, 1), + prior_year_final=round(prior_final, 1) if prior_final is not None else None + )) + + # For occupancy (percentage), show averages; for rooms/guests (counts), show sums + days_count = len(data_points) + if metric == "occupancy" and days_count > 0: + return ProphetResponse( + data=data_points, + summary=ProphetSummary( + otb_total=round(otb_total / days_count, 1), + prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0, + forecast_total=round(forecast_total / days_count, 1), + prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0, + days_count=days_count, + days_forecasting_more=days_forecasting_more, + days_forecasting_less=days_forecasting_less + ) + ) + else: + return ProphetResponse( + data=data_points, + summary=ProphetSummary( + otb_total=round(otb_total, 1), + prior_otb_total=round(prior_otb_total, 1), + forecast_total=round(forecast_total, 1), + prior_final_total=round(prior_final_total, 1), + days_count=days_count, + days_forecasting_more=days_forecasting_more, + days_forecasting_less=days_forecasting_less + ) + ) + + +# ============================================ +# LIVE XGBOOST ENDPOINT +# ============================================ + +class XGBoostDataPoint(BaseModel): + date: str + day_of_week: str + current_otb: Optional[float] + prior_year_otb: Optional[float] + forecast: Optional[float] + prior_year_final: Optional[float] + + +class XGBoostSummary(BaseModel): + otb_total: float + prior_otb_total: float + forecast_total: float + prior_final_total: float + days_count: int + days_forecasting_more: int + days_forecasting_less: int + + +class XGBoostResponse(BaseModel): + data: List[XGBoostDataPoint] + summary: XGBoostSummary + + +@router.get("/xgboost-preview", response_model=XGBoostResponse) +async def get_xgboost_preview( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + metric: str = Query("occupancy", description="Metric: occupancy or rooms"), + perception_date: Optional[str] = Query(None, description="Optional: Generate forecast as if it was this date (YYYY-MM-DD) for backtesting"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Live forecast using XGBoost model. + Trains on historical d0 (final) values and forecasts future dates. + Uses lag features from prior year same DOW. + No logging or persistence - pure read-only preview. + + If perception_date is provided, generates forecast as if it was that date, + training only on data available at that time (for backtesting). + """ + from datetime import datetime + import pandas as pd + import numpy as np + from xgboost import XGBRegressor + import warnings + warnings.filterwarnings('ignore') + + # 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") + + # Use perception_date if provided, otherwise use actual today + actual_today = date.today() + if perception_date: + try: + today = datetime.strptime(perception_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid perception_date format. Use YYYY-MM-DD") + else: + today = actual_today + + is_backtest = perception_date is not None + + # Get default bookable cap (used as fallback for dates without specific data) + default_bookable_cap = await get_bookable_cap(db) + + # Get metric column and query parts + col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric) + is_room_based = metric in ('occupancy', 'rooms') + + # Get historical data for XGBoost training (past 2 years) + history_start = today - timedelta(days=730) + + # Lead times to train on (key intervals) - only used for room-based metrics + train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30] + + # Get final values (and pace data for room-based metrics) + if is_room_based: + history_result = await db.execute(text(""" + SELECT s.date as ds, s.booking_count as final, + p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30 + FROM newbook_bookings_stats s + LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + else: + # Non-room metrics: get values without pace join + history_query = f""" + SELECT s.date as ds, {col_expr} as final + {from_clause} + WHERE s.date >= :history_start + AND s.date < :today + AND {col_expr} IS NOT NULL + ORDER BY s.date + """ + history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today}) + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + raise HTTPException(status_code=400, detail="Insufficient historical data for XGBoost model") + + # Load special dates for feature + special_date_set = set() + try: + special_dates_result = await db.execute(text( + "SELECT * FROM special_dates WHERE is_active = TRUE" + )) + special_dates_rows = special_dates_result.fetchall() + years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1} + for row in special_dates_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 + } + for year in years_needed: + resolved_dates = resolve_special_date(sd, year) + for d in resolved_dates: + special_date_set.add(d) + except Exception: + pass + + # Build lookup dicts + final_by_date = {} + pace_by_date = {} + for row in history_rows: + final_by_date[row.ds] = row.final + if is_room_based and hasattr(row, 'd0'): + pace_by_date[row.ds] = { + 0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7, + 14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30 + } + + # Build training examples + training_rows = [] + + if is_room_based: + # Room-based metrics: use pace features (one per date,lead_time combo) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + continue + + for lead_time in train_lead_times: + current_otb = pace_by_date.get(ds, {}).get(lead_time) + if current_otb is None: + continue + + prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time) + if prior_otb is None: + prior_otb = 0 + + otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0 + + training_rows.append({ + 'ds': ds, + 'y': final, + 'days_out': lead_time, + 'current_otb': float(current_otb), + 'prior_otb_same_lead': float(prior_otb), + 'lag_364': float(prior_final), + 'otb_pct_of_prior_final': otb_pct_of_prior_final + }) + else: + # Non-room metrics: use time features only (one per date) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + prior_final = 0 # Allow training even without prior year for revenue metrics + + training_rows.append({ + 'ds': ds, + 'y': final, + 'lag_364': float(prior_final) if prior_final else 0 + }) + + if len(training_rows) < 30: + raise HTTPException(status_code=400, detail="Insufficient data for XGBoost training") + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + # Convert to occupancy if needed + if metric == "occupancy" and default_bookable_cap > 0: + df["y"] = (df["y"] / default_bookable_cap) * 100 + if "current_otb" in df.columns: + df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100 + if "prior_otb_same_lead" in df.columns: + df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100 + df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100 + + # Create time-based features + df['day_of_week'] = df['ds'].dt.dayofweek + df['month'] = df['ds'].dt.month + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['day_of_week'] >= 5).astype(int) + df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0) + + df_train = df.dropna() + + if len(df_train) < 30: + raise HTTPException(status_code=400, detail="Insufficient data after creating features") + + # Define features based on metric type + if is_room_based: + feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', + 'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final'] + else: + feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', 'lag_364'] + + X_train = df_train[feature_cols] + y_train = df_train['y'] + + # Train XGBoost model + model = XGBRegressor( + n_estimators=100, + max_depth=6, + learning_rate=0.1, + objective='reg:squarederror', + random_state=42, + n_jobs=-1 + ) + model.fit(X_train, y_train) + + # Create future dataframe for forecast period + future_dates = [] + current_date = start + while current_date <= end: + if (current_date - today).days >= 0: + future_dates.append(current_date) + current_date += timedelta(days=1) + + if not future_dates: + return XGBoostResponse( + data=[], + summary=XGBoostSummary( + otb_total=0, prior_otb_total=0, forecast_total=0, + prior_final_total=0, days_count=0, + days_forecasting_more=0, days_forecasting_less=0 + ) + ) + + # Get current OTB and prior year data for each date + data_points = [] + otb_total = 0.0 + prior_otb_total = 0.0 + forecast_total = 0.0 + prior_final_total = 0.0 + days_forecasting_more = 0 + days_forecasting_less = 0 + + for forecast_date in future_dates: + lead_days = (forecast_date - today).days + lead_col = get_lead_time_column(lead_days) + prior_year_date = forecast_date - timedelta(days=364) + day_of_week = forecast_date.strftime("%a") + + # Get OTB data only for room-based metrics + current_otb = None + prior_otb = None + + if is_room_based: + if is_backtest: + current_otb_query = text(f""" + SELECT {lead_col} as current_otb + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """) + current_result = await db.execute(current_otb_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + else: + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + + prior_year_for_otb = forecast_date - timedelta(days=364) + prior_otb_query = text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb}) + prior_otb_row = prior_otb_result.fetchone() + + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None + + # Get prior year final using metric mapping + prior_query = f""" + SELECT {col_expr} as prior_final + {from_clause} + WHERE s.date = :prior_date + """ + prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date}) + prior_row = prior_result.fetchone() + prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0 + + # Get per-date bookable cap for this forecast date + date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap) + + # Build features for this date + forecast_dt = pd.Timestamp(forecast_date) + lag_364_val = prior_final if prior_final else 0 + + # Convert to occupancy if needed + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + if prior_otb is not None: + prior_otb = (prior_otb / date_bookable_cap) * 100 + lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0 + + # Build features based on metric type + if is_room_based: + prior_otb_same_lead = prior_otb if prior_otb is not None else 0 + current_otb_val = current_otb if current_otb is not None else 0 + otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0 + + features = pd.DataFrame([{ + 'day_of_week': forecast_dt.dayofweek, + 'month': forecast_dt.month, + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'days_out': lead_days, + 'current_otb': current_otb_val, + 'prior_otb_same_lead': prior_otb_same_lead, + 'lag_364': lag_364_val, + 'otb_pct_of_prior_final': otb_pct_of_prior_final, + }]) + else: + features = pd.DataFrame([{ + 'day_of_week': forecast_dt.dayofweek, + 'month': forecast_dt.month, + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'lag_364': lag_364_val, + }]) + + # Predict + yhat = float(model.predict(features)[0]) + + # Cap at max capacity based on metric type (uses per-date bookable cap) + if is_pct_metric: + yhat = min(max(yhat, 0), 100.0) + elif metric == 'rooms': + yhat = round(min(max(yhat, 0), float(date_bookable_cap))) + elif metric == 'guests': + yhat = round(max(yhat, 0)) + else: + # Revenue/rate metrics: just ensure non-negative + yhat = max(yhat, 0) + + # Floor forecast to current OTB (room-based only) + if is_room_based and current_otb is not None and yhat < current_otb: + yhat = current_otb + + if current_otb is not None: + otb_total += current_otb + if prior_otb is not None: + prior_otb_total += prior_otb + forecast_total += yhat + if prior_final is not None: + prior_final_total += prior_final + if yhat > prior_final: + days_forecasting_more += 1 + elif yhat < prior_final: + days_forecasting_less += 1 + + # Round to 1 decimal for occupancy %, whole numbers for room counts + if metric == "occupancy": + data_points.append(XGBoostDataPoint( + date=str(forecast_date), + day_of_week=day_of_week, + current_otb=round(current_otb, 1) if current_otb is not None else None, + prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None, + forecast=round(yhat, 1), + prior_year_final=round(prior_final, 1) if prior_final is not None else None + )) + else: + data_points.append(XGBoostDataPoint( + date=str(forecast_date), + day_of_week=day_of_week, + current_otb=round(current_otb) if current_otb is not None else None, + prior_year_otb=round(prior_otb) if prior_otb is not None else None, + forecast=round_towards_reference(yhat, prior_final), + prior_year_final=round(prior_final) if prior_final is not None else None + )) + + # For occupancy (percentage), show averages; for rooms/guests (counts), show sums + days_count = len(data_points) + if metric == "occupancy" and days_count > 0: + return XGBoostResponse( + data=data_points, + summary=XGBoostSummary( + otb_total=round(otb_total / days_count, 1), + prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0, + forecast_total=round(forecast_total / days_count, 1), + prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0, + days_count=days_count, + days_forecasting_more=days_forecasting_more, + days_forecasting_less=days_forecasting_less + ) + ) + else: + return XGBoostResponse( + data=data_points, + summary=XGBoostSummary( + otb_total=round(otb_total), + prior_otb_total=round(prior_otb_total), + forecast_total=round(forecast_total), + prior_final_total=round(prior_final_total), + days_count=days_count, + days_forecasting_more=days_forecasting_more, + days_forecasting_less=days_forecasting_less + ) + ) + + +# ============================================ +# LIVE CHRONOS ENDPOINT +# ============================================ +# LIVE CATBOOST ENDPOINT +# ============================================ + + +class CatBoostDataPoint(BaseModel): + date: str + day_of_week: str + current_otb: Optional[float] = None + prior_year_otb: Optional[float] = None + forecast: Optional[float] = None + prior_year_final: Optional[float] = None + + +class CatBoostSummary(BaseModel): + otb_total: float + prior_otb_total: float + forecast_total: float + prior_final_total: float + days_count: int + days_forecasting_more: int + days_forecasting_less: int + + +class CatBoostResponse(BaseModel): + data: List[CatBoostDataPoint] + summary: CatBoostSummary + + +@router.get("/catboost-preview", response_model=CatBoostResponse) +async def get_catboost_preview( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + metric: str = Query("occupancy", description="Metric: occupancy or room-nights"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Live forecast using CatBoost model. + Gradient boosting with native categorical feature support. + Similar to XGBoost but handles categories natively without encoding. + Uses same features: OTB, prior year, holidays, day-of-week. + """ + from datetime import datetime + import pandas as pd + import numpy as np + from catboost import CatBoostRegressor + import warnings + warnings.filterwarnings('ignore') + + # 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") + + today = date.today() + + # Get default bookable cap + default_bookable_cap = await get_bookable_cap(db) + + # Get metric column and query parts + col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric) + is_room_based = metric in ('occupancy', 'rooms') + + # Get historical data (2+ years for YoY features) + history_start = today - timedelta(days=730) + + # Lead times to train on (only used for room-based metrics) + train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30] + + # Get final values (and pace data for room-based metrics) + if is_room_based: + history_result = await db.execute(text(""" + SELECT s.date as ds, s.booking_count as final, + p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30 + FROM newbook_bookings_stats s + LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + else: + # Non-room metrics: get values without pace join + history_query = f""" + SELECT s.date as ds, {col_expr} as final + {from_clause} + WHERE s.date >= :history_start + AND s.date < :today + AND {col_expr} IS NOT NULL + ORDER BY s.date + """ + history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today}) + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + raise HTTPException(status_code=400, detail="Insufficient historical data for CatBoost model") + + # Load special dates for feature + special_date_set = set() + try: + special_dates_result = await db.execute(text( + "SELECT * FROM special_dates WHERE is_active = TRUE" + )) + special_dates_rows = special_dates_result.fetchall() + years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1} + for row in special_dates_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 + } + for year in years_needed: + resolved_dates = resolve_special_date(sd, year) + for d in resolved_dates: + special_date_set.add(d) + except Exception: + pass + + # Build lookup dicts + final_by_date = {} + pace_by_date = {} + for row in history_rows: + final_by_date[row.ds] = row.final + if is_room_based and hasattr(row, 'd0'): + pace_by_date[row.ds] = { + 0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7, + 14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30 + } + + # Build training examples + training_rows = [] + + if is_room_based: + # Room-based metrics: use pace features (one per date,lead_time combo) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + continue + + for lead_time in train_lead_times: + current_otb = pace_by_date.get(ds, {}).get(lead_time) + if current_otb is None: + continue + + prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time) + if prior_otb is None: + prior_otb = 0 + + otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0 + + training_rows.append({ + 'ds': ds, + 'y': final, + 'days_out': lead_time, + 'current_otb': float(current_otb), + 'prior_otb_same_lead': float(prior_otb), + 'lag_364': float(prior_final), + 'otb_pct_of_prior_final': otb_pct_of_prior_final + }) + else: + # Non-room metrics: use time features only (one per date) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + prior_final = 0 # Allow training even without prior year for revenue metrics + + training_rows.append({ + 'ds': ds, + 'y': final, + 'lag_364': float(prior_final) if prior_final else 0 + }) + + if len(training_rows) < 30: + raise HTTPException(status_code=400, detail="Insufficient data for CatBoost training") + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + # Convert to occupancy if needed + if metric == "occupancy" and default_bookable_cap > 0: + df["y"] = (df["y"] / default_bookable_cap) * 100 + if "current_otb" in df.columns: + df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100 + if "prior_otb_same_lead" in df.columns: + df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100 + df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100 + + # Create features - CatBoost handles categoricals natively + df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical + df['month'] = df['ds'].dt.month.astype(str) # Categorical + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int) + df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0) + + df_train = df.dropna() + + if len(df_train) < 30: + raise HTTPException(status_code=400, detail="Insufficient data after creating features") + + # Define features based on metric type - categoricals handled natively by CatBoost + categorical_features = ['day_of_week', 'month'] + if is_room_based: + numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', + 'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final'] + else: + numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', 'lag_364'] + feature_cols = categorical_features + numerical_features + + X_train = df_train[feature_cols] + y_train = df_train['y'] + + # Train CatBoost model + model = CatBoostRegressor( + iterations=150, + depth=6, + learning_rate=0.1, + loss_function='RMSE', + cat_features=categorical_features, + verbose=False, + random_seed=42 + ) + model.fit(X_train, y_train) + + # Create future dataframe for forecast period + future_dates = [] + current_date = start + while current_date <= end: + if (current_date - today).days >= 0: + future_dates.append(current_date) + current_date += timedelta(days=1) + + if not future_dates: + return CatBoostResponse( + data=[], + summary=CatBoostSummary( + otb_total=0, prior_otb_total=0, forecast_total=0, + prior_final_total=0, days_count=0, + days_forecasting_more=0, days_forecasting_less=0 + ) + ) + + # Generate predictions + data_points = [] + otb_total = 0.0 + prior_otb_total = 0.0 + forecast_total = 0.0 + prior_final_total = 0.0 + days_forecasting_more = 0 + days_forecasting_less = 0 + + for forecast_date in future_dates: + lead_days = (forecast_date - today).days + lead_col = get_lead_time_column(lead_days) + prior_year_date = forecast_date - timedelta(days=364) + day_of_week = forecast_date.strftime("%a") + + # Get OTB data only for room-based metrics + current_otb = None + prior_otb = None + + if is_room_based: + # Get current OTB + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + + # Get prior year OTB + prior_year_for_otb = forecast_date - timedelta(days=364) + prior_otb_query = text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb}) + prior_otb_row = prior_otb_result.fetchone() + + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None + + # Get prior year final using metric mapping + prior_query = f""" + SELECT {col_expr} as prior_final + {from_clause} + WHERE s.date = :prior_date + """ + prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date}) + prior_row = prior_result.fetchone() + prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0 + + # Get per-date bookable cap + date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap) + + forecast_dt = pd.Timestamp(forecast_date) + lag_364_val = prior_final if prior_final else 0 + + # Convert to occupancy if needed + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + if prior_otb is not None: + prior_otb = (prior_otb / date_bookable_cap) * 100 + lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0 + + # Build features based on metric type + if is_room_based: + prior_otb_same_lead = prior_otb if prior_otb is not None else 0 + current_otb_val = current_otb if current_otb is not None else 0 + otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0 + + features = pd.DataFrame([{ + 'day_of_week': str(forecast_dt.dayofweek), # Categorical + 'month': str(forecast_dt.month), # Categorical + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'days_out': lead_days, + 'current_otb': current_otb_val, + 'prior_otb_same_lead': prior_otb_same_lead, + 'lag_364': lag_364_val, + 'otb_pct_of_prior_final': otb_pct_of_prior_final, + }]) + else: + features = pd.DataFrame([{ + 'day_of_week': str(forecast_dt.dayofweek), # Categorical + 'month': str(forecast_dt.month), # Categorical + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'lag_364': lag_364_val, + }]) + + # Predict + yhat = float(model.predict(features)[0]) + + # Cap at max capacity based on metric type (uses per-date bookable cap) + if is_pct_metric: + yhat = min(max(yhat, 0), 100.0) + elif metric == 'rooms': + yhat = round(min(max(yhat, 0), float(date_bookable_cap))) + elif metric == 'guests': + yhat = round(max(yhat, 0)) + else: + # Revenue/rate metrics: just ensure non-negative + yhat = max(yhat, 0) + + # Floor forecast to current OTB (room-based only) + if is_room_based and current_otb is not None and yhat < current_otb: + yhat = current_otb + + if current_otb is not None: + otb_total += current_otb + if prior_otb is not None: + prior_otb_total += prior_otb + forecast_total += yhat + if prior_final is not None: + prior_final_total += prior_final + if yhat > prior_final: + days_forecasting_more += 1 + elif yhat < prior_final: + days_forecasting_less += 1 + + if metric == "occupancy": + data_points.append(CatBoostDataPoint( + date=str(forecast_date), + day_of_week=day_of_week, + current_otb=round(current_otb, 1) if current_otb is not None else None, + prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None, + forecast=round(yhat, 1), + prior_year_final=round(prior_final, 1) if prior_final is not None else None + )) + else: + data_points.append(CatBoostDataPoint( + date=str(forecast_date), + day_of_week=day_of_week, + current_otb=round(current_otb) if current_otb is not None else None, + prior_year_otb=round(prior_otb) if prior_otb is not None else None, + forecast=round_towards_reference(yhat, prior_final), + prior_year_final=round(prior_final) if prior_final is not None else None + )) + + days_count = len(data_points) + if metric == "occupancy" and days_count > 0: + return CatBoostResponse( + data=data_points, + summary=CatBoostSummary( + otb_total=round(otb_total / days_count, 1), + prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0, + forecast_total=round(forecast_total / days_count, 1), + prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0, + days_count=days_count, + days_forecasting_more=days_forecasting_more, + days_forecasting_less=days_forecasting_less + ) + ) + else: + return CatBoostResponse( + data=data_points, + summary=CatBoostSummary( + otb_total=round(otb_total), + prior_otb_total=round(prior_otb_total), + forecast_total=round(forecast_total), + prior_final_total=round(prior_final_total), + days_count=days_count, + days_forecasting_more=days_forecasting_more, + days_forecasting_less=days_forecasting_less + ) + ) + + +class PreviewDataPoint(BaseModel): + date: str + day_of_week: str + lead_days: int + current_otb: Optional[float] + prior_year_date: str + prior_year_dow: str + prior_year_otb: Optional[float] + prior_year_final: Optional[float] + expected_pickup: Optional[float] + forecast: Optional[float] + pace_vs_prior_pct: Optional[float] + + +class PreviewSummary(BaseModel): + otb_total: float + forecast_total: float + prior_otb_total: float + prior_final_total: float + pace_pct: Optional[float] + days_count: int + + +class PreviewResponse(BaseModel): + data: List[PreviewDataPoint] + summary: PreviewSummary + + + +def get_lead_time_column(lead_days: int) -> str: + """ + Map lead days to the appropriate column in newbook_booking_pace. + Columns: d365, d330, d300, d270, d240, d210 (monthly) + d177-d37 in 7-day intervals (weekly) + d30-d0 (daily) + """ + if lead_days <= 0: + return "d0" + elif lead_days <= 30: + return f"d{lead_days}" + elif lead_days <= 177: + # Weekly intervals - find nearest column + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + for col in weekly_cols: + if lead_days <= col: + return f"d{col}" + return "d177" + else: + # Monthly intervals + monthly_cols = [210, 240, 270, 300, 330, 365] + for col in monthly_cols: + if lead_days <= col: + return f"d{col}" + return "d365" + + +# ============================================ +# LIVE BLENDED FORECAST ENDPOINT +# ============================================ + +REVENUE_METRICS = ['net_accom', 'net_dry', 'net_wet', 'total_rev'] +MODEL_WEIGHT = 0.6 # 60% from accuracy-weighted models +BUDGET_PRIOR_WEIGHT = 0.4 # 40% from budget (revenue) or prior year (other) + + +class BlendedDataPoint(BaseModel): + date: str + day_of_week: str + current_otb: Optional[float] + prior_year_otb: Optional[float] + blended_forecast: Optional[float] + prophet_forecast: Optional[float] + xgboost_forecast: Optional[float] + catboost_forecast: Optional[float] + budget_or_prior: Optional[float] + prior_year_final: Optional[float] + + +class BlendedSummary(BaseModel): + otb_total: float + prior_otb_total: float + forecast_total: float + prior_final_total: float + days_count: int + days_forecasting_more: int + days_forecasting_less: int + prophet_weight: float + xgboost_weight: float + catboost_weight: float + + +class BlendedResponse(BaseModel): + data: List[BlendedDataPoint] + summary: BlendedSummary + + +@router.get("/blended-preview", response_model=BlendedResponse) +async def get_blended_preview( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + metric: str = Query("occupancy", description="Metric: occupancy, rooms, net_accom, etc."), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Live blended forecast combining multiple models with accuracy-based weighting. + + For revenue metrics (net_accom, net_dry, net_wet): + - 60% accuracy-weighted models (Prophet/XGBoost/CatBoost) + - 40% budget target + + For other metrics (occupancy, rooms, guests, etc.): + - 60% accuracy-weighted models + - 40% prior year DOW-aligned actuals + + Model weights are calculated from recent accuracy (inverse MAPE). + """ + from datetime import datetime + + # 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") + + today = date.today() + is_revenue_metric = metric in REVENUE_METRICS + + # Get accuracy scores for model weighting (from last 90 days) + accuracy_query = """ + SELECT + AVG(ABS(prophet_pct_error)) as prophet_mape, + AVG(ABS(xgboost_pct_error)) as xgboost_mape, + AVG(ABS(catboost_pct_error)) as catboost_mape + FROM actual_vs_forecast + WHERE date >= CURRENT_DATE - INTERVAL '90 days' + AND date < CURRENT_DATE + AND metric_type = :metric + AND actual_value IS NOT NULL + """ + accuracy_result = await db.execute(text(accuracy_query), {"metric": metric}) + accuracy_row = accuracy_result.fetchone() + + # Calculate inverse-MAPE weights (lower MAPE = higher weight) + # Use default equal weights if no accuracy data + if accuracy_row and accuracy_row.prophet_mape and accuracy_row.xgboost_mape and accuracy_row.catboost_mape: + prophet_mape = float(accuracy_row.prophet_mape) or 10 + xgboost_mape = float(accuracy_row.xgboost_mape) or 10 + catboost_mape = float(accuracy_row.catboost_mape) or 10 + + # Inverse weights (1/MAPE), normalized + inv_prophet = 1 / max(prophet_mape, 0.1) + inv_xgboost = 1 / max(xgboost_mape, 0.1) + inv_catboost = 1 / max(catboost_mape, 0.1) + total_inv = inv_prophet + inv_xgboost + inv_catboost + + prophet_weight = inv_prophet / total_inv + xgboost_weight = inv_xgboost / total_inv + catboost_weight = inv_catboost / total_inv + else: + # Equal weights if no accuracy data + prophet_weight = 1/3 + xgboost_weight = 1/3 + catboost_weight = 1/3 + + # Get forecasts from stored forecasts table + forecasts_query = """ + SELECT + forecast_date, + model_type, + predicted_value + FROM forecasts + WHERE forecast_date BETWEEN :start AND :end + AND forecast_type = :metric + ORDER BY forecast_date + """ + forecasts_result = await db.execute(text(forecasts_query), { + "start": start, "end": end, "metric": metric + }) + forecasts_rows = forecasts_result.fetchall() + + # Build forecasts dict by date and model + forecasts_by_date = {} + for row in forecasts_rows: + date_str = str(row.forecast_date) + if date_str not in forecasts_by_date: + forecasts_by_date[date_str] = {} + forecasts_by_date[date_str][row.model_type] = float(row.predicted_value) if row.predicted_value else None + + # Try to get current OTB from pickup_snapshots (may not exist) + otb_by_date = {} + try: + otb_query = """ + SELECT + stay_date, + otb_value, + prior_year_otb, + prior_year_final + FROM pickup_snapshots + WHERE stay_date BETWEEN :start AND :end + AND metric_type = :metric + AND snapshot_date = CURRENT_DATE + """ + otb_result = await db.execute(text(otb_query), { + "start": start, "end": end, "metric": metric + }) + otb_rows = otb_result.fetchall() + otb_by_date = {str(row.stay_date): row for row in otb_rows} + except Exception: + # Table doesn't exist or query failed - continue without OTB data + pass + + # Get budget OR prior year data depending on metric type + budget_prior_by_date = {} + if is_revenue_metric: + # Get daily budget values + if metric == 'total_rev': + # For total_rev, sum all three department budgets + budget_query = """ + SELECT date, SUM(budget_value) as budget_value + FROM daily_budgets + WHERE date BETWEEN :start AND :end + AND budget_type IN ('net_accom', 'net_dry', 'net_wet') + GROUP BY date + """ + budget_result = await db.execute(text(budget_query), { + "start": start, "end": end + }) + else: + budget_query = """ + SELECT date, budget_value + FROM daily_budgets + WHERE date BETWEEN :start AND :end + AND budget_type = :metric + """ + budget_result = await db.execute(text(budget_query), { + "start": start, "end": end, "metric": metric + }) + budget_rows = budget_result.fetchall() + budget_prior_by_date = {str(row.date): float(row.budget_value) for row in budget_rows} + else: + # Get prior year DOW-aligned actuals from newbook_bookings_stats + # Calculate prior dates (364 days back for DOW alignment) + col_expr, from_clause, _ = get_metric_query_parts(metric) + prior_query = f""" + SELECT s.date, {col_expr} as value + {from_clause} + WHERE s.date BETWEEN :prior_start AND :prior_end + AND {col_expr} IS NOT NULL + """ + prior_start = start - timedelta(days=364) + prior_end = end - timedelta(days=364) + try: + prior_result = await db.execute(text(prior_query), { + "prior_start": prior_start, "prior_end": prior_end + }) + prior_rows = prior_result.fetchall() + # Map prior dates to target dates (+364 days) + for row in prior_rows: + target_date = row.date + timedelta(days=364) + if start <= target_date <= end: + budget_prior_by_date[str(target_date)] = float(row.value) if row.value else None + except Exception: + pass + + # Build response data + data = [] + otb_total = 0 + prior_otb_total = 0 + forecast_total = 0 + prior_final_total = 0 + days_forecasting_more = 0 + days_forecasting_less = 0 + + current_date = start + while current_date <= end: + date_str = str(current_date) + day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + day_of_week = day_names[current_date.weekday()] + + # Get OTB data (may be empty if pickup_snapshots doesn't exist) + otb_row = otb_by_date.get(date_str) + current_otb = float(otb_row.otb_value) if otb_row and otb_row.otb_value is not None else None + prior_year_otb = float(otb_row.prior_year_otb) if otb_row and otb_row.prior_year_otb is not None else None + # For prior_year_final, use OTB data if available, otherwise use budget_prior_by_date for non-revenue + prior_year_final = float(otb_row.prior_year_final) if otb_row and otb_row.prior_year_final is not None else ( + budget_prior_by_date.get(date_str) if not is_revenue_metric else None + ) + + # Get model forecasts + date_forecasts = forecasts_by_date.get(date_str, {}) + prophet_fc = date_forecasts.get('prophet') + xgboost_fc = date_forecasts.get('xgboost') + catboost_fc = date_forecasts.get('catboost') + saved_blended = date_forecasts.get('blended') # Check for pre-generated blended forecast + + # Get budget/prior value + budget_prior = budget_prior_by_date.get(date_str) + + # Use saved blended forecast if available, otherwise calculate on-the-fly + blended_forecast = None + if saved_blended is not None: + # Use pre-generated blended forecast from snapshot (already accuracy-weighted) + blended_forecast = saved_blended + elif any([prophet_fc, xgboost_fc, catboost_fc]): + # Calculate accuracy-weighted model forecast + model_sum = 0 + weight_sum = 0 + if prophet_fc is not None: + model_sum += prophet_fc * prophet_weight + weight_sum += prophet_weight + if xgboost_fc is not None: + model_sum += xgboost_fc * xgboost_weight + weight_sum += xgboost_weight + if catboost_fc is not None: + model_sum += catboost_fc * catboost_weight + weight_sum += catboost_weight + + if weight_sum > 0: + accuracy_weighted = model_sum / weight_sum + + # Blend with budget/prior + if budget_prior is not None: + blended_forecast = (MODEL_WEIGHT * accuracy_weighted) + (BUDGET_PRIOR_WEIGHT * budget_prior) + else: + # No budget/prior data - use just accuracy-weighted models + blended_forecast = accuracy_weighted + + # Accumulate totals + if current_otb is not None: + otb_total += current_otb + if prior_year_otb is not None: + prior_otb_total += prior_year_otb + if blended_forecast is not None: + forecast_total += blended_forecast + if prior_year_final is not None: + prior_final_total += prior_year_final + if blended_forecast is not None: + if blended_forecast > prior_year_final: + days_forecasting_more += 1 + elif blended_forecast < prior_year_final: + days_forecasting_less += 1 + + data.append(BlendedDataPoint( + date=date_str, + day_of_week=day_of_week, + current_otb=current_otb, + prior_year_otb=prior_year_otb, + blended_forecast=round(blended_forecast, 2) if blended_forecast else None, + prophet_forecast=round(prophet_fc, 2) if prophet_fc else None, + xgboost_forecast=round(xgboost_fc, 2) if xgboost_fc else None, + catboost_forecast=round(catboost_fc, 2) if catboost_fc else None, + budget_or_prior=round(budget_prior, 2) if budget_prior else None, + prior_year_final=prior_year_final + )) + + current_date += timedelta(days=1) + + return BlendedResponse( + data=data, + summary=BlendedSummary( + otb_total=round(otb_total, 2), + prior_otb_total=round(prior_otb_total, 2), + forecast_total=round(forecast_total, 2), + prior_final_total=round(prior_final_total, 2), + days_count=len(data), + days_forecasting_more=days_forecasting_more, + days_forecasting_less=days_forecasting_less, + prophet_weight=round(prophet_weight, 3), + xgboost_weight=round(xgboost_weight, 3), + catboost_weight=round(catboost_weight, 3) + ) + ) + + +@router.get("/preview", response_model=PreviewResponse) +async def get_forecast_preview( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + metric: str = Query("occupancy", description="Metric: occupancy or rooms"), + perception_date: Optional[str] = Query(None, description="Optional: Generate forecast as if it was this date (YYYY-MM-DD) for backtesting"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Live forecast preview using transparent pickup model. + Uses newbook_booking_pace table to get current OTB and prior year comparison. + Calculates: Forecast = Current OTB + (Prior Year Final - Prior Year OTB) + + No logging or persistence - pure read-only preview. + + If perception_date is provided, generates forecast as if it was that date, + using only data that would have been available at that time (for backtesting). + + Note: Pickup model only works for room-based metrics (occupancy, rooms, guests). + For revenue/rate metrics, returns empty data as OTB/pace concepts don't apply. + """ + from datetime import datetime + + # Check if metric is room-based (pickup model only works for these) + is_room_based = metric in ('occupancy', 'rooms') + if not is_room_based: + # Pickup model doesn't apply to revenue/rate metrics + return PreviewResponse( + data=[], + summary=PreviewSummary( + otb_total=0, + forecast_total=0, + prior_otb_total=0, + prior_final_total=0, + pace_pct=None, + days_count=0 + ) + ) + + # 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") + + # Use perception_date if provided, otherwise use actual today + actual_today = date.today() + if perception_date: + try: + today = datetime.strptime(perception_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid perception_date format. Use YYYY-MM-DD") + else: + today = actual_today + + is_backtest = perception_date is not None + + # Get default bookable cap + default_bookable_cap = await get_bookable_cap(db) + + # Generate date range and calculate for each date + data_points = [] + otb_total = 0.0 + forecast_total = 0.0 + prior_otb_total = 0.0 + prior_final_total = 0.0 + + current_date = start + while current_date <= end: + lead_days = (current_date - today).days + if lead_days < 0: + current_date += timedelta(days=1) + continue + + lead_col = get_lead_time_column(lead_days) + prior_year_date = current_date - timedelta(days=364) # 52 weeks for DOW alignment + day_of_week = current_date.strftime("%a") + + # Get current OTB + if is_backtest: + # In backtest mode, get "current" OTB from booking_pace at that lead time + current_otb_query = text(f""" + SELECT {lead_col} as current_otb + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """) + current_result = await db.execute(current_otb_query, {"arrival_date": current_date}) + current_row = current_result.fetchone() + else: + # Normal mode: get current OTB from bookings_stats (today's actual booking count) + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": current_date}) + current_row = current_result.fetchone() + + # Get prior year OTB from booking_pace (for lead time comparison - always uses 364-day offset) + prior_year_for_otb = current_date - timedelta(days=364) + prior_otb_query = text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb}) + prior_otb_row = prior_otb_result.fetchone() + + # Get prior year FINAL from bookings_stats (actual booking count for that date) + prior_final_query = text(""" + SELECT booking_count as prior_final + FROM newbook_bookings_stats + WHERE date = :prior_date + """) + prior_final_result = await db.execute(prior_final_query, {"prior_date": prior_year_date}) + prior_final_row = prior_final_result.fetchone() + + # Extract values - default to 0 for stats (no row = no bookings), None for pace (no historical tracking) + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None + prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final is not None else 0 + + # Get per-date bookable cap + date_bookable_cap = await get_bookable_cap(db, current_date, default_bookable_cap) + + # Convert to occupancy % if metric is occupancy + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + if prior_otb is not None: + prior_otb = (prior_otb / date_bookable_cap) * 100 + if prior_final is not None: + prior_final = (prior_final / date_bookable_cap) * 100 + + # Calculate expected pickup and forecast + expected_pickup = None + forecast = None + pace_vs_prior_pct = None + + if current_otb is not None: + if prior_final is not None and prior_otb is not None: + expected_pickup = prior_final - prior_otb + forecast = current_otb + expected_pickup + # Floor to current OTB if pickup is negative + if forecast < current_otb: + forecast = current_otb + expected_pickup = 0 + # Cap at max capacity (uses per-date bookable cap) + if metric == "occupancy" and forecast > 100: + forecast = 100.0 + elif metric == "rooms" and forecast > date_bookable_cap: + forecast = float(date_bookable_cap) + # Calculate pace vs prior + if prior_otb > 0: + pace_vs_prior_pct = ((current_otb - prior_otb) / prior_otb) * 100 + else: + # No prior year data - use current OTB as forecast + forecast = current_otb + expected_pickup = 0 + + otb_total += current_otb + forecast_total += forecast if forecast else current_otb + + if prior_otb is not None: + prior_otb_total += prior_otb + if prior_final is not None: + prior_final_total += prior_final + + prior_year_dow = prior_year_date.strftime("%a") + + data_points.append(PreviewDataPoint( + date=str(current_date), + day_of_week=day_of_week, + lead_days=lead_days, + current_otb=round(current_otb, 1) if current_otb is not None else None, + prior_year_date=str(prior_year_date), + prior_year_dow=prior_year_dow, + prior_year_otb=round(prior_otb, 1) if prior_otb is not None else None, + prior_year_final=round(prior_final, 1) if prior_final is not None else None, + expected_pickup=round(expected_pickup, 1) if expected_pickup is not None else None, + forecast=round(forecast, 1) if forecast is not None else None, + pace_vs_prior_pct=round(pace_vs_prior_pct, 1) if pace_vs_prior_pct is not None else None + )) + + current_date += timedelta(days=1) + + # Calculate overall pace percentage + pace_pct = None + if prior_otb_total > 0: + pace_pct = round(((otb_total - prior_otb_total) / prior_otb_total) * 100, 1) + + # For occupancy (percentage), show averages; for rooms/guests (counts), show sums + days_count = len(data_points) + if metric == "occupancy" and days_count > 0: + return PreviewResponse( + data=data_points, + summary=PreviewSummary( + otb_total=round(otb_total / days_count, 1), + forecast_total=round(forecast_total / days_count, 1), + prior_otb_total=round(prior_otb_total / days_count, 1) if prior_otb_total > 0 else 0, + prior_final_total=round(prior_final_total / days_count, 1) if prior_final_total > 0 else 0, + pace_pct=pace_pct, + days_count=days_count + ) + ) + else: + return PreviewResponse( + data=data_points, + summary=PreviewSummary( + otb_total=round(otb_total, 1), + forecast_total=round(forecast_total, 1), + prior_otb_total=round(prior_otb_total, 1), + prior_final_total=round(prior_final_total, 1), + pace_pct=pace_pct, + days_count=days_count + ) + ) + + +class PaceCurvePoint(BaseModel): + days_out: int + rooms: Optional[int] + + +class PaceCurveResponse(BaseModel): + arrival_date: str + day_of_week: str + current_year: List[PaceCurvePoint] + prior_year: List[PaceCurvePoint] + final_value: Optional[int] + prior_year_final: Optional[int] + + +@router.get("/pace-curve", response_model=PaceCurveResponse) +async def get_pace_curve( + arrival_date: str = Query(..., description="Arrival date (YYYY-MM-DD)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get booking pace curve for a specific arrival date. + Shows how bookings built up over time from 365 days out to today. + Includes prior year same day-of-week comparison (364-day offset). + """ + from datetime import datetime + + try: + target_date = datetime.strptime(arrival_date, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + + # Prior year same DOW (364 days = 52 weeks exactly) + prior_year_date = target_date - timedelta(days=364) + + # Column names in order (d365 to d0) + lead_time_columns = [ + # Monthly intervals (6) + ("d365", 365), ("d330", 330), ("d300", 300), ("d270", 270), ("d240", 240), ("d210", 210), + # Weekly intervals (21) + ("d177", 177), ("d170", 170), ("d163", 163), ("d156", 156), ("d149", 149), + ("d142", 142), ("d135", 135), ("d128", 128), ("d121", 121), ("d114", 114), + ("d107", 107), ("d100", 100), ("d93", 93), ("d86", 86), ("d79", 79), + ("d72", 72), ("d65", 65), ("d58", 58), ("d51", 51), ("d44", 44), ("d37", 37), + # Daily intervals (31) + ("d30", 30), ("d29", 29), ("d28", 28), ("d27", 27), ("d26", 26), + ("d25", 25), ("d24", 24), ("d23", 23), ("d22", 22), ("d21", 21), + ("d20", 20), ("d19", 19), ("d18", 18), ("d17", 17), ("d16", 16), + ("d15", 15), ("d14", 14), ("d13", 13), ("d12", 12), ("d11", 11), + ("d10", 10), ("d9", 9), ("d8", 8), ("d7", 7), ("d6", 6), + ("d5", 5), ("d4", 4), ("d3", 3), ("d2", 2), ("d1", 1), ("d0", 0) + ] + + # Build column select list + col_names = [col[0] for col in lead_time_columns] + col_select = ", ".join(col_names) + + # Get current year pace data + current_query = text(f""" + SELECT {col_select} + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """) + + current_result = await db.execute(current_query, {"arrival_date": target_date}) + current_row = current_result.fetchone() + + # Get prior year pace data + prior_query = text(f""" + SELECT {col_select} + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + + prior_result = await db.execute(prior_query, {"prior_date": prior_year_date}) + prior_row = prior_result.fetchone() + + # Build response + current_year_data = [] + prior_year_data = [] + + for col_name, days_out in lead_time_columns: + # Current year + if current_row: + val = getattr(current_row, col_name, None) + current_year_data.append(PaceCurvePoint(days_out=days_out, rooms=val)) + else: + current_year_data.append(PaceCurvePoint(days_out=days_out, rooms=None)) + + # Prior year + if prior_row: + val = getattr(prior_row, col_name, None) + prior_year_data.append(PaceCurvePoint(days_out=days_out, rooms=val)) + else: + prior_year_data.append(PaceCurvePoint(days_out=days_out, rooms=None)) + + # Get final values from d0 column + final_value = getattr(current_row, 'd0', None) if current_row else None + prior_final = getattr(prior_row, 'd0', None) if prior_row else None + + # Get day of week + day_of_week = target_date.strftime("%a") + + return PaceCurveResponse( + arrival_date=arrival_date, + day_of_week=day_of_week, + current_year=current_year_data, + prior_year=prior_year_data, + final_value=final_value, + prior_year_final=prior_final + ) + + +# ============================================ +# ACTUALS DATA ENDPOINT +# ============================================ + +class ActualsDataPoint(BaseModel): + date: str + day_of_week: str + actual_value: Optional[float] + prior_year_value: Optional[float] + budget_value: Optional[float] + otb_value: Optional[float] = None # On-the-books revenue for future dates + + +class ActualsResponse(BaseModel): + data: List[ActualsDataPoint] + summary: dict + + +@router.get("/actuals", response_model=ActualsResponse) +async def get_actuals_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 type"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get actual/final data for a date range with prior year and budget comparison. + Used for the main forecast page to show actuals for past dates. + + Note: Today's actual is excluded (set to null) since the day isn't finished. + For net_accom metric, OTB (on-the-books) values are included for today and future dates. + """ + from datetime import datetime, date as date_type + + 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") + + today = date_type.today() + day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + + # Get default bookable capacity for rooms/occupancy budget calculation + default_cap = await get_bookable_cap(db) + + # Build query based on metric type + if metric == 'total_rev': + # Total revenue = sum of accommodation + dry + wet + query = text(""" + WITH date_range AS ( + SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date + ), + actuals AS ( + SELECT date, COALESCE(accommodation, 0) + COALESCE(dry, 0) + COALESCE(wet, 0) as value + FROM newbook_net_revenue_data + WHERE date BETWEEN :start_date AND :end_date + ), + prior_year AS ( + SELECT date + interval '364 days' as target_date, + COALESCE(accommodation, 0) + COALESCE(dry, 0) + COALESCE(wet, 0) as value + FROM newbook_net_revenue_data + WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days' + ), + budgets AS ( + SELECT date, SUM(budget_value) as budget_value + FROM daily_budgets + WHERE date BETWEEN :start_date AND :end_date + AND budget_type IN ('net_accom', 'net_dry', 'net_wet') + GROUP BY date + ), + otb_data AS ( + SELECT date, net_booking_rev_total as otb_gross + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ), + tax_rates_lookup AS ( + SELECT DISTINCT ON (dr.date) dr.date, tr.rate + FROM date_range dr + LEFT JOIN tax_rates tr ON tr.tax_type = 'accommodation_vat' AND tr.effective_from <= dr.date + ORDER BY dr.date, tr.effective_from DESC + ) + SELECT + dr.date, + EXTRACT(DOW FROM dr.date) as dow, + CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value, + py.value as prior_year_value, + b.budget_value, + CASE + WHEN dr.date >= :today AND o.otb_gross IS NOT NULL AND trl.rate IS NOT NULL + THEN ROUND(o.otb_gross / (1 + trl.rate), 2) + ELSE NULL + END as otb_value + FROM date_range dr + LEFT JOIN actuals a ON dr.date = a.date + LEFT JOIN prior_year py ON dr.date = py.target_date + LEFT JOIN budgets b ON dr.date = b.date + LEFT JOIN otb_data o ON dr.date = o.date + LEFT JOIN tax_rates_lookup trl ON dr.date = trl.date + ORDER BY dr.date + """) + elif metric in ['net_accom', 'net_dry', 'net_wet']: + # Revenue metrics from newbook_net_revenue_data + col_map = {'net_accom': 'accommodation', 'net_dry': 'dry', 'net_wet': 'wet'} + col_name = col_map[metric] + + # For net_accom, also fetch OTB values from newbook_bookings_stats + # OTB = net_booking_rev_total / (1 + vat_rate) to get net of VAT + if metric == 'net_accom': + query = text(f""" + WITH date_range AS ( + SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date + ), + actuals AS ( + SELECT date, {col_name} as value + FROM newbook_net_revenue_data + WHERE date BETWEEN :start_date AND :end_date + ), + prior_year AS ( + SELECT date + interval '364 days' as target_date, {col_name} as value + FROM newbook_net_revenue_data + WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days' + ), + budgets AS ( + SELECT date, budget_value + FROM daily_budgets + WHERE date BETWEEN :start_date AND :end_date + AND budget_type = :metric + ), + otb_data AS ( + SELECT date, net_booking_rev_total as otb_gross + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ), + tax_rates_lookup AS ( + -- Get the effective tax rate for each date in range + SELECT DISTINCT ON (dr.date) dr.date, tr.rate + FROM date_range dr + LEFT JOIN tax_rates tr ON tr.tax_type = 'accommodation_vat' AND tr.effective_from <= dr.date + ORDER BY dr.date, tr.effective_from DESC + ) + SELECT + dr.date, + EXTRACT(DOW FROM dr.date) as dow, + CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value, + py.value as prior_year_value, + b.budget_value, + CASE + WHEN dr.date >= :today AND o.otb_gross IS NOT NULL AND trl.rate IS NOT NULL + THEN ROUND(o.otb_gross / (1 + trl.rate), 2) + ELSE NULL + END as otb_value + FROM date_range dr + LEFT JOIN actuals a ON dr.date = a.date + LEFT JOIN prior_year py ON dr.date = py.target_date + LEFT JOIN budgets b ON dr.date = b.date + LEFT JOIN otb_data o ON dr.date = o.date + LEFT JOIN tax_rates_lookup trl ON dr.date = trl.date + ORDER BY dr.date + """) + else: + # For net_dry and net_wet, no OTB data available + query = text(f""" + WITH date_range AS ( + SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date + ), + actuals AS ( + SELECT date, {col_name} as value + FROM newbook_net_revenue_data + WHERE date BETWEEN :start_date AND :end_date + ), + prior_year AS ( + SELECT date + interval '364 days' as target_date, {col_name} as value + FROM newbook_net_revenue_data + WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days' + ), + budgets AS ( + SELECT date, budget_value + FROM daily_budgets + WHERE date BETWEEN :start_date AND :end_date + AND budget_type = :metric + ) + SELECT + dr.date, + EXTRACT(DOW FROM dr.date) as dow, + CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value, + py.value as prior_year_value, + b.budget_value, + NULL::numeric as otb_value + FROM date_range dr + LEFT JOIN actuals a ON dr.date = a.date + LEFT JOIN prior_year py ON dr.date = py.target_date + LEFT JOIN budgets b ON dr.date = b.date + ORDER BY dr.date + """) + elif metric == 'occupancy': + # Occupancy from newbook_bookings_stats + # Budget occupancy calculated from: (net_accom_budget / ARR) / bookable_cap * 100 + query = text(""" + WITH date_range AS ( + SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date + ), + actuals AS ( + SELECT date, total_occupancy_pct as value + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ), + prior_year AS ( + SELECT date + interval '364 days' as target_date, total_occupancy_pct as value + FROM newbook_bookings_stats + WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days' + ), + otb_data AS ( + SELECT date, total_occupancy_pct as otb + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ), + budgets AS ( + SELECT date, budget_value + FROM daily_budgets + WHERE date BETWEEN :start_date AND :end_date + AND budget_type = 'net_accom' + ), + arr_forecast AS ( + SELECT + target_date as date, + forecast_value as arr + FROM forecast_snapshots + WHERE target_date BETWEEN :start_date AND :end_date + AND metric_code = 'arr' + AND model = 'blended' + AND perception_date = ( + SELECT MAX(perception_date) + FROM forecast_snapshots + WHERE metric_code = 'arr' AND model = 'blended' + ) + ), + bookable AS ( + SELECT date, bookable_count + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + AND bookable_count IS NOT NULL + ), + prior_year_pace AS ( + SELECT + pace.arrival_date + 364 as target_date, + CASE + WHEN CAST(:today AS date) - 364 >= pace.arrival_date THEN NULL + ELSE CASE (pace.arrival_date - (CAST(:today AS date) - 364)) + WHEN 0 THEN pace.d0 WHEN 1 THEN pace.d1 WHEN 2 THEN pace.d2 WHEN 3 THEN pace.d3 + WHEN 4 THEN pace.d4 WHEN 5 THEN pace.d5 WHEN 6 THEN pace.d6 WHEN 7 THEN pace.d7 + WHEN 8 THEN pace.d8 WHEN 9 THEN pace.d9 WHEN 10 THEN pace.d10 WHEN 11 THEN pace.d11 + WHEN 12 THEN pace.d12 WHEN 13 THEN pace.d13 WHEN 14 THEN pace.d14 WHEN 15 THEN pace.d15 + WHEN 16 THEN pace.d16 WHEN 17 THEN pace.d17 WHEN 18 THEN pace.d18 WHEN 19 THEN pace.d19 + WHEN 20 THEN pace.d20 WHEN 21 THEN pace.d21 WHEN 22 THEN pace.d22 WHEN 23 THEN pace.d23 + WHEN 24 THEN pace.d24 WHEN 25 THEN pace.d25 WHEN 26 THEN pace.d26 WHEN 27 THEN pace.d27 + WHEN 28 THEN pace.d28 WHEN 29 THEN pace.d29 WHEN 30 THEN pace.d30 + WHEN 37 THEN pace.d37 WHEN 44 THEN pace.d44 WHEN 51 THEN pace.d51 WHEN 58 THEN pace.d58 + WHEN 65 THEN pace.d65 WHEN 72 THEN pace.d72 WHEN 79 THEN pace.d79 WHEN 86 THEN pace.d86 + WHEN 93 THEN pace.d93 WHEN 100 THEN pace.d100 WHEN 107 THEN pace.d107 WHEN 114 THEN pace.d114 + WHEN 121 THEN pace.d121 WHEN 128 THEN pace.d128 WHEN 135 THEN pace.d135 WHEN 142 THEN pace.d142 + WHEN 149 THEN pace.d149 WHEN 156 THEN pace.d156 WHEN 163 THEN pace.d163 WHEN 170 THEN pace.d170 + WHEN 177 THEN pace.d177 WHEN 210 THEN pace.d210 WHEN 240 THEN pace.d240 WHEN 270 THEN pace.d270 + WHEN 300 THEN pace.d300 WHEN 330 THEN pace.d330 WHEN 365 THEN pace.d365 + ELSE NULL + END + END as booking_count + FROM newbook_booking_pace pace + WHERE pace.arrival_date BETWEEN CAST(:start_date AS date) - 364 + AND CAST(:end_date AS date) - 364 + ) + SELECT + dr.date, + EXTRACT(DOW FROM dr.date) as dow, + CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value, + CASE + WHEN dr.date < :today THEN py.value + WHEN pyp.booking_count IS NOT NULL AND bc.bookable_count IS NOT NULL AND bc.bookable_count > 0 + THEN (pyp.booking_count::numeric / bc.bookable_count) * 100 + ELSE NULL + END as prior_year_value, + CASE + WHEN b.budget_value IS NOT NULL AND arr.arr IS NOT NULL AND arr.arr > 0 AND COALESCE(bc.bookable_count, :default_cap) > 0 + THEN (LEAST(COALESCE(bc.bookable_count, :default_cap), CEIL(b.budget_value / arr.arr)) / COALESCE(bc.bookable_count, :default_cap)) * 100 + ELSE NULL + END as budget_value, + CASE WHEN dr.date >= :today THEN o.otb ELSE NULL END as otb_value + FROM date_range dr + LEFT JOIN actuals a ON dr.date = a.date + LEFT JOIN prior_year py ON dr.date = py.target_date + LEFT JOIN otb_data o ON dr.date = o.date + LEFT JOIN budgets b ON dr.date = b.date + LEFT JOIN arr_forecast arr ON dr.date = arr.date + LEFT JOIN bookable bc ON dr.date = bc.date + LEFT JOIN prior_year_pace pyp ON dr.date = pyp.target_date + ORDER BY dr.date + """) + elif metric == 'rooms': + # Room nights from newbook_bookings_stats + # Budget rooms calculated from: net_accom_budget / ARR (rounded up) + query = text(""" + WITH date_range AS ( + SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date + ), + actuals AS ( + SELECT date, booking_count as value + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ), + prior_year AS ( + SELECT date + interval '364 days' as target_date, booking_count as value + FROM newbook_bookings_stats + WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days' + ), + otb_data AS ( + SELECT date, booking_count as otb + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ), + budgets AS ( + SELECT date, budget_value + FROM daily_budgets + WHERE date BETWEEN :start_date AND :end_date + AND budget_type = 'net_accom' + ), + arr_forecast AS ( + SELECT + target_date as date, + forecast_value as arr + FROM forecast_snapshots + WHERE target_date BETWEEN :start_date AND :end_date + AND metric_code = 'arr' + AND model = 'blended' + AND perception_date = ( + SELECT MAX(perception_date) + FROM forecast_snapshots + WHERE metric_code = 'arr' AND model = 'blended' + ) + ), + bookable AS ( + SELECT date, bookable_count + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + AND bookable_count IS NOT NULL + ), + prior_year_pace AS ( + SELECT + pace.arrival_date + 364 as target_date, + CASE + WHEN CAST(:today AS date) - 364 >= pace.arrival_date THEN NULL + ELSE CASE (pace.arrival_date - (CAST(:today AS date) - 364)) + WHEN 0 THEN pace.d0 WHEN 1 THEN pace.d1 WHEN 2 THEN pace.d2 WHEN 3 THEN pace.d3 + WHEN 4 THEN pace.d4 WHEN 5 THEN pace.d5 WHEN 6 THEN pace.d6 WHEN 7 THEN pace.d7 + WHEN 8 THEN pace.d8 WHEN 9 THEN pace.d9 WHEN 10 THEN pace.d10 WHEN 11 THEN pace.d11 + WHEN 12 THEN pace.d12 WHEN 13 THEN pace.d13 WHEN 14 THEN pace.d14 WHEN 15 THEN pace.d15 + WHEN 16 THEN pace.d16 WHEN 17 THEN pace.d17 WHEN 18 THEN pace.d18 WHEN 19 THEN pace.d19 + WHEN 20 THEN pace.d20 WHEN 21 THEN pace.d21 WHEN 22 THEN pace.d22 WHEN 23 THEN pace.d23 + WHEN 24 THEN pace.d24 WHEN 25 THEN pace.d25 WHEN 26 THEN pace.d26 WHEN 27 THEN pace.d27 + WHEN 28 THEN pace.d28 WHEN 29 THEN pace.d29 WHEN 30 THEN pace.d30 + WHEN 37 THEN pace.d37 WHEN 44 THEN pace.d44 WHEN 51 THEN pace.d51 WHEN 58 THEN pace.d58 + WHEN 65 THEN pace.d65 WHEN 72 THEN pace.d72 WHEN 79 THEN pace.d79 WHEN 86 THEN pace.d86 + WHEN 93 THEN pace.d93 WHEN 100 THEN pace.d100 WHEN 107 THEN pace.d107 WHEN 114 THEN pace.d114 + WHEN 121 THEN pace.d121 WHEN 128 THEN pace.d128 WHEN 135 THEN pace.d135 WHEN 142 THEN pace.d142 + WHEN 149 THEN pace.d149 WHEN 156 THEN pace.d156 WHEN 163 THEN pace.d163 WHEN 170 THEN pace.d170 + WHEN 177 THEN pace.d177 WHEN 210 THEN pace.d210 WHEN 240 THEN pace.d240 WHEN 270 THEN pace.d270 + WHEN 300 THEN pace.d300 WHEN 330 THEN pace.d330 WHEN 365 THEN pace.d365 + ELSE NULL + END + END as booking_count + FROM newbook_booking_pace pace + WHERE pace.arrival_date BETWEEN CAST(:start_date AS date) - 364 + AND CAST(:end_date AS date) - 364 + ) + SELECT + dr.date, + EXTRACT(DOW FROM dr.date) as dow, + CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value, + CASE + WHEN dr.date < :today THEN py.value + ELSE pyp.booking_count + END as prior_year_value, + CASE + WHEN b.budget_value IS NOT NULL AND arr.arr IS NOT NULL AND arr.arr > 0 + THEN LEAST(COALESCE(bc.bookable_count, :default_cap), CEIL(b.budget_value / arr.arr)) + ELSE NULL + END as budget_value, + CASE WHEN dr.date >= :today THEN o.otb ELSE NULL END as otb_value + FROM date_range dr + LEFT JOIN actuals a ON dr.date = a.date + LEFT JOIN prior_year py ON dr.date = py.target_date + LEFT JOIN otb_data o ON dr.date = o.date + LEFT JOIN budgets b ON dr.date = b.date + LEFT JOIN arr_forecast arr ON dr.date = arr.date + LEFT JOIN bookable bc ON dr.date = bc.date + LEFT JOIN prior_year_pace pyp ON dr.date = pyp.target_date + ORDER BY dr.date + """) + elif metric == 'guests': + # Guests from newbook_bookings_stats + query = text(""" + WITH date_range AS ( + SELECT generate_series(CAST(:start_date AS date), CAST(:end_date AS date), '1 day'::interval)::date as date + ), + actuals AS ( + SELECT date, guests_count as value + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ), + prior_year AS ( + SELECT date + interval '364 days' as target_date, guests_count as value + FROM newbook_bookings_stats + WHERE date BETWEEN CAST(:start_date AS date) - interval '364 days' AND CAST(:end_date AS date) - interval '364 days' + ), + otb_data AS ( + SELECT date, guests_count as otb + FROM newbook_bookings_stats + WHERE date BETWEEN :start_date AND :end_date + ) + SELECT + dr.date, + EXTRACT(DOW FROM dr.date) as dow, + CASE WHEN dr.date < :today THEN a.value ELSE NULL END as actual_value, + py.value as prior_year_value, + NULL::numeric as budget_value, + CASE WHEN dr.date >= :today THEN o.otb ELSE NULL END as otb_value + FROM date_range dr + LEFT JOIN actuals a ON dr.date = a.date + LEFT JOIN prior_year py ON dr.date = py.target_date + LEFT JOIN otb_data o ON dr.date = o.date + ORDER BY dr.date + """) + else: + # Default to rooms + return await get_actuals_data(start_date, end_date, 'rooms', db, current_user) + + result = await db.execute(query, {"start_date": start, "end_date": end, "metric": metric, "today": today, "default_cap": default_cap}) + rows = result.fetchall() + + data = [] + actual_total = 0 + prior_total = 0 + budget_total = 0 + otb_total = 0 + actual_count = 0 + otb_count = 0 + + for row in rows: + actual_val = float(row.actual_value) if row.actual_value is not None else None + prior_val = float(row.prior_year_value) if row.prior_year_value is not None else None + budget_val = float(row.budget_value) if row.budget_value is not None else None + otb_val = float(row.otb_value) if row.otb_value is not None else None + + data.append(ActualsDataPoint( + date=row.date.isoformat(), + day_of_week=day_names[int(row.dow)], + actual_value=actual_val, + prior_year_value=prior_val, + budget_value=budget_val, + otb_value=otb_val + )) + + if actual_val is not None: + actual_total += actual_val + actual_count += 1 + if prior_val is not None: + prior_total += prior_val + if budget_val is not None: + budget_total += budget_val + if otb_val is not None: + otb_total += otb_val + otb_count += 1 + + summary = { + "actual_total": actual_total, + "prior_year_total": prior_total, + "budget_total": budget_total, + "otb_total": otb_total, + "days_with_actuals": actual_count, + "days_with_otb": otb_count, + "total_days": len(data) + } + + return ActualsResponse(data=data, summary=summary) + + +# ============================================ +# PICKUP-V2 PREVIEW ENDPOINT +# ============================================ + +class PickupV2DataPoint(BaseModel): + date: str + day_of_week: str + lead_days: int + prior_year_date: str + # Revenue metrics + current_otb_rev: Optional[float] = None + prior_year_otb_rev: Optional[float] = None + prior_year_final_rev: Optional[float] = None + expected_pickup_rev: Optional[float] = None + forecast: float + upper_bound: Optional[float] = None + lower_bound: Optional[float] = None + ceiling: Optional[float] = None + # Scenario values + at_prior_adr: Optional[float] = None # Revenue at prior year pickup ADR + at_current_rate: Optional[float] = None # Revenue at current rack rates + at_cheaper_50: Optional[float] = None # Revenue at cheaper 50% of prior rates + at_expensive_50: Optional[float] = None # Revenue at expensive 50% of prior rates + # Pricing opportunity fields + has_pricing_opportunity: Optional[bool] = None # True if current rate < prior ADR + lost_potential: Optional[float] = None # Revenue left on table (0 if none) + rate_gap: Optional[float] = None # Negative = opportunity to raise rates + rate_vs_prior_pct: Optional[float] = None # % diff between current and prior rates + pace_vs_prior_pct: Optional[float] = None + pickup_rooms_total: Optional[int] = None # Number of pickup rooms expected + # Weighted average rates per room for display (net) + weighted_avg_prior_rate: Optional[float] = None # Prior year pickup ADR (net) + weighted_avg_current_rate: Optional[float] = None # Current rack rate (net) + # Gross rates (inc VAT) for UI display + weighted_avg_prior_rate_gross: Optional[float] = None # Prior year ADR (gross) + weighted_avg_current_rate_gross: Optional[float] = None # Current rate (gross) + # Listed rate at lead time (earliest bookings) - for rate comparison + weighted_avg_listed_rate: Optional[float] = None # LY listed rate at this lead time (net) + weighted_avg_listed_rate_gross: Optional[float] = None # LY listed rate (gross) + # Effective rate = rate actually used in forecast (min of prior and current) + effective_rate: Optional[float] = None # Rate used in forecast (net) + effective_rate_gross: Optional[float] = None # Rate used in forecast (gross) + # Room metrics (when metric is rooms/occupancy) + current_otb: Optional[float] = None + prior_year_otb: Optional[int] = None + prior_year_final: Optional[int] = None + expected_pickup: Optional[int] = None + floor: Optional[float] = None + category_breakdown: Optional[dict] = None + + +class PickupV2Summary(BaseModel): + otb_rev_total: Optional[float] = None + forecast_total: float + upper_total: Optional[float] = None + lower_total: Optional[float] = None + prior_final_total: Optional[float] = None + avg_adr_position: Optional[float] = None + avg_pace_pct: Optional[float] = None + days_count: int + # Pricing opportunity summary + lost_potential_total: Optional[float] = None # Total revenue left on table + opportunity_days_count: Optional[int] = None # Days with pricing opportunities + + +class PickupV2Response(BaseModel): + data: List[PickupV2DataPoint] + summary: PickupV2Summary + + +@router.get("/pickup-v2-preview", response_model=PickupV2Response) +async def get_pickup_v2_preview( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + metric: str = Query("net_accom", description="Metric type: net_accom, hotel_room_nights, hotel_occupancy_pct"), + include_details: bool = Query(False, description="Include category breakdown"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Pickup-V2 preview supporting both room and revenue metrics. + + Revenue forecast uses additive pickup methodology: + - Forecast = Current OTB + (Prior Year Final - Prior Year OTB at same lead time) + - Floor: Current OTB (can't go below what's booked) + - Ceiling: Based on remaining capacity × current rates per category + + Returns confidence bounds for revenue based on rate analysis: + - Upper bound: OTB + (remaining rooms × current rate per category) + - Lower bound: OTB + (remaining rooms × min historical rate per category) + - ADR position: where current ADR falls between min/max (0-1 scale, indicates pricing pressure) + """ + from datetime import datetime + from services.forecasting.pickup_v2_model import run_pickup_v2_forecast, get_pickup_v2_summary + + 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") + + # Map frontend metric names to model metric codes + metric_map = { + 'net_accom': 'net_accom', + 'rooms': 'hotel_room_nights', + 'hotel_room_nights': 'hotel_room_nights', + 'occupancy': 'hotel_occupancy_pct', + 'hotel_occupancy_pct': 'hotel_occupancy_pct' + } + metric_code = metric_map.get(metric, metric) + + try: + # Run the pickup-v2 forecast + forecasts = await run_pickup_v2_forecast( + db, metric_code, start, end, include_details=include_details + ) + + # Build response data + data = [] + for fc in forecasts: + data.append(PickupV2DataPoint( + date=fc['date'], + day_of_week=fc['day_of_week'], + lead_days=fc['lead_days'], + prior_year_date=fc['prior_year_date'], + current_otb_rev=fc.get('current_otb_rev'), + prior_year_otb_rev=fc.get('prior_year_otb_rev'), + prior_year_final_rev=fc.get('prior_year_final_rev'), + expected_pickup_rev=fc.get('expected_pickup_rev'), + forecast=fc.get('forecast', fc.get('predicted_value', 0)), + upper_bound=fc.get('upper_bound'), + lower_bound=fc.get('lower_bound'), + ceiling=fc.get('ceiling'), + # Scenario values + at_prior_adr=fc.get('at_prior_adr'), + at_current_rate=fc.get('at_current_rate'), + at_cheaper_50=fc.get('at_cheaper_50'), + at_expensive_50=fc.get('at_expensive_50'), + # Pricing opportunity fields + has_pricing_opportunity=fc.get('has_pricing_opportunity'), + lost_potential=fc.get('lost_potential'), + rate_gap=fc.get('rate_gap'), + rate_vs_prior_pct=fc.get('rate_vs_prior_pct'), + pace_vs_prior_pct=fc.get('pace_vs_prior_pct'), + pickup_rooms_total=fc.get('pickup_rooms_total'), + # Weighted average rates per room (net and gross) + weighted_avg_prior_rate=fc.get('weighted_avg_prior_rate'), + weighted_avg_current_rate=fc.get('weighted_avg_current_rate'), + weighted_avg_prior_rate_gross=fc.get('weighted_avg_prior_rate_gross'), + weighted_avg_current_rate_gross=fc.get('weighted_avg_current_rate_gross'), + # Listed rate at lead time (earliest bookings) - for rate comparison + weighted_avg_listed_rate=fc.get('weighted_avg_listed_rate'), + weighted_avg_listed_rate_gross=fc.get('weighted_avg_listed_rate_gross'), + # Effective rate = rate actually used in forecast (min of prior and current) + effective_rate=fc.get('effective_rate'), + effective_rate_gross=fc.get('effective_rate_gross'), + # Room metrics + current_otb=fc.get('current_otb'), + prior_year_otb=fc.get('prior_year_otb'), + prior_year_final=fc.get('prior_year_final'), + expected_pickup=fc.get('expected_pickup'), + floor=fc.get('floor'), + category_breakdown=fc.get('category_breakdown') if include_details else None + )) + + # Calculate summary + if metric_code == 'net_accom': + # Calculate pricing opportunity totals + lost_potential_total = sum(f.get('lost_potential', 0) or 0 for f in forecasts) + opportunity_days = sum(1 for f in forecasts if f.get('has_pricing_opportunity', False)) + + summary = PickupV2Summary( + otb_rev_total=sum(f.get('current_otb_rev', 0) or 0 for f in forecasts), + forecast_total=sum(f.get('forecast', 0) or 0 for f in forecasts), + upper_total=sum(f.get('upper_bound', 0) or 0 for f in forecasts), + lower_total=sum(f.get('lower_bound', 0) or 0 for f in forecasts), + prior_final_total=sum(f.get('prior_year_final_rev', 0) or 0 for f in forecasts), + avg_adr_position=sum(f.get('adr_position', 0.5) or 0.5 for f in forecasts) / max(len(forecasts), 1), + avg_pace_pct=sum(f.get('pace_vs_prior_pct', 0) or 0 for f in forecasts) / max(len(forecasts), 1), + days_count=len(forecasts), + lost_potential_total=lost_potential_total, + opportunity_days_count=opportunity_days + ) + else: + summary = PickupV2Summary( + forecast_total=sum(f.get('forecast', 0) or 0 for f in forecasts), + prior_final_total=sum(f.get('prior_year_final', 0) or 0 for f in forecasts), + avg_pace_pct=sum(f.get('pace_vs_prior_pct', 0) or 0 for f in forecasts) / max(len(forecasts), 1), + days_count=len(forecasts) + ) + + return PickupV2Response(data=data, summary=summary) + + except Exception as e: + import logging + logging.error(f"Pickup-V2 preview failed: {e}") + raise HTTPException(status_code=500, detail=f"Forecast generation failed: {str(e)}") + + +# ============================================ +# RESTAURANT COVERS FORECAST +# ============================================ + +class CoversDataPoint(BaseModel): + date: str + day_of_week: str + lead_days: int + prior_year_date: str + # Breakfast (based on hotel guest count from night before) + breakfast_otb: int + breakfast_pickup: int + breakfast_forecast: int + breakfast_prior: int + breakfast_hotel_guests_otb: int + breakfast_hotel_guests_prior: int + breakfast_calc: Optional[dict] = None # Calculation breakdown for tooltip + # Lunch (simple OTB + pickup) + lunch_otb: int + lunch_pickup: int + lunch_forecast: int + lunch_prior: int + lunch_calc: Optional[dict] = None # Calculation breakdown for tooltip + # Dinner + dinner_otb: int + dinner_resident_otb: int + dinner_non_resident_otb: int + dinner_resident_pickup: int + dinner_non_resident_pickup: int + dinner_forecast: int + dinner_prior: int + dinner_resident_calc: Optional[dict] = None # Calculation breakdown for tooltip + dinner_non_resident_calc: Optional[dict] = None # Calculation breakdown for tooltip + # Totals + total_otb: int + total_forecast: int + total_prior: int + pace_vs_prior_pct: Optional[float] + # Hotel context + hotel_occupancy_pct: float + hotel_rooms: int + + +class CoversSummary(BaseModel): + breakfast_otb: int + breakfast_forecast: int + breakfast_prior: int + lunch_otb: int + lunch_forecast: int + lunch_prior: int + dinner_otb: int + dinner_forecast: int + dinner_prior: int + total_otb: int + total_forecast: int + total_prior: int + days_count: int + + +class CoversResponse(BaseModel): + data: List[CoversDataPoint] + summary: CoversSummary + + +@router.get("/covers-forecast", response_model=CoversResponse) +async def get_covers_forecast( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + include_details: bool = Query(False, description="Include detailed breakdown"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get restaurant covers forecast for a date range. + + Returns covers forecast by meal period (breakfast, lunch, dinner) with + breakdown by guest segment (resident/non-resident). + + Breakfast is forecast based on previous night's hotel occupancy. + Lunch and dinner use OTB bookings plus pickup forecasts. + """ + from datetime import datetime + from services.forecasting.covers_model import forecast_covers_range + + 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") + + try: + result = await forecast_covers_range(db, start, end, include_details) + + # Transform to response format + data = [] + for fc in result["data"]: + data.append(CoversDataPoint( + date=fc["date"], + day_of_week=fc["day_of_week"], + lead_days=fc["lead_days"], + prior_year_date=fc["prior_year_date"], + # Breakfast (based on hotel guest count from night before) + breakfast_otb=fc["breakfast"]["otb"], + breakfast_pickup=fc["breakfast"]["pickup"], + breakfast_forecast=fc["breakfast"]["forecast"], + breakfast_prior=fc["breakfast"]["prior_year"], + breakfast_hotel_guests_otb=fc["breakfast"]["hotel_guests_otb"], + breakfast_hotel_guests_prior=fc["breakfast"]["hotel_guests_prior"], + breakfast_calc=fc["breakfast"].get("calc"), + # Lunch (simple OTB + pickup) + lunch_otb=fc["lunch"]["otb"], + lunch_pickup=fc["lunch"]["pickup"], + lunch_forecast=fc["lunch"]["forecast"], + lunch_prior=fc["lunch"]["prior_year"], + lunch_calc=fc["lunch"].get("calc"), + # Dinner + dinner_otb=fc["dinner"]["otb"], + dinner_resident_otb=fc["dinner"]["resident_otb"], + dinner_non_resident_otb=fc["dinner"]["non_resident_otb"], + dinner_resident_pickup=fc["dinner"]["resident_pickup"], + dinner_non_resident_pickup=fc["dinner"]["non_resident_pickup"], + dinner_forecast=fc["dinner"]["forecast"], + dinner_prior=fc["dinner"]["prior_year"], + dinner_resident_calc=fc["dinner"].get("resident_calc"), + dinner_non_resident_calc=fc["dinner"].get("non_resident_calc"), + # Totals + total_otb=fc["totals"]["otb"], + total_forecast=fc["totals"]["forecast"], + total_prior=fc["totals"]["prior_year"], + pace_vs_prior_pct=fc["totals"]["pace_vs_prior_pct"], + # Hotel context + hotel_occupancy_pct=fc["hotel_context"]["night_before_occupancy"], + hotel_rooms=fc["hotel_context"]["night_before_rooms"] + )) + + summary = CoversSummary( + breakfast_otb=result["summary"]["breakfast_otb"], + breakfast_forecast=result["summary"]["breakfast_forecast"], + breakfast_prior=result["summary"]["breakfast_prior"], + lunch_otb=result["summary"]["lunch_otb"], + lunch_forecast=result["summary"]["lunch_forecast"], + lunch_prior=result["summary"]["lunch_prior"], + dinner_otb=result["summary"]["dinner_otb"], + dinner_forecast=result["summary"]["dinner_forecast"], + dinner_prior=result["summary"]["dinner_prior"], + total_otb=result["summary"]["total_otb"], + total_forecast=result["summary"]["total_forecast"], + total_prior=result["summary"]["total_prior"], + days_count=result["summary"]["days_count"] + ) + + return CoversResponse(data=data, summary=summary) + + except Exception as e: + import logging + logging.error(f"Covers forecast failed: {e}") + raise HTTPException(status_code=500, detail=f"Covers forecast failed: {str(e)}") + + +@router.get("/revenue-forecast") +async def get_revenue_forecast( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + revenue_type: str = Query("dry", description="Revenue type: dry, wet, or total"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get restaurant revenue forecast for a date range. + + Returns: + - Past dates: Actual revenue from newbook_net_revenue_data + - Future dates: Forecast revenue (covers × spend) + - Prior year values for comparison + """ + from datetime import datetime, date as date_type + from services.forecasting.covers_model import forecast_covers_range + + 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") + + today = date_type.today() + VAT_RATE = 1.20 + + def get_prior_year_date(d: date_type) -> date_type: + """ + Get prior year date with 364-day offset for day-of-week alignment. + 52 weeks = 364 days, so Monday aligns with Monday. + """ + return d - timedelta(days=364) + + # Get spend settings + 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) -> float: + """Get net spend per cover for a period""" + 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: # total + 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 for past dates + actual_result = await db.execute( + text(""" + SELECT date, dry, wet, (dry + wet) as total + FROM newbook_net_revenue_data + WHERE date >= :start_date AND date <= :end_date + """), + {"start_date": start, "end_date": end} + ) + actual_rows = actual_result.fetchall() + actual_by_date = {row.date: row for row in actual_rows} + + # Get prior year actual revenue + prior_start = get_prior_year_date(start) + prior_end = get_prior_year_date(end) + prior_result = await db.execute( + text(""" + SELECT date, dry, wet, (dry + wet) as total + FROM newbook_net_revenue_data + 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} + + # Get covers forecast for future dates + covers_data = await forecast_covers_range(db, start, end, include_details=False) + + # Build response + data = [] + current = start + while current <= end: + is_past = current < today + prior_date = get_prior_year_date(current) + + # Get prior year revenue + prior_row = prior_by_date.get(prior_date) + if revenue_type == 'dry': + prior_revenue = float(prior_row.dry) if prior_row else 0 + elif revenue_type == 'wet': + prior_revenue = float(prior_row.wet) if prior_row else 0 + else: + prior_revenue = float(prior_row.total) if prior_row else 0 + + if is_past: + # Past: use actual revenue + actual_row = actual_by_date.get(current) + if revenue_type == 'dry': + actual_revenue = float(actual_row.dry) if actual_row else 0 + elif revenue_type == 'wet': + actual_revenue = float(actual_row.wet) if actual_row else 0 + else: + actual_revenue = float(actual_row.total) if actual_row else 0 + + data.append({ + "date": current.isoformat(), + "day_of_week": current.strftime("%A"), + "is_past": True, + "actual_revenue": actual_revenue, + "otb_revenue": actual_revenue, # For past, OTB = actual + "pickup_revenue": 0, + "forecast_revenue": actual_revenue, + "prior_revenue": prior_revenue, + }) + else: + # Future: calculate from covers forecast + day_covers = next((c for c in covers_data["data"] if c["date"] == current.isoformat()), None) + + if day_covers: + breakfast_otb = day_covers["breakfast"]["otb"] + lunch_otb = day_covers["lunch"]["otb"] + dinner_otb = day_covers["dinner"]["otb"] + + breakfast_pickup = day_covers["breakfast"]["pickup"] + lunch_pickup = day_covers["lunch"]["pickup"] + dinner_resident_pickup = day_covers["dinner"]["resident_pickup"] + dinner_non_resident_pickup = day_covers["dinner"]["non_resident_pickup"] + dinner_pickup = dinner_resident_pickup + dinner_non_resident_pickup + + otb_revenue = ( + breakfast_otb * get_spend_by_period('breakfast') + + lunch_otb * get_spend_by_period('lunch') + + dinner_otb * get_spend_by_period('dinner') + ) + + pickup_revenue = ( + breakfast_pickup * get_spend_by_period('breakfast') + + lunch_pickup * get_spend_by_period('lunch') + + dinner_pickup * get_spend_by_period('dinner') + ) + else: + otb_revenue = 0 + pickup_revenue = 0 + + data.append({ + "date": current.isoformat(), + "day_of_week": current.strftime("%A"), + "is_past": False, + "actual_revenue": 0, + "otb_revenue": otb_revenue, + "pickup_revenue": pickup_revenue, + "forecast_revenue": otb_revenue + pickup_revenue, + "prior_revenue": prior_revenue, + }) + + current += timedelta(days=1) + + # Calculate summary + past_data = [d for d in data if d["is_past"]] + future_data = [d for d in data if not d["is_past"]] + + summary = { + "actual_total": sum(d["actual_revenue"] for d in past_data), + "prior_actual_total": sum(d["prior_revenue"] for d in past_data), + "otb_total": sum(d["otb_revenue"] for d in future_data), + "pickup_total": sum(d["pickup_revenue"] for d in future_data), + "forecast_remaining": sum(d["forecast_revenue"] for d in future_data), + "prior_future_total": sum(d["prior_revenue"] for d in future_data), + "prior_year_total": sum(d["prior_revenue"] for d in data), + "projected_total": sum(d["actual_revenue"] for d in past_data) + sum(d["forecast_revenue"] for d in future_data), + "days_actual": len(past_data), + "days_forecast": len(future_data), + } + + return {"data": data, "summary": summary} + + +@router.get("/combined-revenue-forecast") +async def get_combined_revenue_forecast( + start_date: str = Query(..., description="Start date (YYYY-MM-DD)"), + end_date: str = Query(..., description="End date (YYYY-MM-DD)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get combined total revenue forecast (accom + dry + wet) for a date range. + + Returns: + - Past dates: Actual revenue from newbook_net_revenue_data (all revenue types) + - Future dates: Forecast revenue (accom from pickup-v2, dry/wet from covers × spend) + - Prior year actual values for comparison + """ + from datetime import datetime, date as date_type + 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() + 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") + + today = date_type.today() + VAT_RATE = 1.20 + + def get_prior_year_date(d: date_type) -> date_type: + """ + Get prior year date with 364-day offset for day-of-week alignment. + 52 weeks = 364 days, so Monday aligns with Monday. + """ + return d - timedelta(days=364) + + # 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: + """Get net spend per cover for a period""" + 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: # total + 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 for past dates (all types: accom, dry, wet) + 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_rows = actual_result.fetchall() + actual_by_date = {row.date: row for row in actual_rows} + + # Get prior year actual 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_rows = prior_result.fetchall() + prior_by_date = {row.date: row for row in prior_rows} + + # Get covers forecast for restaurant revenue (future dates) + covers_data = await forecast_covers_range(db, start, end, include_details=False) + + # Build response + data = [] + current = start + while current <= end: + is_past = current < today + prior_date = get_prior_year_date(current) + lead_days = (current - today).days if current >= today else 0 + + # Get prior year revenue (all types combined) + 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 + prior_total = prior_accom + prior_dry + prior_wet + + if is_past: + # Past: use actual revenue from database + actual_row = actual_by_date.get(current) + actual_accom = float(actual_row.accommodation) if actual_row and actual_row.accommodation else 0 + actual_dry = float(actual_row.dry) if actual_row and actual_row.dry else 0 + actual_wet = float(actual_row.wet) if actual_row and actual_row.wet else 0 + actual_total = actual_accom + actual_dry + actual_wet + + data.append({ + "date": current.isoformat(), + "day_of_week": current.strftime("%A"), + "is_past": True, + "actual_accom": actual_accom, + "actual_dry": actual_dry, + "actual_wet": actual_wet, + "actual_revenue": actual_total, + "otb_revenue": actual_total, # For past, OTB = actual + "pickup_revenue": 0, + "forecast_revenue": actual_total, + "prior_accom": prior_accom, + "prior_dry": prior_dry, + "prior_wet": prior_wet, + "prior_revenue": prior_total, + }) + else: + # Future: calculate forecast + # 1. Accommodation from pickup-v2 revenue model + try: + accom_forecast = await forecast_revenue_for_date( + db, current, lead_days, prior_date + ) + accom_otb = accom_forecast.get('current_otb_rev', 0) or 0 + accom_pickup = accom_forecast.get('forecast_pickup_rev', 0) or 0 + except Exception as e: + logger.warning(f"Accom forecast failed for {current}: {e}") + accom_otb = 0 + accom_pickup = 0 + + # 2. Restaurant from covers forecast × spend + day_covers = next((c for c in covers_data["data"] if c["date"] == current.isoformat()), None) + + if day_covers: + breakfast_otb = day_covers["breakfast"]["otb"] + lunch_otb = day_covers["lunch"]["otb"] + dinner_otb = day_covers["dinner"]["otb"] + + breakfast_pickup = day_covers["breakfast"]["pickup"] + lunch_pickup = day_covers["lunch"]["pickup"] + dinner_resident_pickup = day_covers["dinner"]["resident_pickup"] + dinner_non_resident_pickup = day_covers["dinner"]["non_resident_pickup"] + dinner_pickup = dinner_resident_pickup + dinner_non_resident_pickup + + 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_pickup = ( + breakfast_pickup * get_spend_by_period('breakfast', 'dry') + + lunch_pickup * get_spend_by_period('lunch', 'dry') + + dinner_pickup * 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_pickup = ( + breakfast_pickup * get_spend_by_period('breakfast', 'wet') + + lunch_pickup * get_spend_by_period('lunch', 'wet') + + dinner_pickup * get_spend_by_period('dinner', 'wet') + ) + else: + dry_otb = dry_pickup = wet_otb = wet_pickup = 0 + + total_otb = accom_otb + dry_otb + wet_otb + total_pickup = accom_pickup + dry_pickup + wet_pickup + + data.append({ + "date": current.isoformat(), + "day_of_week": current.strftime("%A"), + "is_past": False, + "actual_accom": 0, + "actual_dry": 0, + "actual_wet": 0, + "actual_revenue": 0, + "otb_accom": accom_otb, + "otb_dry": dry_otb, + "otb_wet": wet_otb, + "otb_revenue": total_otb, + "pickup_accom": accom_pickup, + "pickup_dry": dry_pickup, + "pickup_wet": wet_pickup, + "pickup_revenue": total_pickup, + "forecast_revenue": total_otb + total_pickup, + "prior_accom": prior_accom, + "prior_dry": prior_dry, + "prior_wet": prior_wet, + "prior_revenue": prior_total, + }) + + current += timedelta(days=1) + + # Calculate summary + past_data = [d for d in data if d["is_past"]] + future_data = [d for d in data if not d["is_past"]] + + summary = { + "actual_total": sum(d["actual_revenue"] for d in past_data), + "actual_accom": sum(d.get("actual_accom", 0) for d in past_data), + "actual_dry": sum(d.get("actual_dry", 0) for d in past_data), + "actual_wet": sum(d.get("actual_wet", 0) for d in past_data), + "prior_actual_total": sum(d["prior_revenue"] for d in past_data), + "otb_total": sum(d["otb_revenue"] for d in future_data), + "otb_accom": sum(d.get("otb_accom", 0) for d in future_data), + "otb_dry": sum(d.get("otb_dry", 0) for d in future_data), + "otb_wet": sum(d.get("otb_wet", 0) for d in future_data), + "pickup_total": sum(d["pickup_revenue"] for d in future_data), + "pickup_accom": sum(d.get("pickup_accom", 0) for d in future_data), + "pickup_dry": sum(d.get("pickup_dry", 0) for d in future_data), + "pickup_wet": sum(d.get("pickup_wet", 0) for d in future_data), + "forecast_remaining": sum(d["forecast_revenue"] for d in future_data), + "prior_future_total": sum(d["prior_revenue"] for d in future_data), + "prior_year_total": sum(d["prior_revenue"] for d in data), + "projected_total": sum(d["actual_revenue"] for d in past_data) + sum(d["forecast_revenue"] for d in future_data), + "days_actual": len(past_data), + "days_forecast": len(future_data), + } + + return {"data": data, "summary": summary} diff --git a/backend/api/historical.py b/backend/api/historical.py new file mode 100644 index 0000000..f160f3f --- /dev/null +++ b/backend/api/historical.py @@ -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 + ] diff --git a/backend/api/public.py b/backend/api/public.py new file mode 100644 index 0000000..9ee8e46 --- /dev/null +++ b/backend/api/public.py @@ -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), + }, + } + } diff --git a/backend/api/reconciliation.py b/backend/api/reconciliation.py new file mode 100644 index 0000000..54b38fe --- /dev/null +++ b/backend/api/reconciliation.py @@ -0,0 +1,1281 @@ +""" +Reconciliation API Endpoints + +Provides cash-up management, Newbook payment integration, multi-day reporting, +float management, attachment handling, and reconciliation settings. +""" +import os +import shutil +import logging +from datetime import date, datetime +from typing import Optional, List +from decimal import Decimal + +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File +from fastapi.responses import FileResponse +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, get_admin_user +from services.reconciliation_service import ( + categorize_payments, + calculate_payment_totals, + parse_till_transactions, + build_reconciliation_rows, + build_multi_day_report, + build_transaction_breakdown, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _parse_date(d: str) -> date: + """Convert YYYY-MM-DD string to datetime.date for asyncpg compatibility.""" + return date.fromisoformat(d) + +UPLOAD_DIR = "/app/uploads/reconciliation" + + +# ============================================ +# PYDANTIC MODELS +# ============================================ + +class DenominationEntry(BaseModel): + count_type: str # 'float' | 'takings' + denomination_type: str # 'note' | 'coin' + denomination_value: float + quantity: Optional[int] = None + value_entered: Optional[float] = None + total_amount: float + + +class CardMachineEntry(BaseModel): + machine_name: str + total_amount: float + amex_amount: float + visa_mc_amount: float + + +class ReconciliationEntry(BaseModel): + category: str + banked_amount: float + reported_amount: float + variance: float + + +class CashUpCreate(BaseModel): + session_date: str # YYYY-MM-DD + + +class CashUpUpdate(BaseModel): + denominations: List[DenominationEntry] = [] + card_machines: List[CardMachineEntry] = [] + reconciliation: List[ReconciliationEntry] = [] + notes: Optional[str] = None + total_float_counted: float = 0.0 + total_cash_counted: float = 0.0 + + +class FloatDenominationEntry(BaseModel): + denomination_value: float + quantity: int + total_amount: float + + +class FloatReceiptEntry(BaseModel): + receipt_value: float + receipt_description: Optional[str] = None + + +class FloatCountCreate(BaseModel): + count_type: str # 'petty_cash' | 'change_tin' | 'safe_cash' + count_date: Optional[str] = None + denominations: List[FloatDenominationEntry] = [] + receipts: List[FloatReceiptEntry] = [] + total_counted: float = 0.0 + total_receipts: float = 0.0 + target_amount: float = 0.0 + variance: float = 0.0 + notes: Optional[str] = None + + +class ReconSettingsUpdate(BaseModel): + expected_till_float: Optional[float] = None + variance_threshold: Optional[float] = None + default_report_days: Optional[int] = None + petty_cash_target: Optional[float] = None + change_tin_breakdown: Optional[dict] = None + safe_cash_target: Optional[float] = None + sales_breakdown_columns: Optional[list] = None + denominations: Optional[dict] = None + + +class BulkFinalizeRequest(BaseModel): + ids: List[int] + + +# ============================================ +# CASH UP CRUD +# ============================================ + +@router.get("/cash-ups") +async def list_cash_ups( + status: Optional[str] = Query(None, description="Filter by status (draft/final)"), + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """List cash-ups with filters and pagination.""" + conditions = [] + params = {} + + if status: + conditions.append("c.status = :status") + params["status"] = status + if date_from: + conditions.append("c.session_date >= :date_from") + params["date_from"] = _parse_date(date_from) + if date_to: + conditions.append("c.session_date <= :date_to") + params["date_to"] = _parse_date(date_to) + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + offset = (page - 1) * per_page + params["limit"] = per_page + params["offset"] = offset + + # Get count + count_result = await db.execute( + text(f"SELECT COUNT(*) FROM recon_cash_ups c {where}"), params + ) + total = count_result.scalar() + + # Get rows with variance data from reconciliation table + result = await db.execute( + text(f""" + SELECT c.*, u.display_name as created_by_name, + su.display_name as submitted_by_name, + COALESCE(SUM(r.variance), 0) as total_variance, + COALESCE(SUM(CASE WHEN r.category = 'Cash' THEN r.variance ELSE 0 END), 0) as cash_variance, + COALESCE(SUM(CASE WHEN r.category IN ('PDQ Visa/MC', 'PDQ Amex', 'Gateway Visa/MC', 'Gateway Amex') THEN r.variance ELSE 0 END), 0) as card_variance, + COALESCE(SUM(CASE WHEN r.category = 'BACS' THEN r.variance ELSE 0 END), 0) as bacs_variance + FROM recon_cash_ups c + LEFT JOIN users u ON c.created_by = u.id + LEFT JOIN users su ON c.submitted_by = su.id + LEFT JOIN recon_reconciliation r ON c.id = r.cash_up_id + {where} + GROUP BY c.id, u.display_name, su.display_name + ORDER BY c.session_date DESC + LIMIT :limit OFFSET :offset + """), params + ) + rows = result.fetchall() + + cash_ups = [] + for row in rows: + cash_ups.append({ + "id": row.id, + "session_date": row.session_date.isoformat() if row.session_date else None, + "status": row.status, + "total_float_counted": float(row.total_float_counted or 0), + "total_cash_counted": float(row.total_cash_counted or 0), + "notes": row.notes, + "created_by": row.created_by, + "created_by_name": row.created_by_name, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "submitted_at": row.submitted_at.isoformat() if row.submitted_at else None, + "submitted_by_name": row.submitted_by_name, + "total_variance": float(row.total_variance or 0), + "cash_variance": float(row.cash_variance or 0), + "card_variance": float(row.card_variance or 0), + "bacs_variance": float(row.bacs_variance or 0), + }) + + return { + "cash_ups": cash_ups, + "total": total, + "page": page, + "per_page": per_page, + "total_pages": (total + per_page - 1) // per_page + } + + +@router.get("/cash-ups/by-date/{session_date}") +async def get_cash_up_by_date( + session_date: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Check if cash-up exists for a given date and return full data.""" + result = await db.execute( + text("SELECT * FROM recon_cash_ups WHERE session_date = :d"), + {"d": _parse_date(session_date)} + ) + cash_up = result.fetchone() + if not cash_up: + raise HTTPException(status_code=404, detail="No cash-up found for this date") + + return await _build_full_cash_up(db, cash_up) + + +@router.get("/cash-ups/{cash_up_id}") +async def get_cash_up( + cash_up_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get full cash-up with all related data.""" + result = await db.execute( + text("SELECT * FROM recon_cash_ups WHERE id = :id"), + {"id": cash_up_id} + ) + cash_up = result.fetchone() + if not cash_up: + raise HTTPException(status_code=404, detail="Cash-up not found") + + return await _build_full_cash_up(db, cash_up) + + +async def _build_full_cash_up(db: AsyncSession, cash_up) -> dict: + """Build full cash-up response with denominations, cards, reconciliation, attachments.""" + cash_up_id = cash_up.id + + # Denominations + denom_result = await db.execute( + text("SELECT * FROM recon_denominations WHERE cash_up_id = :id ORDER BY count_type, denomination_value DESC"), + {"id": cash_up_id} + ) + denominations = [ + { + "id": d.id, + "count_type": d.count_type, + "denomination_type": d.denomination_type, + "denomination_value": float(d.denomination_value), + "quantity": d.quantity, + "value_entered": float(d.value_entered) if d.value_entered else None, + "total_amount": float(d.total_amount), + } + for d in denom_result.fetchall() + ] + + # Card machines + card_result = await db.execute( + text("SELECT * FROM recon_card_machines WHERE cash_up_id = :id"), + {"id": cash_up_id} + ) + card_machines = [ + { + "id": c.id, + "machine_name": c.machine_name, + "total_amount": float(c.total_amount), + "amex_amount": float(c.amex_amount), + "visa_mc_amount": float(c.visa_mc_amount), + } + for c in card_result.fetchall() + ] + + # Reconciliation rows + recon_result = await db.execute( + text("SELECT * FROM recon_reconciliation WHERE cash_up_id = :id"), + {"id": cash_up_id} + ) + reconciliation = [ + { + "id": r.id, + "category": r.category, + "banked_amount": float(r.banked_amount), + "reported_amount": float(r.reported_amount), + "variance": float(r.variance), + } + for r in recon_result.fetchall() + ] + + # Attachments + attach_result = await db.execute( + text("SELECT * FROM recon_attachments WHERE cash_up_id = :id ORDER BY uploaded_at DESC"), + {"id": cash_up_id} + ) + attachments = [ + { + "id": a.id, + "file_name": a.file_name, + "file_type": a.file_type, + "file_size": a.file_size, + "uploaded_at": a.uploaded_at.isoformat() if a.uploaded_at else None, + } + for a in attach_result.fetchall() + ] + + # Creator name + user_result = await db.execute( + text("SELECT display_name FROM users WHERE id = :id"), + {"id": cash_up.created_by} + ) + creator = user_result.fetchone() + + return { + "cash_up": { + "id": cash_up.id, + "session_date": cash_up.session_date.isoformat() if cash_up.session_date else None, + "status": cash_up.status, + "total_float_counted": float(cash_up.total_float_counted or 0), + "total_cash_counted": float(cash_up.total_cash_counted or 0), + "notes": cash_up.notes, + "created_by": cash_up.created_by, + "created_by_name": creator.display_name if creator else None, + "created_at": cash_up.created_at.isoformat() if cash_up.created_at else None, + "updated_at": cash_up.updated_at.isoformat() if cash_up.updated_at else None, + "submitted_at": cash_up.submitted_at.isoformat() if cash_up.submitted_at else None, + }, + "denominations": denominations, + "card_machines": card_machines, + "reconciliation": reconciliation, + "attachments": attachments, + } + + +@router.post("/cash-ups") +async def create_cash_up( + data: CashUpCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Create a new cash-up session.""" + # Check if date already exists + parsed_date = _parse_date(data.session_date) + existing = await db.execute( + text("SELECT id FROM recon_cash_ups WHERE session_date = :d"), + {"d": parsed_date} + ) + if existing.fetchone(): + raise HTTPException(status_code=409, detail="Cash-up already exists for this date") + + result = await db.execute( + text(""" + INSERT INTO recon_cash_ups (session_date, created_by, status, created_at, updated_at) + VALUES (:session_date, :created_by, 'draft', NOW(), NOW()) + RETURNING id, session_date, status, created_at + """), + {"session_date": parsed_date, "created_by": current_user["id"]} + ) + await db.commit() + row = result.fetchone() + return { + "id": row.id, + "session_date": row.session_date.isoformat(), + "status": row.status, + "created_at": row.created_at.isoformat(), + } + + +@router.put("/cash-ups/{cash_up_id}") +async def update_cash_up( + cash_up_id: int, + data: CashUpUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Update a cash-up (denominations, cards, reconciliation, notes).""" + # Verify exists and is draft + existing = await db.execute( + text("SELECT id, status FROM recon_cash_ups WHERE id = :id"), + {"id": cash_up_id} + ) + cash_up = existing.fetchone() + if not cash_up: + raise HTTPException(status_code=404, detail="Cash-up not found") + if cash_up.status == 'final': + raise HTTPException(status_code=400, detail="Cannot edit a finalized cash-up") + + # Update main record + await db.execute( + text(""" + UPDATE recon_cash_ups + SET total_float_counted = :float_total, + total_cash_counted = :cash_total, + notes = :notes, + updated_at = NOW() + WHERE id = :id + """), + { + "id": cash_up_id, + "float_total": data.total_float_counted, + "cash_total": data.total_cash_counted, + "notes": data.notes, + } + ) + + # Replace denominations + await db.execute( + text("DELETE FROM recon_denominations WHERE cash_up_id = :id"), + {"id": cash_up_id} + ) + for d in data.denominations: + await db.execute( + text(""" + INSERT INTO recon_denominations + (cash_up_id, count_type, denomination_type, denomination_value, quantity, value_entered, total_amount) + VALUES (:cid, :ct, :dt, :dv, :q, :ve, :ta) + """), + { + "cid": cash_up_id, + "ct": d.count_type, + "dt": d.denomination_type, + "dv": d.denomination_value, + "q": d.quantity, + "ve": d.value_entered, + "ta": d.total_amount, + } + ) + + # Replace card machines + await db.execute( + text("DELETE FROM recon_card_machines WHERE cash_up_id = :id"), + {"id": cash_up_id} + ) + for c in data.card_machines: + await db.execute( + text(""" + INSERT INTO recon_card_machines + (cash_up_id, machine_name, total_amount, amex_amount, visa_mc_amount) + VALUES (:cid, :mn, :ta, :aa, :vma) + """), + { + "cid": cash_up_id, + "mn": c.machine_name, + "ta": c.total_amount, + "aa": c.amex_amount, + "vma": c.visa_mc_amount, + } + ) + + # Replace reconciliation rows + await db.execute( + text("DELETE FROM recon_reconciliation WHERE cash_up_id = :id"), + {"id": cash_up_id} + ) + for r in data.reconciliation: + await db.execute( + text(""" + INSERT INTO recon_reconciliation + (cash_up_id, category, banked_amount, reported_amount, variance) + VALUES (:cid, :cat, :ba, :ra, :var) + """), + { + "cid": cash_up_id, + "cat": r.category, + "ba": r.banked_amount, + "ra": r.reported_amount, + "var": r.variance, + } + ) + + await db.commit() + return {"message": "Cash-up updated successfully", "id": cash_up_id} + + +@router.post("/cash-ups/{cash_up_id}/finalize") +async def finalize_cash_up( + cash_up_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Finalize a draft cash-up.""" + existing = await db.execute( + text("SELECT id, status FROM recon_cash_ups WHERE id = :id"), + {"id": cash_up_id} + ) + cash_up = existing.fetchone() + if not cash_up: + raise HTTPException(status_code=404, detail="Cash-up not found") + if cash_up.status == 'final': + raise HTTPException(status_code=400, detail="Cash-up is already finalized") + + await db.execute( + text(""" + UPDATE recon_cash_ups + SET status = 'final', submitted_at = NOW(), submitted_by = :user_id, updated_at = NOW() + WHERE id = :id + """), + {"id": cash_up_id, "user_id": current_user["id"]} + ) + await db.commit() + return {"message": "Cash-up finalized", "id": cash_up_id} + + +@router.delete("/cash-ups/{cash_up_id}") +async def delete_cash_up( + cash_up_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Delete a draft cash-up. Admins can delete any; staff only drafts they created.""" + existing = await db.execute( + text("SELECT id, status, created_by FROM recon_cash_ups WHERE id = :id"), + {"id": cash_up_id} + ) + cash_up = existing.fetchone() + if not cash_up: + raise HTTPException(status_code=404, detail="Cash-up not found") + if cash_up.status == 'final' and current_user.get("role") != "admin": + raise HTTPException(status_code=400, detail="Cannot delete a finalized cash-up") + + # Delete attachments from filesystem + attach_result = await db.execute( + text("SELECT file_path FROM recon_attachments WHERE cash_up_id = :id"), + {"id": cash_up_id} + ) + for a in attach_result.fetchall(): + if os.path.exists(a.file_path): + os.remove(a.file_path) + + # CASCADE handles child records + await db.execute( + text("DELETE FROM recon_cash_ups WHERE id = :id"), + {"id": cash_up_id} + ) + await db.commit() + return {"message": "Cash-up deleted"} + + +@router.post("/cash-ups/bulk-finalize") +async def bulk_finalize_cash_ups( + data: BulkFinalizeRequest, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_admin_user) +): + """Finalize multiple cash-ups (admin only).""" + finalized = 0 + errors = [] + for cash_up_id in data.ids: + result = await db.execute( + text("SELECT id, status FROM recon_cash_ups WHERE id = :id"), + {"id": cash_up_id} + ) + row = result.fetchone() + if not row: + errors.append(f"ID {cash_up_id}: not found") + elif row.status == 'final': + errors.append(f"ID {cash_up_id}: already finalized") + else: + await db.execute( + text(""" + UPDATE recon_cash_ups + SET status = 'final', submitted_at = NOW(), submitted_by = :user_id, updated_at = NOW() + WHERE id = :id + """), + {"id": cash_up_id, "user_id": current_user["id"]} + ) + finalized += 1 + + await db.commit() + return {"finalized": finalized, "errors": errors} + + +# ============================================ +# NEWBOOK PAYMENT INTEGRATION +# ============================================ + +@router.get("/newbook/payments/{payment_date}") +async def fetch_newbook_payments( + payment_date: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Fetch and categorize payments from Newbook for a given date.""" + from services.newbook_client import NewbookClient + + try: + target_date = date.fromisoformat(payment_date) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") + + try: + async with await NewbookClient.from_db(db) as client: + raw_transactions = await client.get_transaction_flow(target_date, target_date) + except Exception as e: + logger.error(f"Newbook API error: {e}") + raise HTTPException(status_code=502, detail=f"Newbook API error: {str(e)}") + + # Process transactions + payments = categorize_payments(raw_transactions) + totals = calculate_payment_totals(payments) + till_breakdown = parse_till_transactions(raw_transactions) + transaction_breakdown = build_transaction_breakdown(payments) + + # Store payment records (replace existing for this date) + await db.execute( + text("DELETE FROM recon_payment_records WHERE payment_date::date = :d"), + {"d": target_date} + ) + for p in payments: + # Parse payment_date string to datetime for asyncpg + pd_str = p['payment_date'] + try: + pd_val = datetime.fromisoformat(pd_str) if pd_str else datetime.now() + except (ValueError, TypeError): + pd_val = datetime.now() + await db.execute( + text(""" + INSERT INTO recon_payment_records + (newbook_payment_id, booking_id, guest_name, payment_date, + payment_type, payment_method, transaction_method, card_type, + amount, tendered, processed_by, item_type, synced_at) + VALUES (:pid, :bid, :gn, :pd, :pt, :pm, :tm, :ct, :amt, :ten, :pb, :it, NOW()) + """), + { + "pid": p['payment_id'], + "bid": p['booking_id'], + "gn": p['guest_name'], + "pd": pd_val, + "pt": p['payment_type'], + "pm": p['payment_method'], + "tm": p['transaction_method'], + "ct": p['card_type'], + "amt": p['amount'], + "ten": p['tendered'], + "pb": p['processed_by'], + "it": p['item_type'], + } + ) + await db.commit() + + return { + "date": payment_date, + "payment_count": len(payments), + "totals": totals, + "till_breakdown": till_breakdown, + "transaction_breakdown": transaction_breakdown, + "payments": payments, + } + + +@router.get("/newbook/daily-stats/{stats_date}") +async def fetch_newbook_daily_stats( + stats_date: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Fetch daily stats (occupancy, sales, debtors/creditors) from existing app data.""" + try: + target_date = date.fromisoformat(stats_date) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format") + + # Fetch from existing bookings stats + stats_result = await db.execute( + text(""" + SELECT booking_count as rooms_sold, guests_count as total_people, + guest_rate_total as gross_sales + FROM newbook_bookings_stats + WHERE date = :d + """), + {"d": target_date} + ) + stats_row = stats_result.fetchone() + + stats = { + "business_date": stats_date, + "rooms_sold": stats_row.rooms_sold if stats_row else 0, + "total_people": stats_row.total_people if stats_row else 0, + "gross_sales": float(stats_row.gross_sales) if stats_row else 0, + "debtors_creditors_balance": 0, + } + + # Upsert into recon_daily_stats + await db.execute( + text(""" + INSERT INTO recon_daily_stats (business_date, gross_sales, rooms_sold, total_people, source, updated_at) + VALUES (:d, :gs, :rs, :tp, 'newbook_auto', NOW()) + ON CONFLICT (business_date) + DO UPDATE SET gross_sales = :gs, rooms_sold = :rs, total_people = :tp, + source = 'newbook_auto', updated_at = NOW() + """), + {"d": target_date, "gs": stats["gross_sales"], "rs": stats["rooms_sold"], "tp": stats["total_people"]} + ) + await db.commit() + + return stats + + +# ============================================ +# MULTI-DAY REPORT +# ============================================ + +@router.get("/reports/multi-day") +async def generate_multi_day_report( + start_date: str = Query(...), + num_days: int = Query(7, ge=1, le=365), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Generate multi-day reconciliation report with 3 tables.""" + from datetime import timedelta + + try: + start = date.fromisoformat(start_date) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date format") + + end = start + timedelta(days=num_days - 1) + + # Fetch cash-ups for range + cu_result = await db.execute( + text(""" + SELECT c.* FROM recon_cash_ups c + WHERE c.session_date BETWEEN :start AND :end + ORDER BY c.session_date ASC + """), + {"start": start, "end": end} + ) + cash_up_rows = cu_result.fetchall() + + # Build cash-up dicts with card machines + cash_ups = [] + for cu in cash_up_rows: + cards_result = await db.execute( + text("SELECT * FROM recon_card_machines WHERE cash_up_id = :id"), + {"id": cu.id} + ) + card_machines = [ + {"machine_name": c.machine_name, "total_amount": float(c.total_amount), + "amex_amount": float(c.amex_amount), "visa_mc_amount": float(c.visa_mc_amount)} + for c in cards_result.fetchall() + ] + cash_ups.append({ + "session_date": cu.session_date.isoformat(), + "status": cu.status, + "total_float_counted": float(cu.total_float_counted or 0), + "total_cash_counted": float(cu.total_cash_counted or 0), + "card_machines": card_machines, + }) + + # Fetch stored payment totals by date from recon_payment_records + payment_totals_by_date = {} + pr_result = await db.execute( + text(""" + SELECT payment_date::date as pdate, card_type, transaction_method, + SUM(amount) as total + FROM recon_payment_records + WHERE payment_date::date BETWEEN :start AND :end + GROUP BY payment_date::date, card_type, transaction_method + """), + {"start": start, "end": end} + ) + for row in pr_result.fetchall(): + d = row.pdate.isoformat() + if d not in payment_totals_by_date: + payment_totals_by_date[d] = { + 'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0, + 'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0 + } + card_type = row.card_type or '' + tm = (row.transaction_method or '').lower() + amount = float(row.total or 0) + + if card_type == 'cash': + payment_totals_by_date[d]['cash'] += amount + elif card_type == 'bacs': + payment_totals_by_date[d]['bacs'] += amount + elif tm == 'manual': + if card_type == 'amex': + payment_totals_by_date[d]['manual_amex'] += amount + elif card_type == 'visa_mc': + payment_totals_by_date[d]['manual_visa_mc'] += amount + elif tm in ('automated', 'gateway', 'cc_gateway'): + if card_type == 'amex': + payment_totals_by_date[d]['gateway_amex'] += amount + elif card_type == 'visa_mc': + payment_totals_by_date[d]['gateway_visa_mc'] += amount + + # Fetch daily stats + ds_result = await db.execute( + text(""" + SELECT * FROM recon_daily_stats + WHERE business_date BETWEEN :start AND :end + ORDER BY business_date ASC + """), + {"start": start, "end": end} + ) + daily_stats = [ + { + "business_date": s.business_date.isoformat(), + "gross_sales": float(s.gross_sales or 0), + "rooms_sold": s.rooms_sold or 0, + "total_people": s.total_people or 0, + "debtors_creditors_balance": float(s.debtors_creditors_balance or 0), + } + for s in ds_result.fetchall() + ] + + # Fetch sales breakdown + sb_result = await db.execute( + text(""" + SELECT * FROM recon_sales_breakdown + WHERE business_date BETWEEN :start AND :end + ORDER BY business_date ASC, category ASC + """), + {"start": start, "end": end} + ) + sales_breakdown = [ + { + "business_date": s.business_date.isoformat(), + "category": s.category, + "net_amount": float(s.net_amount or 0), + } + for s in sb_result.fetchall() + ] + + report = build_multi_day_report(cash_ups, payment_totals_by_date, daily_stats, sales_breakdown) + return report + + +# ============================================ +# FLOAT COUNTS (Petty Cash, Change Tin, Safe Cash) +# ============================================ + +@router.get("/float-counts") +async def list_float_counts( + count_type: Optional[str] = Query(None), + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """List float counts with filters.""" + conditions = [] + params = {} + + if count_type: + conditions.append("f.count_type = :count_type") + params["count_type"] = count_type + if date_from: + conditions.append("f.count_date >= :date_from") + params["date_from"] = datetime.fromisoformat(date_from) + if date_to: + conditions.append("f.count_date <= :date_to") + params["date_to"] = datetime.fromisoformat(date_to + "T23:59:59") + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + offset = (page - 1) * per_page + params["limit"] = per_page + params["offset"] = offset + + count_result = await db.execute( + text(f"SELECT COUNT(*) FROM recon_float_counts f {where}"), params + ) + total = count_result.scalar() + + result = await db.execute( + text(f""" + SELECT f.*, u.display_name as created_by_name + FROM recon_float_counts f + LEFT JOIN users u ON f.created_by = u.id + {where} + ORDER BY f.count_date DESC + LIMIT :limit OFFSET :offset + """), params + ) + rows = result.fetchall() + + float_counts = [] + for row in rows: + float_counts.append({ + "id": row.id, + "count_type": row.count_type, + "count_date": row.count_date.isoformat() if row.count_date else None, + "total_counted": float(row.total_counted or 0), + "total_receipts": float(row.total_receipts or 0), + "target_amount": float(row.target_amount or 0), + "variance": float(row.variance or 0), + "notes": row.notes, + "created_by_name": row.created_by_name, + "created_at": row.created_at.isoformat() if row.created_at else None, + }) + + return {"float_counts": float_counts, "total": total, "page": page, "per_page": per_page} + + +@router.get("/float-counts/{count_id}") +async def get_float_count( + count_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get float count with denominations and receipts.""" + result = await db.execute( + text("SELECT f.*, u.display_name as created_by_name FROM recon_float_counts f LEFT JOIN users u ON f.created_by = u.id WHERE f.id = :id"), + {"id": count_id} + ) + fc = result.fetchone() + if not fc: + raise HTTPException(status_code=404, detail="Float count not found") + + denom_result = await db.execute( + text("SELECT * FROM recon_float_denominations WHERE float_count_id = :id ORDER BY denomination_value DESC"), + {"id": count_id} + ) + denominations = [ + {"denomination_value": float(d.denomination_value), "quantity": d.quantity, "total_amount": float(d.total_amount)} + for d in denom_result.fetchall() + ] + + receipt_result = await db.execute( + text("SELECT * FROM recon_float_receipts WHERE float_count_id = :id"), + {"id": count_id} + ) + receipts = [ + {"id": r.id, "receipt_value": float(r.receipt_value), "receipt_description": r.receipt_description} + for r in receipt_result.fetchall() + ] + + return { + "float_count": { + "id": fc.id, + "count_type": fc.count_type, + "count_date": fc.count_date.isoformat() if fc.count_date else None, + "total_counted": float(fc.total_counted or 0), + "total_receipts": float(fc.total_receipts or 0), + "target_amount": float(fc.target_amount or 0), + "variance": float(fc.variance or 0), + "notes": fc.notes, + "created_by_name": fc.created_by_name, + }, + "denominations": denominations, + "receipts": receipts, + } + + +@router.post("/float-counts") +async def create_float_count( + data: FloatCountCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Create a new float count.""" + count_date = datetime.fromisoformat(data.count_date) if data.count_date else datetime.now() + + result = await db.execute( + text(""" + INSERT INTO recon_float_counts + (count_type, count_date, created_by, total_counted, total_receipts, target_amount, variance, notes) + VALUES (:ct, :cd, :cb, :tc, :tr, :ta, :var, :notes) + RETURNING id + """), + { + "ct": data.count_type, + "cd": count_date, + "cb": current_user["id"], + "tc": data.total_counted, + "tr": data.total_receipts, + "ta": data.target_amount, + "var": data.variance, + "notes": data.notes, + } + ) + fc_id = result.fetchone().id + + # Insert denominations + for d in data.denominations: + await db.execute( + text(""" + INSERT INTO recon_float_denominations (float_count_id, denomination_value, quantity, total_amount) + VALUES (:fid, :dv, :q, :ta) + """), + {"fid": fc_id, "dv": d.denomination_value, "q": d.quantity, "ta": d.total_amount} + ) + + # Insert receipts + for r in data.receipts: + await db.execute( + text(""" + INSERT INTO recon_float_receipts (float_count_id, receipt_value, receipt_description) + VALUES (:fid, :rv, :rd) + """), + {"fid": fc_id, "rv": r.receipt_value, "rd": r.receipt_description} + ) + + await db.commit() + return {"id": fc_id, "message": "Float count saved"} + + +@router.put("/float-counts/{count_id}") +async def update_float_count( + count_id: int, + data: FloatCountCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Update a float count.""" + existing = await db.execute( + text("SELECT id FROM recon_float_counts WHERE id = :id"), + {"id": count_id} + ) + if not existing.fetchone(): + raise HTTPException(status_code=404, detail="Float count not found") + + await db.execute( + text(""" + UPDATE recon_float_counts + SET total_counted = :tc, total_receipts = :tr, target_amount = :ta, + variance = :var, notes = :notes + WHERE id = :id + """), + { + "id": count_id, + "tc": data.total_counted, + "tr": data.total_receipts, + "ta": data.target_amount, + "var": data.variance, + "notes": data.notes, + } + ) + + # Replace denominations + await db.execute(text("DELETE FROM recon_float_denominations WHERE float_count_id = :id"), {"id": count_id}) + for d in data.denominations: + await db.execute( + text("INSERT INTO recon_float_denominations (float_count_id, denomination_value, quantity, total_amount) VALUES (:fid, :dv, :q, :ta)"), + {"fid": count_id, "dv": d.denomination_value, "q": d.quantity, "ta": d.total_amount} + ) + + # Replace receipts + await db.execute(text("DELETE FROM recon_float_receipts WHERE float_count_id = :id"), {"id": count_id}) + for r in data.receipts: + await db.execute( + text("INSERT INTO recon_float_receipts (float_count_id, receipt_value, receipt_description) VALUES (:fid, :rv, :rd)"), + {"fid": count_id, "rv": r.receipt_value, "rd": r.receipt_description} + ) + + await db.commit() + return {"message": "Float count updated", "id": count_id} + + +@router.delete("/float-counts/{count_id}") +async def delete_float_count( + count_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Delete a float count.""" + existing = await db.execute( + text("SELECT id FROM recon_float_counts WHERE id = :id"), + {"id": count_id} + ) + if not existing.fetchone(): + raise HTTPException(status_code=404, detail="Float count not found") + + await db.execute(text("DELETE FROM recon_float_counts WHERE id = :id"), {"id": count_id}) + await db.commit() + return {"message": "Float count deleted"} + + +# ============================================ +# ATTACHMENTS +# ============================================ + +@router.post("/cash-ups/{cash_up_id}/attachments") +async def upload_attachment( + cash_up_id: int, + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Upload an attachment to a cash-up.""" + # Verify cash-up exists + existing = await db.execute( + text("SELECT id FROM recon_cash_ups WHERE id = :id"), + {"id": cash_up_id} + ) + if not existing.fetchone(): + raise HTTPException(status_code=404, detail="Cash-up not found") + + # Validate file type + allowed_types = ['image/jpeg', 'image/png', 'application/pdf'] + if file.content_type not in allowed_types: + raise HTTPException(status_code=400, detail=f"File type not allowed. Use JPEG, PNG, or PDF.") + + # Read file and check size (5MB max) + contents = await file.read() + if len(contents) > 5 * 1024 * 1024: + raise HTTPException(status_code=400, detail="File too large. Maximum 5MB.") + + # Save file + os.makedirs(UPLOAD_DIR, exist_ok=True) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + safe_name = f"{cash_up_id}_{timestamp}_{file.filename}" + file_path = os.path.join(UPLOAD_DIR, safe_name) + + with open(file_path, "wb") as f: + f.write(contents) + + # Insert record + result = await db.execute( + text(""" + INSERT INTO recon_attachments (cash_up_id, file_name, file_path, file_type, file_size, uploaded_by) + VALUES (:cid, :fn, :fp, :ft, :fs, :ub) + RETURNING id, file_name, uploaded_at + """), + { + "cid": cash_up_id, + "fn": file.filename, + "fp": file_path, + "ft": file.content_type, + "fs": len(contents), + "ub": current_user["id"], + } + ) + await db.commit() + row = result.fetchone() + + return { + "id": row.id, + "file_name": row.file_name, + "uploaded_at": row.uploaded_at.isoformat() if row.uploaded_at else None, + } + + +@router.delete("/attachments/{attachment_id}") +async def delete_attachment( + attachment_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Delete an attachment.""" + result = await db.execute( + text("SELECT id, file_path FROM recon_attachments WHERE id = :id"), + {"id": attachment_id} + ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Attachment not found") + + # Delete file + if os.path.exists(row.file_path): + os.remove(row.file_path) + + await db.execute(text("DELETE FROM recon_attachments WHERE id = :id"), {"id": attachment_id}) + await db.commit() + return {"message": "Attachment deleted"} + + +@router.get("/attachments/{attachment_id}/download") +async def download_attachment( + attachment_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Download an attachment file.""" + result = await db.execute( + text("SELECT file_name, file_path, file_type FROM recon_attachments WHERE id = :id"), + {"id": attachment_id} + ) + row = result.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Attachment not found") + if not os.path.exists(row.file_path): + raise HTTPException(status_code=404, detail="File not found on disk") + + return FileResponse( + path=row.file_path, + filename=row.file_name, + media_type=row.file_type + ) + + +# ============================================ +# RECONCILIATION SETTINGS +# ============================================ + +@router.get("/settings") +async def get_recon_settings( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Get all reconciliation settings from system_config.""" + result = await db.execute( + text("SELECT config_key, config_value FROM system_config WHERE config_key LIKE 'recon_%'") + ) + rows = result.fetchall() + + settings = {} + for row in rows: + key = row.config_key.replace('recon_', '', 1) + value = row.config_value + # Try to parse JSON values + if value and value.startswith('{') or value and value.startswith('['): + import json + try: + value = json.loads(value) + except (json.JSONDecodeError, TypeError): + pass + settings[key] = value + + return settings + + +@router.post("/settings") +async def update_recon_settings( + data: ReconSettingsUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_admin_user) +): + """Update reconciliation settings (admin only).""" + import json + + updates = {} + if data.expected_till_float is not None: + updates['recon_expected_till_float'] = str(data.expected_till_float) + if data.variance_threshold is not None: + updates['recon_variance_threshold'] = str(data.variance_threshold) + if data.default_report_days is not None: + updates['recon_default_report_days'] = str(data.default_report_days) + if data.petty_cash_target is not None: + updates['recon_petty_cash_target'] = str(data.petty_cash_target) + if data.change_tin_breakdown is not None: + updates['recon_change_tin_breakdown'] = json.dumps(data.change_tin_breakdown) + if data.safe_cash_target is not None: + updates['recon_safe_cash_target'] = str(data.safe_cash_target) + if data.sales_breakdown_columns is not None: + updates['recon_sales_breakdown_columns'] = json.dumps(data.sales_breakdown_columns) + if data.denominations is not None: + updates['recon_denominations'] = json.dumps(data.denominations) + + for key, value in updates.items(): + await db.execute( + text(""" + UPDATE system_config SET config_value = :val, updated_at = NOW(), updated_by = :user + WHERE config_key = :key + """), + {"key": key, "val": value, "user": current_user["username"]} + ) + + await db.commit() + return {"message": "Settings updated", "updated_keys": list(updates.keys())} + + +@router.post("/settings/refresh-gl-accounts") +async def refresh_gl_accounts( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_admin_user) +): + """Fetch GL accounts from Newbook and return for column configuration.""" + from services.newbook_client import NewbookClient + + try: + async with await NewbookClient.from_db(db) as client: + gl_accounts = await client.get_gl_account_list() + except Exception as e: + logger.error(f"Failed to fetch GL accounts: {e}") + raise HTTPException(status_code=502, detail=f"Newbook API error: {str(e)}") + + return {"gl_accounts": gl_accounts} diff --git a/backend/api/reports.py b/backend/api/reports.py new file mode 100644 index 0000000..1d68890 --- /dev/null +++ b/backend/api/reports.py @@ -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 + ) diff --git a/backend/api/resos.py b/backend/api/resos.py new file mode 100644 index 0000000..a61fe59 --- /dev/null +++ b/backend/api/resos.py @@ -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 + } diff --git a/backend/api/resos_sync.py b/backend/api/resos_sync.py new file mode 100644 index 0000000..9bf3871 --- /dev/null +++ b/backend/api/resos_sync.py @@ -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() diff --git a/backend/api/special_dates.py b/backend/api/special_dates.py new file mode 100644 index 0000000..e0e570d --- /dev/null +++ b/backend/api/special_dates.py @@ -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 diff --git a/backend/api/sync.py b/backend/api/sync.py new file mode 100644 index 0000000..4a8b90a --- /dev/null +++ b/backend/api/sync.py @@ -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() diff --git a/backend/api/sync_bookings.py b/backend/api/sync_bookings.py new file mode 100644 index 0000000..d3396b6 --- /dev/null +++ b/backend/api/sync_bookings.py @@ -0,0 +1,1426 @@ +""" +Newbook Bookings Data Sync API endpoints +Handles syncing booking data to newbook_bookings_data table +""" +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, SyncSessionLocal +from auth import get_current_user + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class SyncConfig(BaseModel): + """Auto sync configuration for bookings""" + enabled: bool + sync_type: str = "incremental" # "incremental" or "full" + sync_time: str = "05:00" # HH:MM format + + +class OccupancySyncConfig(BaseModel): + """Auto sync configuration for occupancy data""" + enabled: bool + sync_time: str = "05:00" # HH:MM format + + +class EarnedRevenueSyncConfig(BaseModel): + """Auto sync configuration for earned revenue data""" + enabled: bool + sync_time: str = "05:10" # HH:MM format + + +class CurrentRatesSyncConfig(BaseModel): + """Auto sync configuration for current rates data (pickup-v2)""" + enabled: bool + sync_time: str = "05:20" # HH:MM format + + +@router.get("/bookings-data/status") +async def get_bookings_sync_status( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get sync status for newbook 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 = 'newbook' 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 = 'newbook' 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_newbook_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_newbook_bookings_type'") + ) + row = result.fetchone() + sync_type = row.config_value if row and row.config_value else 'incremental' + + result = await db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'sync_newbook_bookings_time'") + ) + row = result.fetchone() + sync_time = row.config_value if row and row.config_value else '05:00' + + # Get total records in table + result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_bookings_data")) + total_records = result.fetchone().count + + return { + "last_successful_sync": { + "completed_at": last_success.completed_at if last_success else None, + "records_fetched": last_success.records_fetched if last_success else None, + "records_created": last_success.records_created if last_success 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 if last_sync else None, + "completed_at": last_sync.completed_at if last_sync else None, + "status": last_sync.status if last_sync else None, + "records_fetched": last_sync.records_fetched if last_sync 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, + "type": sync_type, + "time": sync_time + }, + "total_records": total_records + } + + +@router.get("/bookings-data/logs") +async def get_bookings_sync_logs( + limit: int = Query(5, description="Number of logs to return"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get recent sync logs for bookings data. + """ + 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 = 'newbook' AND sync_type = 'bookings_data' + ORDER BY started_at DESC + LIMIT :limit + """), + {"limit": limit} + ) + rows = result.fetchall() + + return [ + { + "id": row.id, + "started_at": row.started_at, + "completed_at": row.completed_at, + "status": row.status, + "records_fetched": row.records_fetched, + "records_created": row.records_created, + "date_from": row.date_from, + "date_to": row.date_to, + "error_message": row.error_message, + "triggered_by": row.triggered_by + } + for row in rows + ] + + +@router.post("/bookings-data/sync") +async def trigger_bookings_sync( + background_tasks: BackgroundTasks, + sync_mode: str = Query("incremental", description="Sync mode: 'incremental', 'staying_range', or 'full'"), + from_date: Optional[date] = Query(None, description="Start date for staying range sync"), + to_date: Optional[date] = Query(None, description="End date for staying range sync"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Trigger a bookings data sync. + + Modes: + - incremental: Fetch bookings modified since last successful sync (or last 7 days if no history) + - staying_range: Fetch bookings staying during the specified date range + - full: Fetch all bookings (warning: large dataset) + """ + if sync_mode == "staying_range" and (not from_date or not to_date): + raise HTTPException(status_code=400, detail="from_date and to_date required for staying_range mode") + + # Queue background task + background_tasks.add_task( + run_bookings_data_sync, + sync_mode=sync_mode, + from_date=from_date, + to_date=to_date, + triggered_by=f"user:{current_user['username']}" + ) + + msg = f"Bookings data sync started ({sync_mode})" + if sync_mode == "staying_range": + msg = f"Bookings data sync started for staying period {from_date} to {to_date}" + + return { + "status": "started", + "sync_mode": sync_mode, + "from_date": from_date, + "to_date": to_date, + "message": msg + } + + +@router.post("/bookings-data/config") +async def update_bookings_sync_config( + config: SyncConfig, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Update auto sync configuration for bookings data. + """ + # Upsert enabled setting + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by) + VALUES ('sync_newbook_bookings_enabled', :value, 'Enable automatic Newbook bookings sync', NOW(), :user) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :value, + updated_at = NOW(), + updated_by = :user + """), + {"value": str(config.enabled).lower(), "user": current_user['username']} + ) + + # Upsert sync type setting + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by) + VALUES ('sync_newbook_bookings_type', :value, 'Newbook bookings sync type (incremental/full)', NOW(), :user) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :value, + updated_at = NOW(), + updated_by = :user + """), + {"value": config.sync_type, "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_newbook_bookings_time', :value, 'Newbook 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", + "enabled": config.enabled, + "sync_type": config.sync_type, + "sync_time": config.sync_time + } + + +def run_bookings_data_sync( + sync_mode: str, + from_date: Optional[date] = None, + to_date: Optional[date] = None, + triggered_by: str = "scheduler" +): + """ + Background task to sync bookings data to newbook_bookings_data table. + + Modes: + - incremental: Uses modified_since from last successful sync (or -7 days fallback) + - staying_range: Uses bookings_list with list_type="staying" for date range + - full: Fetches all bookings + """ + import json + import sys + import asyncio + from services.newbook_client import NewbookClient + + print(f"[SYNC-BOOKINGS] Starting sync (mode={sync_mode})", flush=True) + sys.stdout.flush() + + db = SyncSessionLocal() + + try: + # Load credentials + def get_config(key): + result = db.execute( + text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + if row and row.config_value: + if row.is_encrypted: + import base64 + try: + return base64.b64decode(row.config_value.encode()).decode() + except: + return row.config_value + return row.config_value + return None + + creds = { + 'api_key': get_config('newbook_api_key'), + 'username': get_config('newbook_username'), + 'password': get_config('newbook_password'), + 'region': get_config('newbook_region') + } + + # Determine modified_since for incremental sync + modified_since = None + if sync_mode == "incremental": + # Get last successful sync + result = db.execute( + text(""" + SELECT completed_at FROM sync_log + WHERE source = 'newbook' AND sync_type = 'bookings_data' AND status = 'success' + ORDER BY completed_at DESC LIMIT 1 + """) + ) + row = result.fetchone() + if row and row.completed_at: + modified_since = row.completed_at.isoformat() + print(f"[SYNC-BOOKINGS] Incremental: fetching since {modified_since}", flush=True) + else: + # Fallback: last 7 days + fallback_date = date.today() - timedelta(days=7) + modified_since = fallback_date.isoformat() + "T00:00:00" + print(f"[SYNC-BOOKINGS] No history, fallback to {modified_since}", flush=True) + + # Log sync start + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by) + VALUES ('bookings_data', 'newbook', NOW(), 'running', :from_date, :to_date, :triggered_by) + """), + {"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by} + ) + db.commit() + + print("[SYNC-BOOKINGS] Creating NewbookClient...", flush=True) + + async def do_sync(): + async with NewbookClient( + api_key=creds['api_key'], + username=creds['username'], + password=creds['password'], + region=creds['region'] + ) as client: + # Test connection + if not await client.test_connection(): + raise Exception("Newbook connection failed") + + # Fetch bookings based on mode + if sync_mode == "staying_range" and from_date and to_date: + bookings = await client.get_bookings_by_stay_dates( + from_date=from_date, + to_date=to_date, + list_type="staying" + ) + print(f"[SYNC-BOOKINGS] Fetched {len(bookings)} bookings (staying {from_date} to {to_date})", flush=True) + elif sync_mode == "full": + bookings = await client.get_bookings(modified_since=None) + print(f"[SYNC-BOOKINGS] Fetched {len(bookings)} bookings (full sync)", flush=True) + else: + # Incremental + bookings = await client.get_bookings(modified_since=modified_since) + print(f"[SYNC-BOOKINGS] Fetched {len(bookings)} bookings (incremental)", flush=True) + + return bookings + + # Run async sync - create new event loop for background task + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + bookings = loop.run_until_complete(do_sync()) + finally: + loop.close() + + records_created = 0 + records_updated = 0 + + print(f"[SYNC-BOOKINGS] Processing {len(bookings)} bookings...", flush=True) + + for i, booking in enumerate(bookings): + newbook_id = booking.get("booking_id") + + if i > 0 and i % 100 == 0: + print(f"[SYNC-BOOKINGS] Processed {i}/{len(bookings)}", flush=True) + + if not newbook_id: + continue + + newbook_id = str(newbook_id) + + try: + # Create sanitized raw JSON (remove guest PII) + raw_booking = {k: v for k, v in booking.items() if k != "guests"} + raw_json_str = json.dumps(raw_booking) + + # Parse dates + arrival_raw = booking.get("booking_arrival") + departure_raw = booking.get("booking_departure") + arrival = arrival_raw.split(" ")[0] if arrival_raw else None + departure = departure_raw.split(" ")[0] if departure_raw else None + + # Extract all fields for the extended schema + status = booking.get("booking_status") + category_id = str(booking.get("category_id")) if booking.get("category_id") else None + category_name = booking.get("category_name") + + # Check if record exists + existing = db.execute( + text("SELECT id FROM newbook_bookings_data WHERE newbook_id = :nid"), + {"nid": newbook_id} + ).fetchone() + + # Upsert booking with extended fields + db.execute( + text(""" + INSERT INTO newbook_bookings_data ( + newbook_id, booking_reference, bookings_group_id, + booking_placed, arrival_date, departure_date, nights, + adults, children, infants, total_guests, + category_id, room_type, site_id, room_number, + status, total_amount, tariff_name, tariff_total, + travel_agent_id, travel_agent_name, travel_agent_commission, + booking_source_id, booking_source_name, + booking_parent_source_id, booking_parent_source_name, + booking_method_id, booking_method_name, + raw_json, fetched_at + ) VALUES ( + :newbook_id, :reference, :group_id, + :booking_placed, :arrival, :departure, :nights, + :adults, :children, :infants, :total_guests, + :category_id, :room_type, :site_id, :room_number, + :status, :total_amount, :tariff_name, :tariff_total, + :travel_agent_id, :travel_agent_name, :travel_agent_commission, + :source_id, :source_name, + :parent_source_id, :parent_source_name, + :method_id, :method_name, + :raw_json, NOW() + ) + ON CONFLICT (newbook_id) DO UPDATE SET + booking_reference = EXCLUDED.booking_reference, + booking_placed = COALESCE(EXCLUDED.booking_placed, newbook_bookings_data.booking_placed), + status = EXCLUDED.status, + total_amount = EXCLUDED.total_amount, + tariff_total = EXCLUDED.tariff_total, + travel_agent_commission = EXCLUDED.travel_agent_commission, + raw_json = EXCLUDED.raw_json, + fetched_at = NOW() + """), + { + "newbook_id": newbook_id, + "reference": booking.get("booking_reference_id"), + "group_id": str(booking.get("bookings_group_id")) if booking.get("bookings_group_id") else None, + "booking_placed": booking.get("booking_placed"), + "arrival": arrival, + "departure": departure, + "nights": booking.get("booking_length"), + "adults": int(booking.get("booking_adults") or 0), + "children": int(booking.get("booking_children") or 0), + "infants": int(booking.get("booking_infants") or 0), + "total_guests": int(booking.get("booking_adults") or 0) + int(booking.get("booking_children") or 0), + "category_id": category_id, + "room_type": category_name, + "site_id": str(booking.get("site_id")) if booking.get("site_id") else None, + "room_number": booking.get("site_name"), + "status": status, + "total_amount": booking.get("booking_total"), + "tariff_name": booking.get("tariff_name"), + "tariff_total": booking.get("tariff_total"), + "travel_agent_id": str(booking.get("travel_agent_id")) if booking.get("travel_agent_id") else None, + "travel_agent_name": booking.get("travel_agent_name"), + "travel_agent_commission": booking.get("travel_agent_commission"), + "source_id": str(booking.get("booking_source_id")) if booking.get("booking_source_id") else None, + "source_name": booking.get("booking_source_name"), + "parent_source_id": str(booking.get("booking_parent_source_id")) if booking.get("booking_parent_source_id") else None, + "parent_source_name": booking.get("booking_parent_source_name"), + "method_id": str(booking.get("booking_method_id")) if booking.get("booking_method_id") else None, + "method_name": booking.get("booking_method_name"), + "raw_json": raw_json_str + } + ) + + if existing: + records_updated += 1 + else: + records_created += 1 + + db.commit() + + except Exception as booking_error: + print(f"[SYNC-BOOKINGS] Error processing {newbook_id}: {booking_error}", flush=True) + logger.error(f"Error processing booking {newbook_id}: {booking_error}") + db.rollback() + continue + + # Update sync log - success + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'success', + records_fetched = :fetched, records_created = :created, records_updated = :updated + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND sync_type = 'bookings_data' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"fetched": len(bookings), "created": records_created, "updated": records_updated} + ) + db.commit() + + print(f"[SYNC-BOOKINGS] Completed: {records_created} created, {records_updated} updated", flush=True) + logger.info(f"Bookings data sync completed: {records_created} created, {records_updated} updated") + + # Trigger bookings aggregation after successful sync + print(f"[SYNC-BOOKINGS] Triggering bookings aggregation...", flush=True) + try: + from jobs.bookings_aggregation import run_bookings_aggregation + agg_loop = asyncio.new_event_loop() + asyncio.set_event_loop(agg_loop) + try: + agg_loop.run_until_complete(run_bookings_aggregation(triggered_by=triggered_by)) + print(f"[SYNC-BOOKINGS] Bookings aggregation completed", flush=True) + finally: + agg_loop.close() + except Exception as agg_error: + print(f"[SYNC-BOOKINGS] Aggregation warning: {agg_error}", flush=True) + logger.warning(f"Bookings aggregation failed (non-fatal): {agg_error}") + + except Exception as e: + print(f"[SYNC-BOOKINGS] FAILED: {e}", flush=True) + import traceback + traceback.print_exc() + logger.error(f"Bookings data sync failed: {e}") + try: + db.rollback() + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND sync_type = 'bookings_data' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"error": str(e)[:500]} + ) + db.commit() + except Exception as log_error: + logger.error(f"Failed to update sync_log: {log_error}") + raise + finally: + db.close() + + +# ============================================ +# OCCUPANCY DATA SYNC ENDPOINTS +# ============================================ + +@router.get("/occupancy-data/status") +async def get_occupancy_sync_status( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get sync status for newbook occupancy report 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 = 'newbook' AND sync_type = 'occupancy_report' + 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 = 'newbook' AND sync_type = 'occupancy_report' + 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_newbook_occupancy_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_newbook_occupancy_time'") + ) + row = result.fetchone() + sync_time = row.config_value if row and row.config_value else '05:00' + + # Get total records in table + result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_occupancy_report_data")) + total_records = result.fetchone().count + + # Get date range of data + result = await db.execute( + text("SELECT MIN(date) as min_date, MAX(date) as max_date FROM newbook_occupancy_report_data") + ) + date_range = result.fetchone() + + return { + "last_successful_sync": { + "completed_at": last_success.completed_at if last_success else None, + "records_fetched": last_success.records_fetched if last_success else None, + "records_created": last_success.records_created if last_success else None, + "date_from": last_success.date_from if last_success else None, + "date_to": last_success.date_to if last_success 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 if last_sync else None, + "completed_at": last_sync.completed_at if last_sync 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 if last_sync else None, + "date_to": last_sync.date_to if last_sync 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 if date_range else None, + "to": date_range.max_date if date_range else None + } + } + + +@router.get("/occupancy-data/logs") +async def get_occupancy_sync_logs( + limit: int = Query(5, description="Number of logs to return"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get recent sync logs for occupancy report data. + """ + 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 = 'newbook' AND sync_type = 'occupancy_report' + ORDER BY started_at DESC + LIMIT :limit + """), + {"limit": limit} + ) + rows = result.fetchall() + + return [ + { + "id": row.id, + "started_at": row.started_at, + "completed_at": row.completed_at, + "status": row.status, + "records_fetched": row.records_fetched, + "records_created": row.records_created, + "date_from": row.date_from, + "date_to": row.date_to, + "error_message": row.error_message, + "triggered_by": row.triggered_by + } + for row in rows + ] + + +@router.post("/occupancy-data/sync") +async def trigger_occupancy_sync( + background_tasks: BackgroundTasks, + from_date: Optional[date] = Query(None, description="Start date for sync (default: today - 7 days)"), + to_date: Optional[date] = Query(None, description="End date for sync (default: today + 365 days)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Trigger an occupancy report data sync. + + If dates not provided, defaults to -7 to +365 days from today. + """ + # Default date range + if not from_date: + from_date = date.today() - timedelta(days=7) + if not to_date: + to_date = date.today() + timedelta(days=365) + + # Queue background task + background_tasks.add_task( + run_occupancy_data_sync, + from_date=from_date, + to_date=to_date, + triggered_by=f"user:{current_user['username']}" + ) + + return { + "status": "started", + "from_date": from_date, + "to_date": to_date, + "message": f"Occupancy data sync started for {from_date} to {to_date}" + } + + +@router.post("/occupancy-data/config") +async def update_occupancy_sync_config( + config: OccupancySyncConfig, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Update auto sync configuration for occupancy report data. + """ + # Upsert enabled setting + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by) + VALUES ('sync_newbook_occupancy_enabled', :value, 'Enable automatic Newbook occupancy sync', NOW(), :user) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :value, + updated_at = NOW(), + updated_by = :user + """), + {"value": str(config.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_newbook_occupancy_time', :value, 'Newbook occupancy 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", + "enabled": config.enabled, + "sync_time": config.sync_time + } + + +def run_occupancy_data_sync( + from_date: date, + to_date: date, + triggered_by: str = "scheduler" +): + """ + Background task to sync occupancy report data to newbook_occupancy_report_data table. + """ + import sys + import asyncio + from jobs.data_sync import sync_newbook_occupancy_report + + print(f"[SYNC-OCCUPANCY] Starting sync ({from_date} to {to_date})", flush=True) + sys.stdout.flush() + + # Run async sync - create new event loop for background task + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete( + sync_newbook_occupancy_report(from_date, to_date, triggered_by) + ) + print(f"[SYNC-OCCUPANCY] Sync completed", flush=True) + except Exception as e: + print(f"[SYNC-OCCUPANCY] FAILED: {e}", flush=True) + import traceback + traceback.print_exc() + raise + finally: + loop.close() + + +# ============================================ +# EARNED REVENUE DATA SYNC ENDPOINTS +# ============================================ + +@router.get("/earned-revenue-data/status") +async def get_earned_revenue_sync_status( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get sync status for newbook earned revenue 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 = 'newbook' AND sync_type = 'earned_revenue' + 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 = 'newbook' AND sync_type = 'earned_revenue' + 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_newbook_earned_revenue_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_newbook_earned_revenue_time'") + ) + row = result.fetchone() + sync_time = row.config_value if row and row.config_value else '05:10' + + # Get total records in table + result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_earned_revenue_data")) + total_records = result.fetchone().count + + # Get date range of data + result = await db.execute( + text("SELECT MIN(date) as min_date, MAX(date) as max_date FROM newbook_earned_revenue_data") + ) + date_range = result.fetchone() + + return { + "last_successful_sync": { + "completed_at": last_success.completed_at if last_success else None, + "records_fetched": last_success.records_fetched if last_success else None, + "records_created": last_success.records_created if last_success else None, + "date_from": last_success.date_from if last_success else None, + "date_to": last_success.date_to if last_success 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 if last_sync else None, + "completed_at": last_sync.completed_at if last_sync 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 if last_sync else None, + "date_to": last_sync.date_to if last_sync 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 if date_range else None, + "to": date_range.max_date if date_range else None + } + } + + +@router.get("/earned-revenue-data/logs") +async def get_earned_revenue_sync_logs( + limit: int = Query(5, description="Number of logs to return"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get recent sync logs for earned revenue data. + """ + 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 = 'newbook' AND sync_type = 'earned_revenue' + ORDER BY started_at DESC + LIMIT :limit + """), + {"limit": limit} + ) + rows = result.fetchall() + + return [ + { + "id": row.id, + "started_at": row.started_at, + "completed_at": row.completed_at, + "status": row.status, + "records_fetched": row.records_fetched, + "records_created": row.records_created, + "date_from": row.date_from, + "date_to": row.date_to, + "error_message": row.error_message, + "triggered_by": row.triggered_by + } + for row in rows + ] + + +@router.post("/earned-revenue-data/sync") +async def trigger_earned_revenue_sync( + background_tasks: BackgroundTasks, + from_date: Optional[date] = Query(None, description="Start date for sync (default: today - 7 days)"), + to_date: Optional[date] = Query(None, description="End date for sync (default: today)"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Trigger an earned revenue data sync. + + If dates not provided, defaults to last 7 days (catches backdated adjustments). + """ + # Default date range - last 7 days (revenue is historical) + if not from_date: + from_date = date.today() - timedelta(days=7) + if not to_date: + to_date = date.today() + + # Queue background task + background_tasks.add_task( + run_earned_revenue_data_sync, + from_date=from_date, + to_date=to_date, + triggered_by=f"user:{current_user['username']}" + ) + + return { + "status": "started", + "from_date": from_date, + "to_date": to_date, + "message": f"Earned revenue sync started for {from_date} to {to_date}" + } + + +@router.post("/earned-revenue-data/config") +async def update_earned_revenue_sync_config( + config: EarnedRevenueSyncConfig, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Update auto sync configuration for earned revenue data. + """ + # Upsert enabled setting + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by) + VALUES ('sync_newbook_earned_revenue_enabled', :value, 'Enable automatic Newbook earned revenue sync', NOW(), :user) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :value, + updated_at = NOW(), + updated_by = :user + """), + {"value": str(config.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_newbook_earned_revenue_time', :value, 'Newbook earned revenue 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", + "enabled": config.enabled, + "sync_time": config.sync_time + } + + +def run_earned_revenue_data_sync( + from_date: date, + to_date: date, + triggered_by: str = "scheduler" +): + """ + Background task to sync earned revenue data to newbook_earned_revenue_data table. + """ + import sys + import asyncio + from jobs.data_sync import sync_newbook_earned_revenue + + print(f"[SYNC-EARNED-REV] Starting sync ({from_date} to {to_date})", flush=True) + sys.stdout.flush() + + # Run async sync - create new event loop for background task + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete( + sync_newbook_earned_revenue(from_date, to_date, triggered_by) + ) + print(f"[SYNC-EARNED-REV] Sync completed", flush=True) + except Exception as e: + print(f"[SYNC-EARNED-REV] FAILED: {e}", flush=True) + import traceback + traceback.print_exc() + raise + finally: + loop.close() + + +# ============================================ +# CURRENT RATES SYNC (Pickup-V2) +# ============================================ + +@router.get("/current-rates/status") +async def get_current_rates_sync_status( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get sync status for Newbook current rates data. + Used for pickup-v2 upper bound calculations. + """ + # Auto-clear stuck syncs (running for more than 60 minutes) + await db.execute( + text(""" + UPDATE sync_log + SET status = 'failed', completed_at = NOW(), + error_message = 'Auto-cleared: sync stuck for more than 60 minutes' + WHERE source = 'newbook' AND sync_type = 'current_rates' + AND status = 'running' + AND started_at < NOW() - INTERVAL '60 minutes' + """) + ) + await db.commit() + + # Get last successful sync + result = await db.execute( + text(""" + SELECT id, sync_type, started_at, completed_at, status, + records_fetched, records_created, records_updated, + error_message, triggered_by + FROM sync_log + WHERE source = 'newbook' AND sync_type = 'current_rates' + 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, + error_message, triggered_by + FROM sync_log + WHERE source = 'newbook' AND sync_type = 'current_rates' + 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_newbook_current_rates_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_newbook_current_rates_time'") + ) + row = result.fetchone() + sync_time = row.config_value if row and row.config_value else '05:20' + + # Get total records in table + result = await db.execute(text("SELECT COUNT(*) as count FROM newbook_current_rates")) + total_records = result.fetchone().count + + # Get date range of data + result = await db.execute( + text("SELECT MIN(rate_date) as min_date, MAX(rate_date) as max_date FROM newbook_current_rates") + ) + date_range = result.fetchone() + + # Get count by category + result = await db.execute( + text("SELECT category_id, COUNT(*) as count FROM newbook_current_rates GROUP BY category_id ORDER BY category_id") + ) + category_counts = {row.category_id: row.count for row in result.fetchall()} + + return { + "last_successful_sync": { + "completed_at": last_success.completed_at if last_success else None, + "records_fetched": last_success.records_fetched if last_success else None, + "records_created": last_success.records_created if last_success 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 if last_sync else None, + "completed_at": last_sync.completed_at if last_sync else None, + "status": last_sync.status if last_sync else None, + "records_fetched": last_sync.records_fetched if last_sync 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 if date_range else None, + "to": date_range.max_date if date_range else None + }, + "category_counts": category_counts + } + + +@router.get("/current-rates/logs") +async def get_current_rates_sync_logs( + limit: int = Query(5, description="Number of logs to return"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Get recent sync logs for current rates data. + """ + result = await db.execute( + text(""" + SELECT id, sync_type, started_at, completed_at, status, + records_fetched, records_created, records_updated, + error_message, triggered_by + FROM sync_log + WHERE source = 'newbook' AND sync_type = 'current_rates' + ORDER BY started_at DESC + LIMIT :limit + """), + {"limit": limit} + ) + rows = result.fetchall() + + return [ + { + "id": row.id, + "started_at": row.started_at, + "completed_at": row.completed_at, + "status": row.status, + "records_fetched": row.records_fetched, + "records_created": row.records_created, + "error_message": row.error_message, + "triggered_by": row.triggered_by + } + for row in rows + ] + + +class CurrentRatesSyncRequest(BaseModel): + horizon_days: Optional[int] = None # None = full 720-day run + + +@router.post("/current-rates/sync") +async def trigger_current_rates_sync( + background_tasks: BackgroundTasks, + request: CurrentRatesSyncRequest = CurrentRatesSyncRequest(), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Trigger a current rates sync from Newbook API. + Optional horizon_days param for manual range (default: 720 days). + """ + horizon_days = request.horizon_days or 720 + + # Auto-clear stuck syncs (running for more than 60 minutes) + await db.execute( + text(""" + UPDATE sync_log + SET status = 'failed', completed_at = NOW(), + error_message = 'Auto-cleared: sync stuck for more than 60 minutes' + WHERE source = 'newbook' AND sync_type = 'current_rates' + AND status = 'running' + AND started_at < NOW() - INTERVAL '60 minutes' + """) + ) + await db.commit() + + # Check if a sync is already running + result = await db.execute( + text(""" + SELECT id, started_at FROM sync_log + WHERE source = 'newbook' AND sync_type = 'current_rates' + AND status = 'running' + ORDER BY started_at DESC + LIMIT 1 + """) + ) + running = result.fetchone() + if running: + raise HTTPException( + status_code=409, + detail=f"A sync is already running (started at {running.started_at}). Please wait for it to complete." + ) + + # Queue background task + background_tasks.add_task( + run_current_rates_sync, + triggered_by=f"user:{current_user['username']}", + horizon_days=horizon_days + ) + + return { + "status": "started", + "message": f"Current rates sync started - fetching rates for next {horizon_days} days" + } + + +@router.post("/current-rates/config") +async def update_current_rates_sync_config( + config: CurrentRatesSyncConfig, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Update auto sync configuration for current rates data. + """ + # Upsert enabled setting + await db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by) + VALUES ('sync_newbook_current_rates_enabled', :value, 'Enable automatic Newbook current rates sync', NOW(), :user) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :value, + updated_at = NOW(), + updated_by = :user + """), + {"value": str(config.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_newbook_current_rates_time', :value, 'Newbook current rates 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", + "enabled": config.enabled, + "sync_time": config.sync_time + } + + +@router.post("/current-rates/cancel") +async def cancel_current_rates_sync( + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """ + Cancel/clear a stuck current rates sync. + Marks any running sync as failed so a new sync can be started. + """ + result = await db.execute( + text(""" + UPDATE sync_log + SET status = 'failed', + completed_at = NOW(), + error_message = 'Manually cancelled by user' + WHERE source = 'newbook' + AND sync_type = 'current_rates' + AND status = 'running' + RETURNING id, started_at + """) + ) + cancelled_rows = result.fetchall() + await db.commit() + + if cancelled_rows: + return { + "status": "success", + "message": f"Cancelled {len(cancelled_rows)} running sync(s)", + "cancelled_ids": [row.id for row in cancelled_rows] + } + else: + return { + "status": "success", + "message": "No running sync to cancel" + } + + +def run_current_rates_sync(triggered_by: str = "scheduler", horizon_days: int = 720): + """ + Background task to sync current rates from Newbook API. + """ + import sys + import asyncio + from jobs.fetch_current_rates import run_fetch_current_rates + from database import SyncSessionLocal + from sqlalchemy import text + + print(f"[SYNC-CURRENT-RATES] Starting sync ({horizon_days} days)", flush=True) + sys.stdout.flush() + + db = SyncSessionLocal() + log_id = None + + try: + # Create sync log entry + result = db.execute( + text(""" + INSERT INTO sync_log (source, sync_type, started_at, status, triggered_by) + VALUES ('newbook', 'current_rates', NOW(), 'running', :triggered_by) + RETURNING id + """), + {"triggered_by": triggered_by} + ) + log_id = result.fetchone().id + db.commit() + + # Run async sync + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(run_fetch_current_rates(horizon_days=horizon_days)) + print(f"[SYNC-CURRENT-RATES] Sync completed ({horizon_days} days)", flush=True) + + # Count records + result = db.execute(text("SELECT COUNT(*) as count FROM newbook_current_rates")) + total_records = result.fetchone().count + + # Update log as success + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'success', + records_fetched = :count, records_created = :count + WHERE id = :id + """), + {"id": log_id, "count": total_records} + ) + db.commit() + + except Exception as e: + print(f"[SYNC-CURRENT-RATES] FAILED: {e}", flush=True) + import traceback + traceback.print_exc() + + # Update log as failed + if log_id: + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE id = :id + """), + {"id": log_id, "error": str(e)[:500]} + ) + db.commit() + raise + finally: + loop.close() + + finally: + db.close() diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..eda098a --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,68 @@ +""" +Auth middleware — verifies the stack's hnf_session cookie using the shared +CENTRAL_AUTH_SECRET. Replaces the old per-app JWT/users system. +""" +import os +from fastapi import Depends, HTTPException, Request, status +from jose import JWTError, jwt + +CENTRAL_AUTH_SECRET = os.getenv("CENTRAL_AUTH_SECRET", "") +JWT_ALGORITHM = "HS256" +APP_SLUG = os.getenv("APP_SLUG", "forecasting") + + +async def get_current_user(request: Request) -> dict: + token = request.cookies.get("hnf_session") + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + try: + payload = jwt.decode(token, CENTRAL_AUTH_SECRET, algorithms=[JWT_ALGORITHM]) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") + + apps = payload.get("apps", []) + if APP_SLUG not in apps: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No permission for this app") + + prefix = f"{APP_SLUG}:" + raw_caps = payload.get("caps", []) + if isinstance(raw_caps, list): + caps = [c[len(prefix):] for c in raw_caps if c.startswith(prefix)] + else: + caps = [] + + is_admin = payload.get("is_admin", False) + + return { + "id": 0, + "username": payload.get("sub", ""), + "email": payload.get("sub", ""), + "display_name": payload.get("name", ""), + "name": payload.get("name", ""), + "is_admin": is_admin, + "caps": caps, + "role": "admin" if is_admin else "user", + } + + +def has_cap(user: dict, cap: str) -> bool: + return user.get("is_admin", False) or cap in user.get("caps", []) + + +def require_cap(cap: str): + async def checker(user: dict = Depends(get_current_user)): + if not has_cap(user, cap): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing capability: {cap}", + ) + return user + return checker + + +# Keep get_admin_user for routes that require admin +async def get_admin_user(user: dict = Depends(get_current_user)) -> dict: + if not user.get("is_admin", False): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") + return user diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..fdec24b --- /dev/null +++ b/backend/database.py @@ -0,0 +1,42 @@ +""" +Database connection and session management +""" +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker, declarative_base +from sqlalchemy import create_engine + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://forecast:forecast_secret@localhost:5432/forecast") + +# Convert to async URL +ASYNC_DATABASE_URL = DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://") + +# Async engine for FastAPI +async_engine = create_async_engine(ASYNC_DATABASE_URL, echo=False) +AsyncSessionLocal = sessionmaker( + async_engine, class_=AsyncSession, expire_on_commit=False +) + +# Sync engine for scheduler jobs and migrations +sync_engine = create_engine(DATABASE_URL) +SyncSessionLocal = sessionmaker(bind=sync_engine) + +Base = declarative_base() + + +async def get_db(): + """Dependency for FastAPI endpoints""" + async with AsyncSessionLocal() as session: + try: + yield session + finally: + await session.close() + + +def get_sync_db(): + """Get sync session for scheduler jobs""" + db = SyncSessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/jobs/__init__.py b/backend/jobs/__init__.py new file mode 100644 index 0000000..ac168c3 --- /dev/null +++ b/backend/jobs/__init__.py @@ -0,0 +1 @@ +# Scheduled jobs diff --git a/backend/jobs/accuracy_calc.py b/backend/jobs/accuracy_calc.py new file mode 100644 index 0000000..b93eba7 --- /dev/null +++ b/backend/jobs/accuracy_calc.py @@ -0,0 +1,182 @@ +""" +Accuracy calculation job +Compares forecasts to actuals once dates have passed +""" +import logging +from datetime import date, timedelta + +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + + +async def run_accuracy_calculation(): + """ + Calculate forecast accuracy for dates that have passed. + Updates actual_vs_forecast table with error metrics. + """ + logger.info("Starting accuracy calculation") + + db = next(iter([SyncSessionLocal()])) + + try: + # Process yesterday's actuals + calc_date = date.today() - timedelta(days=1) + + # Get all metrics + metrics_result = db.execute( + text(""" + SELECT metric_code FROM forecast_metrics WHERE is_active = TRUE + """) + ) + metrics = [row.metric_code for row in metrics_result.fetchall()] + + for metric_code in metrics: + # Get actual value from daily_metrics + actual_result = db.execute( + text(""" + SELECT actual_value FROM daily_metrics + WHERE date = :calc_date AND metric_code = :metric_code + """), + {"calc_date": calc_date, "metric_code": metric_code} + ) + actual_row = actual_result.fetchone() + actual_value = actual_row.actual_value if actual_row else None + + if actual_value is None: + continue # Skip if no actual available + + # Get forecasts for this date + forecast_result = db.execute( + text(""" + SELECT model_type, predicted_value, lower_bound, upper_bound + FROM forecasts + WHERE forecast_date = :calc_date AND forecast_type = :metric_code + """), + {"calc_date": calc_date, "metric_code": metric_code} + ) + forecasts = {row.model_type: row for row in forecast_result.fetchall()} + + prophet_forecast = forecasts.get('prophet') + xgboost_forecast = forecasts.get('xgboost') + pickup_forecast = forecasts.get('pickup') + catboost_forecast = forecasts.get('catboost') + + # Calculate errors + def calc_error(forecast_val): + if forecast_val is None: + return None, None + error = actual_value - forecast_val + pct_error = (error / actual_value * 100) if actual_value != 0 else None + return error, pct_error + + prophet_error, prophet_pct = calc_error( + prophet_forecast.predicted_value if prophet_forecast else None + ) + xgboost_error, xgboost_pct = calc_error( + xgboost_forecast.predicted_value if xgboost_forecast else None + ) + pickup_error, pickup_pct = calc_error( + pickup_forecast.predicted_value if pickup_forecast else None + ) + catboost_error, catboost_pct = calc_error( + catboost_forecast.predicted_value if catboost_forecast else None + ) + + # Determine best model + errors = [] + if prophet_error is not None: + errors.append(('prophet', abs(prophet_error))) + if xgboost_error is not None: + errors.append(('xgboost', abs(xgboost_error))) + if pickup_error is not None: + errors.append(('pickup', abs(pickup_error))) + if catboost_error is not None: + errors.append(('catboost', abs(catboost_error))) + + best_model = min(errors, key=lambda x: x[1])[0] if errors else None + + # Get budget value + budget_result = db.execute( + text(""" + SELECT budget_value FROM daily_budgets + WHERE date = :calc_date AND budget_type = :metric_code + """), + {"calc_date": calc_date, "metric_code": metric_code} + ) + budget_row = budget_result.fetchone() + budget_value = budget_row.budget_value if budget_row else None + + # Upsert accuracy record + db.execute( + text(""" + INSERT INTO actual_vs_forecast ( + date, metric_type, actual_value, + prophet_forecast, prophet_lower, prophet_upper, + xgboost_forecast, pickup_forecast, + catboost_forecast, + budget_value, + prophet_error, prophet_pct_error, + xgboost_error, xgboost_pct_error, + pickup_error, pickup_pct_error, + catboost_error, catboost_pct_error, + best_model, calculated_at + ) VALUES ( + :date, :metric_type, :actual, + :prophet_val, :prophet_lower, :prophet_upper, + :xgboost_val, :pickup_val, + :catboost_val, + :budget, + :prophet_error, :prophet_pct, + :xgboost_error, :xgboost_pct, + :pickup_error, :pickup_pct, + :catboost_error, :catboost_pct, + :best_model, NOW() + ) + ON CONFLICT (date, metric_type) DO UPDATE SET + actual_value = :actual, + prophet_error = :prophet_error, + prophet_pct_error = :prophet_pct, + xgboost_error = :xgboost_error, + xgboost_pct_error = :xgboost_pct, + pickup_error = :pickup_error, + pickup_pct_error = :pickup_pct, + catboost_forecast = :catboost_val, + catboost_error = :catboost_error, + catboost_pct_error = :catboost_pct, + best_model = :best_model, + calculated_at = NOW() + """), + { + "date": calc_date, + "metric_type": metric_code, + "actual": actual_value, + "prophet_val": prophet_forecast.predicted_value if prophet_forecast else None, + "prophet_lower": prophet_forecast.lower_bound if prophet_forecast else None, + "prophet_upper": prophet_forecast.upper_bound if prophet_forecast else None, + "xgboost_val": xgboost_forecast.predicted_value if xgboost_forecast else None, + "pickup_val": pickup_forecast.predicted_value if pickup_forecast else None, + "catboost_val": catboost_forecast.predicted_value if catboost_forecast else None, + "budget": budget_value, + "prophet_error": prophet_error, + "prophet_pct": prophet_pct, + "xgboost_error": xgboost_error, + "xgboost_pct": xgboost_pct, + "pickup_error": pickup_error, + "pickup_pct": pickup_pct, + "catboost_error": catboost_error, + "catboost_pct": catboost_pct, + "best_model": best_model + } + ) + + db.commit() + logger.info(f"Accuracy calculation completed for {calc_date}") + + except Exception as e: + logger.error(f"Accuracy calculation failed: {e}") + db.rollback() + raise + finally: + db.close() diff --git a/backend/jobs/aggregation.py b/backend/jobs/aggregation.py new file mode 100644 index 0000000..da71376 --- /dev/null +++ b/backend/jobs/aggregation.py @@ -0,0 +1,765 @@ +""" +Aggregation job - calculates daily summaries from raw booking data + +Processes dates from aggregation_queue and updates: +- daily_occupancy (from newbook_bookings) +- daily_covers (from resos_bookings) +""" +import json +import logging +from datetime import date, datetime +from typing import List, Optional + +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + + +async def run_aggregation(source: Optional[str] = None): + """ + Process pending aggregation queue and update daily summary tables. + + Args: + source: Optional filter - 'newbook' or 'resos'. If None, processes both. + """ + logger.info(f"Starting aggregation job (source={source or 'all'})") + + db = next(iter([SyncSessionLocal()])) + + try: + # Get pending dates from queue + query = """ + SELECT DISTINCT date, source + FROM aggregation_queue + WHERE aggregated_at IS NULL + """ + + if source: + query += " AND source = :source" + + query += " ORDER BY date" + + result = db.execute(text(query), {"source": source} if source else {}) + pending = result.fetchall() + + if not pending: + logger.info("No pending dates to aggregate") + return + + logger.info(f"Found {len(pending)} date/source combinations to aggregate") + + # Group by source + newbook_dates = [row.date for row in pending if row.source == 'newbook'] + resos_dates = [row.date for row in pending if row.source == 'resos'] + + # Process Newbook dates + if newbook_dates: + await aggregate_newbook_dates(db, newbook_dates) + + # Process Resos dates + if resos_dates: + await aggregate_resos_dates(db, resos_dates) + + # Populate daily_metrics from aggregated data (for forecasting models) + all_dates = list(set(newbook_dates + resos_dates)) + if all_dates: + await populate_daily_metrics(db, all_dates) + + logger.info("Aggregation completed successfully") + + except Exception as e: + logger.error(f"Aggregation failed: {e}") + raise + finally: + db.close() + + +async def aggregate_newbook_dates(db, dates: List[date]): + """ + Aggregate newbook_bookings into daily_occupancy for specified dates. + + Room availability is sourced from newbook_occupancy_report table (preferred) + which provides accurate available rooms accounting for maintenance/offline rooms. + Falls back to system config total_rooms if no occupancy report data exists. + + Revenue metrics: + - room_revenue, adr, revpar = NET values (after VAT) + - agr = Actual Guest Rate (gross rate guest paid, from calculated_amount) + """ + logger.info(f"Aggregating {len(dates)} Newbook dates") + + # Get fallback total_rooms from system config (used when no occupancy report data) + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'total_rooms'") + ) + row = result.fetchone() + fallback_total_rooms = int(row.config_value) if row and row.config_value else 80 + + # Get accommodation VAT rate from config (default 20%) + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ) + row = result.fetchone() + accommodation_vat = float(row.config_value) if row and row.config_value else 0.20 + + # Statuses that count as "occupied" (case-insensitive check in query) + # Includes: Confirmed, Unconfirmed, Arrived, Departed, In-House, etc. + # Excludes: Cancelled, No Show, Quote, Waitlist + excluded_statuses = "('cancelled', 'no show', 'no_show', 'quote', 'waitlist')" + + # Overflow room category (category_id=5) is used for chargeable no-shows/cancellations + # and should be excluded from room night counts + overflow_category_id = '5' + + for d in dates: + # Get room availability from daily_occupancy (pre-calculated by occupancy report sync) + # These values account for maintenance/offline rooms + # Revenue comes from booking data, not occupancy report + result = db.execute( + text(""" + SELECT + total_rooms, available_rooms, maintenance_rooms, + newbook_occupied, newbook_occupancy_pct + FROM daily_occupancy + WHERE date = :date + """), + {"date": d} + ) + existing = result.fetchone() + + # Use existing availability values if present, else fallback to config + if existing and existing.available_rooms and existing.available_rooms > 0: + total_rooms = existing.total_rooms + available_rooms = existing.available_rooms + maintenance_rooms = existing.maintenance_rooms or 0 + newbook_occupied = existing.newbook_occupied + newbook_occupancy_pct = float(existing.newbook_occupancy_pct or 0) + else: + # No occupancy data yet - fall back to config + total_rooms = fallback_total_rooms + available_rooms = fallback_total_rooms # Assume all rooms available + maintenance_rooms = 0 + newbook_occupied = None + newbook_occupancy_pct = None + + # Calculate occupancy stats for this date from booking data + # A booking is "in house" if: arrival_date <= date < departure_date + # AND status is not cancelled/no-show/quote/waitlist + # EXCLUDES overflow room category (used for chargeable no-shows) + result = db.execute( + text(f""" + SELECT + COUNT(*) as occupied_rooms, + COALESCE(SUM(total_guests), 0) as total_guests, + COALESCE(SUM(adults), 0) as total_adults, + COALESCE(SUM(children), 0) as total_children, + COALESCE(SUM(infants), 0) as total_infants + FROM newbook_bookings + WHERE arrival_date <= :date + AND departure_date > :date + AND LOWER(status) NOT IN {excluded_statuses} + AND (category_id IS NULL OR category_id != :overflow_cat) + """), + {"date": d, "overflow_cat": overflow_category_id} + ) + stats = result.fetchone() + + # Count arrivals for this date (active bookings only, excluding overflow) + result = db.execute( + text(f""" + SELECT COUNT(*) as arrival_count + FROM newbook_bookings + WHERE arrival_date = :date + AND LOWER(status) NOT IN {excluded_statuses} + AND (category_id IS NULL OR category_id != :overflow_cat) + """), + {"date": d, "overflow_cat": overflow_category_id} + ) + arrivals = result.fetchone() + + # Calculate room revenue, breakfast/dinner allocations from booking_nights + # charge_amount = room rate (net of inventory items, but includes VAT) + # calculated_amount = gross rate guest paid (for AGR) + # GL code matching is done during sync for meal allocations + # EXCLUDES overflow category (chargeable no-shows are not actual room stays) + result = db.execute( + text(f""" + SELECT + COALESCE(SUM(bn.charge_amount), 0) as charge_amount_total, + COALESCE(SUM(bn.calculated_amount), 0) as calculated_amount_total, + COALESCE(SUM(CASE WHEN bn.breakfast_gross > 0 THEN 1 ELSE 0 END), 0) as breakfast_qty, + COALESCE(SUM(bn.breakfast_net), 0) as breakfast_value, + COALESCE(SUM(CASE WHEN bn.dinner_gross > 0 THEN 1 ELSE 0 END), 0) as dinner_qty, + COALESCE(SUM(bn.dinner_net), 0) as dinner_value + FROM newbook_booking_nights bn + JOIN newbook_bookings b ON bn.booking_id = b.id + WHERE bn.stay_date = :date + AND LOWER(b.status) NOT IN {excluded_statuses} + AND (b.category_id IS NULL OR b.category_id != :overflow_cat) + """), + {"date": d, "overflow_cat": overflow_category_id} + ) + revenue_and_meals = result.fetchone() + + # Revenue breakdown by room category (for revenue_by_room_type JSON) + # EXCLUDES overflow category from breakdown + result = db.execute( + text(f""" + SELECT + COALESCE(b.category_id, 'unknown') as category_id, + COUNT(DISTINCT b.id) as rooms, + COALESCE(SUM(bn.charge_amount), 0) as charge_amount, + COALESCE(SUM(bn.calculated_amount), 0) as calculated_amount + FROM newbook_booking_nights bn + JOIN newbook_bookings b ON bn.booking_id = b.id + WHERE bn.stay_date = :date + AND LOWER(b.status) NOT IN {excluded_statuses} + AND (b.category_id IS NULL OR b.category_id != :overflow_cat) + GROUP BY b.category_id + """), + {"date": d, "overflow_cat": overflow_category_id} + ) + revenue_by_category_rows = result.fetchall() + + # Booking movement stats - count by status category + result = db.execute( + text(""" + SELECT + COUNT(*) FILTER (WHERE LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist')) as total_bookings, + COUNT(*) FILTER (WHERE LOWER(status) IN ('cancelled')) as cancelled_bookings, + COUNT(*) FILTER (WHERE LOWER(status) IN ('no show', 'no_show')) as no_show_bookings + FROM newbook_bookings + WHERE arrival_date <= :date + AND departure_date > :date + """), + {"date": d} + ) + movement = result.fetchone() + + # Breakdown by room category (keyed by category_id for stability) + # EXCLUDES overflow category + result = db.execute( + text(f""" + SELECT + COALESCE(category_id, 'unknown') as category_id, + COUNT(*) as rooms, + COALESCE(SUM(total_guests), 0) as guests, + COALESCE(SUM(adults), 0) as adults, + COALESCE(SUM(children), 0) as children, + COALESCE(SUM(infants), 0) as infants + FROM newbook_bookings + WHERE arrival_date <= :date + AND departure_date > :date + AND LOWER(status) NOT IN {excluded_statuses} + AND (category_id IS NULL OR category_id != :overflow_cat) + GROUP BY category_id + """), + {"date": d, "overflow_cat": overflow_category_id} + ) + room_type_rows = result.fetchall() + + # Keyed by category_id - use room_categories table to get names in UI + by_room_type = {} + for row in room_type_rows: + by_room_type[row.category_id] = { + "rooms": row.rooms, + "guests": row.guests, + "adults": row.adults, + "children": row.children, + "infants": row.infants + } + + occupied_rooms = stats.occupied_rooms or 0 + # Use available_rooms (accounts for maintenance) for accurate occupancy % + occupancy_pct = (occupied_rooms / available_rooms * 100) if available_rooms > 0 else 0 + + # Calculate revenue metrics + # charge_amount is the room rate (includes VAT), convert to NET + charge_amount_total = float(revenue_and_meals.charge_amount_total or 0) + room_revenue = charge_amount_total / (1 + accommodation_vat) # NET room revenue + + # ADR and RevPAR are NET values + # ADR uses occupied rooms, RevPAR uses available rooms + adr = (room_revenue / occupied_rooms) if occupied_rooms > 0 else 0 + revpar = (room_revenue / available_rooms) if available_rooms > 0 else 0 + + # AGR (Actual Guest Rate) = gross rate guest paid (from calculated_amount) + calculated_amount_total = float(revenue_and_meals.calculated_amount_total or 0) + agr = (calculated_amount_total / occupied_rooms) if occupied_rooms > 0 else 0 + + # Build revenue_by_room_type JSON with net revenue, ADR, AGR per category + revenue_by_room_type = {} + for row in revenue_by_category_rows: + cat_charge = float(row.charge_amount or 0) + cat_calculated = float(row.calculated_amount or 0) + cat_rooms = row.rooms or 0 + cat_revenue_net = cat_charge / (1 + accommodation_vat) + + revenue_by_room_type[row.category_id] = { + "rooms": cat_rooms, + "revenue_net": round(cat_revenue_net, 2), + "adr_net": round(cat_revenue_net / cat_rooms, 2) if cat_rooms > 0 else 0, + "agr_total": round(cat_calculated, 2), + "agr_avg": round(cat_calculated / cat_rooms, 2) if cat_rooms > 0 else 0 + } + + # Upsert into daily_occupancy + # Revenue comes from booking data (room_revenue, adr, revpar, agr) + db.execute( + text(""" + INSERT INTO daily_occupancy ( + date, total_rooms, available_rooms, maintenance_rooms, occupied_rooms, occupancy_pct, + newbook_occupied, newbook_occupancy_pct, + total_guests, total_adults, total_children, total_infants, + arrival_count, total_bookings, cancelled_bookings, no_show_bookings, + room_revenue, adr, revpar, agr, + breakfast_allocation_qty, breakfast_allocation_value, + dinner_allocation_qty, dinner_allocation_value, + by_room_type, revenue_by_room_type, fetched_at + ) VALUES ( + :date, :total_rooms, :available_rooms, :maintenance_rooms, :occupied_rooms, :occupancy_pct, + :newbook_occupied, :newbook_occupancy_pct, + :total_guests, :total_adults, :total_children, :total_infants, + :arrival_count, :total_bookings, :cancelled_bookings, :no_show_bookings, + :room_revenue, :adr, :revpar, :agr, + :breakfast_qty, :breakfast_value, + :dinner_qty, :dinner_value, + :by_room_type, :revenue_by_room_type, NOW() + ) + ON CONFLICT (date) DO UPDATE SET + total_rooms = :total_rooms, + available_rooms = :available_rooms, + maintenance_rooms = :maintenance_rooms, + occupied_rooms = :occupied_rooms, + occupancy_pct = :occupancy_pct, + newbook_occupied = :newbook_occupied, + newbook_occupancy_pct = :newbook_occupancy_pct, + total_guests = :total_guests, + total_adults = :total_adults, + total_children = :total_children, + total_infants = :total_infants, + arrival_count = :arrival_count, + total_bookings = :total_bookings, + cancelled_bookings = :cancelled_bookings, + no_show_bookings = :no_show_bookings, + room_revenue = :room_revenue, + adr = :adr, + revpar = :revpar, + agr = :agr, + breakfast_allocation_qty = :breakfast_qty, + breakfast_allocation_value = :breakfast_value, + dinner_allocation_qty = :dinner_qty, + dinner_allocation_value = :dinner_value, + by_room_type = :by_room_type, + revenue_by_room_type = :revenue_by_room_type, + fetched_at = NOW() + """), + { + "date": d, + "total_rooms": total_rooms, + "available_rooms": available_rooms, + "maintenance_rooms": maintenance_rooms, + "occupied_rooms": occupied_rooms, + "occupancy_pct": round(occupancy_pct, 2), + "newbook_occupied": newbook_occupied, + "newbook_occupancy_pct": round(newbook_occupancy_pct, 2) if newbook_occupancy_pct is not None else None, + "total_guests": stats.total_guests, + "total_adults": stats.total_adults, + "total_children": stats.total_children, + "total_infants": stats.total_infants, + "arrival_count": arrivals.arrival_count or 0, + "total_bookings": movement.total_bookings or 0, + "cancelled_bookings": movement.cancelled_bookings or 0, + "no_show_bookings": movement.no_show_bookings or 0, + "room_revenue": round(room_revenue, 2), + "adr": round(adr, 2), + "revpar": round(revpar, 2), + "agr": round(agr, 2), + "breakfast_qty": revenue_and_meals.breakfast_qty or 0, + "breakfast_value": revenue_and_meals.breakfast_value or 0, + "dinner_qty": revenue_and_meals.dinner_qty or 0, + "dinner_value": revenue_and_meals.dinner_value or 0, + "by_room_type": json.dumps(by_room_type), + "revenue_by_room_type": json.dumps(revenue_by_room_type) + } + ) + + # Mark queue entries as processed + db.execute( + text(""" + UPDATE aggregation_queue + SET aggregated_at = NOW() + WHERE date = :date AND source = 'newbook' AND aggregated_at IS NULL + """), + {"date": d} + ) + + db.commit() + logger.info(f"Aggregated {len(dates)} Newbook dates into daily_occupancy") + + +def load_opening_hours_mappings(db) -> dict: + """ + Load opening hours to period type mappings from resos_opening_hours_mapping table. + + Returns dict: {opening_hour_id: period_type} + Where period_type is one of: 'lunch', 'afternoon', 'dinner', 'ignore' + """ + result = db.execute(text(""" + SELECT opening_hour_id, period_type + FROM resos_opening_hours_mapping + WHERE period_type != 'ignore' + """)) + mappings = {} + for row in result.fetchall(): + mappings[row.opening_hour_id] = row.period_type + return mappings + + +async def aggregate_resos_dates(db, dates: List[date]): + """ + Aggregate resos_bookings into daily_covers for specified dates. + + Uses opening hours mapping to determine service periods (lunch, afternoon, dinner). + Falls back to time-based logic if no mappings configured. + """ + logger.info(f"Aggregating {len(dates)} Resos dates") + + # Load opening hours to period type mappings + oh_mappings = load_opening_hours_mappings(db) + use_oh_mapping = len(oh_mappings) > 0 + if use_oh_mapping: + logger.info(f"Using {len(oh_mappings)} opening hours mappings for period detection") + else: + logger.info("No opening hours mappings configured, using time-based period detection") + + # Status values that count as "active" (case-insensitive check in query) + # Excludes: Cancelled, No Show + excluded_statuses = "('cancelled', 'no show', 'no_show')" + + for d in dates: + # Get hotel occupancy data for this date (for dining rate calculation) + occ_result = db.execute( + text(""" + SELECT total_guests FROM daily_occupancy WHERE date = :date + """), + {"date": d} + ) + occ_row = occ_result.fetchone() + total_hotel_residents = occ_row.total_guests if occ_row and occ_row.total_guests else None + + # Calculate covers by service period + # If we have opening hours mappings, aggregate by mapped period_type + # Otherwise fall back to simple time-based logic + for period in ['lunch', 'afternoon', 'dinner']: + if use_oh_mapping: + # Get the opening_hour_ids that map to this period + period_oh_ids = [oh_id for oh_id, pt in oh_mappings.items() if pt == period] + + if not period_oh_ids: + # No mappings for this period, skip + continue + + # Build SQL placeholders for opening_hour_ids + oh_placeholders = ", ".join([f":oh_{i}" for i in range(len(period_oh_ids))]) + oh_params = {f"oh_{i}": oh_id for i, oh_id in enumerate(period_oh_ids)} + oh_params["date"] = d + + period_filter = f"opening_hour_id IN ({oh_placeholders})" + + # Get active booking stats + result = db.execute( + text(f""" + SELECT + COUNT(*) as total_bookings, + COALESCE(SUM(covers), 0) as total_covers, + COALESCE(SUM(CASE WHEN is_hotel_guest THEN covers ELSE 0 END), 0) as hotel_guest_covers, + COALESCE(SUM(CASE WHEN NOT is_hotel_guest OR is_hotel_guest IS NULL THEN covers ELSE 0 END), 0) as external_covers, + COALESCE(SUM(CASE WHEN is_dbb THEN covers ELSE 0 END), 0) as dbb_covers, + COALESCE(SUM(CASE WHEN is_package THEN covers ELSE 0 END), 0) as package_covers + FROM resos_bookings + WHERE booking_date = :date + AND {period_filter} + AND LOWER(status) NOT IN {excluded_statuses} + """), + oh_params + ) + stats = result.fetchone() + + # Get cancelled/no-show stats separately + result = db.execute( + text(f""" + SELECT + COUNT(*) FILTER (WHERE LOWER(status) = 'cancelled') as cancelled_bookings, + COALESCE(SUM(covers) FILTER (WHERE LOWER(status) = 'cancelled'), 0) as cancelled_covers, + COUNT(*) FILTER (WHERE LOWER(status) IN ('no show', 'no_show')) as no_show_bookings, + COALESCE(SUM(covers) FILTER (WHERE LOWER(status) IN ('no show', 'no_show')), 0) as no_show_covers + FROM resos_bookings + WHERE booking_date = :date + AND {period_filter} + """), + oh_params + ) + movement = result.fetchone() + + # Get source breakdown as JSON + result = db.execute( + text(f""" + SELECT + COALESCE(source, 'unknown') as source, + COUNT(*) as bookings, + COALESCE(SUM(covers), 0) as covers + FROM resos_bookings + WHERE booking_date = :date + AND {period_filter} + AND LOWER(status) NOT IN {excluded_statuses} + GROUP BY source + """), + oh_params + ) + source_rows = result.fetchall() + by_source = {row.source: {"bookings": row.bookings, "covers": row.covers} for row in source_rows} + + else: + # Fallback: time-based logic (skip afternoon if using fallback) + if period == 'afternoon': + continue + + if period == 'lunch': + time_filter = "booking_time < '15:00'" + else: # dinner + time_filter = "booking_time >= '15:00'" + + # Get active booking stats + result = db.execute( + text(f""" + SELECT + COUNT(*) as total_bookings, + COALESCE(SUM(covers), 0) as total_covers, + COALESCE(SUM(CASE WHEN is_hotel_guest THEN covers ELSE 0 END), 0) as hotel_guest_covers, + COALESCE(SUM(CASE WHEN NOT is_hotel_guest OR is_hotel_guest IS NULL THEN covers ELSE 0 END), 0) as external_covers, + COALESCE(SUM(CASE WHEN is_dbb THEN covers ELSE 0 END), 0) as dbb_covers, + COALESCE(SUM(CASE WHEN is_package THEN covers ELSE 0 END), 0) as package_covers + FROM resos_bookings + WHERE booking_date = :date + AND {time_filter} + AND LOWER(status) NOT IN {excluded_statuses} + """), + {"date": d} + ) + stats = result.fetchone() + + # Get cancelled/no-show stats separately + result = db.execute( + text(f""" + SELECT + COUNT(*) FILTER (WHERE LOWER(status) = 'cancelled') as cancelled_bookings, + COALESCE(SUM(covers) FILTER (WHERE LOWER(status) = 'cancelled'), 0) as cancelled_covers, + COUNT(*) FILTER (WHERE LOWER(status) IN ('no show', 'no_show')) as no_show_bookings, + COALESCE(SUM(covers) FILTER (WHERE LOWER(status) IN ('no show', 'no_show')), 0) as no_show_covers + FROM resos_bookings + WHERE booking_date = :date + AND {time_filter} + """), + {"date": d} + ) + movement = result.fetchone() + + # Get source breakdown as JSON + result = db.execute( + text(f""" + SELECT + COALESCE(source, 'unknown') as source, + COUNT(*) as bookings, + COALESCE(SUM(covers), 0) as covers + FROM resos_bookings + WHERE booking_date = :date + AND {time_filter} + AND LOWER(status) NOT IN {excluded_statuses} + GROUP BY source + """), + {"date": d} + ) + source_rows = result.fetchall() + by_source = {row.source: {"bookings": row.bookings, "covers": row.covers} for row in source_rows} + + total_bookings = stats.total_bookings or 0 + total_covers = stats.total_covers or 0 + avg_party_size = (total_covers / total_bookings) if total_bookings > 0 else 0 + hotel_guest_covers = stats.hotel_guest_covers or 0 + + # Calculate hotel guest dining rate (% of hotel residents who dined this period) + # This enables forecasting: forecast occupancy → apply dining rate → predict hotel guest covers + hotel_guest_dining_rate = None + if total_hotel_residents and total_hotel_residents > 0 and hotel_guest_covers > 0: + hotel_guest_dining_rate = round((hotel_guest_covers / total_hotel_residents) * 100, 2) + + # Upsert into daily_covers + db.execute( + text(""" + INSERT INTO daily_covers ( + date, service_period, total_bookings, total_covers, avg_party_size, + hotel_guest_covers, external_covers, dbb_covers, package_covers, + total_hotel_residents, hotel_guest_dining_rate, + cancelled_bookings, cancelled_covers, no_show_bookings, no_show_covers, + by_source, fetched_at + ) VALUES ( + :date, :service_period, :total_bookings, :total_covers, :avg_party_size, + :hotel_guest_covers, :external_covers, :dbb_covers, :package_covers, + :total_hotel_residents, :hotel_guest_dining_rate, + :cancelled_bookings, :cancelled_covers, :no_show_bookings, :no_show_covers, + :by_source, NOW() + ) + ON CONFLICT (date, service_period) DO UPDATE SET + total_bookings = :total_bookings, + total_covers = :total_covers, + avg_party_size = :avg_party_size, + hotel_guest_covers = :hotel_guest_covers, + external_covers = :external_covers, + dbb_covers = :dbb_covers, + package_covers = :package_covers, + total_hotel_residents = :total_hotel_residents, + hotel_guest_dining_rate = :hotel_guest_dining_rate, + cancelled_bookings = :cancelled_bookings, + cancelled_covers = :cancelled_covers, + no_show_bookings = :no_show_bookings, + no_show_covers = :no_show_covers, + by_source = :by_source, + fetched_at = NOW() + """), + { + "date": d, + "service_period": period, + "total_bookings": total_bookings, + "total_covers": total_covers, + "avg_party_size": round(avg_party_size, 2), + "hotel_guest_covers": hotel_guest_covers, + "external_covers": stats.external_covers or 0, + "dbb_covers": stats.dbb_covers or 0, + "package_covers": stats.package_covers or 0, + "total_hotel_residents": total_hotel_residents, + "hotel_guest_dining_rate": hotel_guest_dining_rate, + "cancelled_bookings": movement.cancelled_bookings or 0, + "cancelled_covers": movement.cancelled_covers or 0, + "no_show_bookings": movement.no_show_bookings or 0, + "no_show_covers": movement.no_show_covers or 0, + "by_source": json.dumps(by_source) + } + ) + + # Mark queue entries as processed + db.execute( + text(""" + UPDATE aggregation_queue + SET aggregated_at = NOW() + WHERE date = :date AND source = 'resos' AND aggregated_at IS NULL + """), + {"date": d} + ) + + db.commit() + logger.info(f"Aggregated {len(dates)} Resos dates into daily_covers") + + +async def populate_daily_metrics(db, dates: List[date]): + """ + Populate daily_metrics table from daily_occupancy and daily_covers. + This table is the source for forecasting models. + """ + logger.info(f"Populating daily_metrics for {len(dates)} dates") + + for d in dates: + # Get daily_occupancy data + result = db.execute( + text(""" + SELECT + occupied_rooms, total_guests, total_adults, total_children, + arrival_count, occupancy_pct, adr, revpar, + breakfast_allocation_qty, dinner_allocation_qty, + room_revenue, available_rooms + FROM daily_occupancy + WHERE date = :date + """), + {"date": d} + ) + occupancy = result.fetchone() + + # Get daily_covers data (lunch and dinner) + result = db.execute( + text(""" + SELECT + service_period, total_bookings, total_covers, avg_party_size + FROM daily_covers + WHERE date = :date + """), + {"date": d} + ) + covers_rows = result.fetchall() + + # Build covers data by period + covers_data = {} + for row in covers_rows: + covers_data[row.service_period] = { + "bookings": row.total_bookings, + "covers": row.total_covers, + "party_size": float(row.avg_party_size or 0) + } + + # Define metrics to populate + metrics_to_insert = [] + + if occupancy: + # Hotel metrics + metrics_to_insert.extend([ + ("hotel_room_nights", occupancy.occupied_rooms, "newbook"), + ("hotel_occupancy_pct", float(occupancy.occupancy_pct or 0), "newbook"), + ("hotel_guests", occupancy.total_guests, "newbook"), + ("hotel_arrivals", occupancy.arrival_count, "newbook"), + ("hotel_adr", float(occupancy.adr or 0), "newbook"), + ("hotel_revpar", float(occupancy.revpar or 0), "newbook"), + ("hotel_breakfast_qty", occupancy.breakfast_allocation_qty, "newbook"), + ("hotel_dinner_qty", occupancy.dinner_allocation_qty, "newbook"), + ("revenue_rooms", float(occupancy.room_revenue or 0), "newbook"), + ]) + + # Restaurant metrics - lunch + if "lunch" in covers_data: + lunch = covers_data["lunch"] + metrics_to_insert.extend([ + ("resos_lunch_bookings", lunch["bookings"], "resos"), + ("resos_lunch_covers", lunch["covers"], "resos"), + ("resos_lunch_party_size", lunch["party_size"], "resos"), + ]) + + # Restaurant metrics - dinner + if "dinner" in covers_data: + dinner = covers_data["dinner"] + metrics_to_insert.extend([ + ("resos_dinner_bookings", dinner["bookings"], "resos"), + ("resos_dinner_covers", dinner["covers"], "resos"), + ("resos_dinner_party_size", dinner["party_size"], "resos"), + ]) + + # Insert/update all metrics + for metric_code, actual_value, source in metrics_to_insert: + if actual_value is not None: + db.execute( + text(""" + INSERT INTO daily_metrics (date, metric_code, actual_value, source, calculated_at) + VALUES (:date, :metric_code, :actual_value, :source, NOW()) + ON CONFLICT (date, metric_code) DO UPDATE SET + actual_value = :actual_value, + source = :source, + calculated_at = NOW() + """), + { + "date": d, + "metric_code": metric_code, + "actual_value": actual_value, + "source": source + } + ) + + db.commit() + logger.info(f"Populated daily_metrics for {len(dates)} dates") diff --git a/backend/jobs/ai_insights.py b/backend/jobs/ai_insights.py new file mode 100644 index 0000000..0d1e7bf --- /dev/null +++ b/backend/jobs/ai_insights.py @@ -0,0 +1,456 @@ +""" +AI Daily Insights Generation Job + +Gathers Pickup-V2 forecast data, booking pace, competitor rates, and rate parity +information, then sends a compact prompt to Anthropic's Haiku model to generate +a daily briefing for hotel revenue staff. + +Schedule: Daily at 7:15 AM (after all forecasts and accuracy calc complete) +Cost: ~$0.05/month at 1 run/day with Haiku +""" +import json +import logging +from datetime import date, timedelta, datetime, timezone +from typing import Dict, List, Any, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from database import AsyncSessionLocal + +logger = logging.getLogger(__name__) + +DEFAULT_MODEL = "claude-haiku-4-5-20251001" +DEFAULT_DAILY_TOKEN_BUDGET = 5000 +MAX_OUTPUT_TOKENS = 400 + + +async def get_config(db: AsyncSession) -> Dict[str, str]: + """Get AI insights config from system_config.""" + result = await db.execute( + text(""" + SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted + FROM system_config + WHERE config_key LIKE 'ai_insights_%' + """) + ) + config = {} + for row in result.fetchall(): + value = row.config_value + if row.is_encrypted and value: + import base64 + try: + value = base64.b64decode(value.encode()).decode() + except Exception: + pass + config[row.config_key] = value + return config + + +async def check_daily_budget(db: AsyncSession, budget: int) -> tuple[bool, int]: + """Check if we're within the daily token budget. Returns (within_budget, tokens_used_today).""" + result = await db.execute( + text(""" + SELECT COALESCE(SUM(input_tokens + output_tokens), 0) as total + FROM ai_insights + WHERE generated_at >= CURRENT_DATE + """) + ) + row = result.fetchone() + used = int(row.total) if row else 0 + return used < budget, used + + +async def gather_occupancy_data(db: AsyncSession, days: int = 14) -> List[Dict]: + """Gather Pickup-V2 occupancy forecast data for the next N days.""" + from services.forecasting.pickup_v2_model import run_pickup_v2_forecast + + today = date.today() + end = today + timedelta(days=days - 1) + + try: + forecasts = await run_pickup_v2_forecast( + db, 'hotel_occupancy_pct', today, end, include_details=False + ) + return forecasts + except Exception as e: + logger.warning(f"Failed to gather occupancy data: {e}") + return [] + + +async def gather_revenue_data(db: AsyncSession, days: int = 14) -> List[Dict]: + """Gather Pickup-V2 revenue forecast data for the next N days.""" + from services.forecasting.pickup_v2_model import run_pickup_v2_forecast + + today = date.today() + end = today + timedelta(days=days - 1) + + try: + forecasts = await run_pickup_v2_forecast( + db, 'net_accom', today, end, include_details=False + ) + return forecasts + except Exception as e: + logger.warning(f"Failed to gather revenue data: {e}") + return [] + + +async def gather_budget_data(db: AsyncSession, days: int = 14) -> Dict[str, float]: + """Gather budget values for forecast comparison.""" + today = date.today() + end = today + timedelta(days=days - 1) + + result = await db.execute( + text(""" + SELECT date, budget_type, budget_value + FROM daily_budgets + WHERE date BETWEEN :start AND :end + AND budget_type IN ('net_accom', 'occupancy') + """), + {"start": today, "end": end} + ) + + budgets = {} + for row in result.fetchall(): + key = f"{row.date}_{row.budget_type}" + budgets[key] = float(row.budget_value) if row.budget_value else None + return budgets + + +async def gather_competitor_data(db: AsyncSession, days: int = 14) -> Dict[str, Any]: + """Gather competitor rate data from Booking.com scraper.""" + today = date.today() + end = today + timedelta(days=days - 1) + + # Own hotel rate on Booking.com + own_result = await db.execute( + text(""" + SELECT DISTINCT ON (r.rate_date) + r.rate_date, + r.rate_gross as booking_rate, + r.availability_status + FROM booking_com_rates r + JOIN booking_com_hotels h ON r.hotel_id = h.id + WHERE h.tier = 'own' + AND r.rate_date BETWEEN :start AND :end + ORDER BY r.rate_date, r.scraped_at DESC + """), + {"start": today, "end": end} + ) + own_rates = {str(row.rate_date): { + 'rate': float(row.booking_rate) if row.booking_rate else None, + 'status': row.availability_status + } for row in own_result.fetchall()} + + # Cheapest competitor rate per date + comp_result = await db.execute( + text(""" + SELECT DISTINCT ON (r.rate_date) + r.rate_date, + r.rate_gross as comp_rate, + h.name as comp_name + FROM booking_com_rates r + JOIN booking_com_hotels h ON r.hotel_id = h.id + WHERE h.tier IN ('primary', 'secondary') + AND r.rate_date BETWEEN :start AND :end + AND r.availability_status = 'available' + AND r.rate_gross IS NOT NULL + ORDER BY r.rate_date, r.rate_gross ASC + """), + {"start": today, "end": end} + ) + comp_rates = {str(row.rate_date): { + 'rate': float(row.comp_rate), + 'name': row.comp_name + } for row in comp_result.fetchall()} + + # Own rack rate from Newbook + rack_result = await db.execute( + text(""" + SELECT DISTINCT ON (rate_date) + rate_date, + rate_gross + FROM newbook_current_rates + WHERE rate_date BETWEEN :start AND :end + ORDER BY rate_date, valid_from DESC + """), + {"start": today, "end": end} + ) + rack_rates = {str(row.rate_date): float(row.rate_gross) if row.rate_gross else None + for row in rack_result.fetchall()} + + return { + 'own_booking': own_rates, + 'competitors': comp_rates, + 'rack': rack_rates + } + + +def build_prompt( + occupancy: List[Dict], + revenue: List[Dict], + budgets: Dict[str, float], + competitor: Dict[str, Any] +) -> tuple[str, str]: + """Build system and user prompts from gathered data. Returns (system_msg, user_msg).""" + + system_msg = ( + "You are an AI assistant for a hotel revenue manager. Analyze the data below and provide " + "a concise daily briefing (3-5 bullet points). Focus on: occupancy trends, pace vs prior " + "year, pricing opportunities, rate parity issues, and anything unusual requiring attention. " + "Be specific with numbers and dates. Keep it actionable — no fluff or generic advice." + ) + + lines = [] + + # Occupancy section + if occupancy: + lines.append("## Occupancy Forecast - Pickup-V2 (next 14 days)") + lines.append("Date | DoW | OTB | Forecast | PY Final | Pace vs LY | Budget") + for fc in occupancy: + d = fc.get('date', '') + dow = fc.get('day_of_week', '') + otb = fc.get('current_otb') + forecast = fc.get('forecast') + py_final = fc.get('prior_year_final') + pace = fc.get('pace_vs_prior_pct') + budget_key = f"{d}_occupancy" + budget_val = budgets.get(budget_key) + + otb_str = f"{otb:.0f}%" if otb is not None else "-" + fc_str = f"{forecast:.0f}%" if forecast is not None else "-" + py_str = f"{py_final:.0f}%" if py_final is not None else "-" + pace_str = f"{pace:+.0f}%" if pace is not None else "-" + bud_str = f"{budget_val:.0f}%" if budget_val is not None else "-" + + lines.append(f"{d} | {dow} | {otb_str} | {fc_str} | {py_str} | {pace_str} | {bud_str}") + lines.append("") + + # Revenue summary + if revenue: + total_forecast = sum(fc.get('forecast', 0) or 0 for fc in revenue) + total_otb = sum(fc.get('current_otb_rev', 0) or 0 for fc in revenue) + total_py = sum(fc.get('prior_year_final_rev', 0) or 0 for fc in revenue) + opportunity_days = sum(1 for fc in revenue if fc.get('has_pricing_opportunity')) + total_lost = sum(fc.get('lost_potential', 0) or 0 for fc in revenue) + + lines.append("## Revenue Signals") + lines.append(f"14-day forecast: ${total_forecast:,.0f} | OTB: ${total_otb:,.0f} | PY: ${total_py:,.0f}") + if opportunity_days > 0: + lines.append(f"Pricing opportunity days: {opportunity_days} | Total lost potential: ${total_lost:,.0f}") + lines.append("") + + # Competitor rates section + own_booking = competitor.get('own_booking', {}) + comp_rates = competitor.get('competitors', {}) + rack_rates = competitor.get('rack', {}) + + if own_booking or comp_rates: + lines.append("## Competitor Rates (next 14 days)") + lines.append("Date | Own Rack | Own B.com | Cheapest Competitor | Competitor Name") + + all_dates = sorted(set(list(own_booking.keys()) + list(comp_rates.keys()) + list(rack_rates.keys()))) + for d in all_dates: + rack = rack_rates.get(d) + own = own_booking.get(d, {}) + comp = comp_rates.get(d, {}) + + rack_str = f"${rack:.0f}" if rack else "-" + own_str = f"${own['rate']:.0f}" if own.get('rate') else "-" + comp_str = f"${comp['rate']:.0f}" if comp.get('rate') else "-" + comp_name = comp.get('name', '-') + + note = "" + if own.get('rate') and comp.get('rate') and own['rate'] < comp['rate']: + note = " <- cheapest on B.com" + + lines.append(f"{d} | {rack_str} | {own_str} | {comp_str} | {comp_name}{note}") + lines.append("") + + # Rate parity flags + parity_flags = [] + for d in sorted(rack_rates.keys()): + rack = rack_rates.get(d) + own = own_booking.get(d, {}) + if rack and own.get('rate') and rack > 0: + delta_pct = ((own['rate'] - rack) / rack) * 100 + if abs(delta_pct) > 5: + parity_flags.append(f"{d}: Rack ${rack:.0f} vs B.com ${own['rate']:.0f} ({delta_pct:+.1f}%)") + + if parity_flags: + lines.append("## Rate Parity Flags (own rack vs own Booking.com, >5% delta)") + for flag in parity_flags: + lines.append(flag) + lines.append("") + + user_msg = "\n".join(lines) + return system_msg, user_msg + + +async def call_llm(api_key: str, system_msg: str, user_msg: str, model: str) -> Dict[str, Any]: + """Call Anthropic API and return response with token usage.""" + import anthropic + + client = anthropic.AsyncAnthropic(api_key=api_key) + + try: + response = await client.messages.create( + model=model, + max_tokens=MAX_OUTPUT_TOKENS, + temperature=0.2, + system=system_msg, + messages=[{"role": "user", "content": user_msg}] + ) + + content = response.content[0].text if response.content else "" + return { + "content": content, + "input_tokens": response.usage.input_tokens, + "output_tokens": response.usage.output_tokens, + "model": model, + } + finally: + await client.close() + + +async def save_insight( + db: AsyncSession, + content: str, + model: str, + input_tokens: int, + output_tokens: int, + data_snapshot: Dict, + triggered_by: str = "scheduler" +): + """Save generated insight to database.""" + await db.execute( + text(""" + INSERT INTO ai_insights + (content, model, input_tokens, output_tokens, data_snapshot, triggered_by) + VALUES (:content, :model, :input_tokens, :output_tokens, + CAST(:data_snapshot AS jsonb), :triggered_by) + """), + { + "content": content, + "model": model, + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "data_snapshot": json.dumps(data_snapshot), + "triggered_by": triggered_by, + } + ) + await db.commit() + + +async def cleanup_old_insights(db: AsyncSession, keep_days: int = 90): + """Remove insights older than keep_days.""" + cutoff = datetime.now(timezone.utc) - timedelta(days=keep_days) + await db.execute( + text("DELETE FROM ai_insights WHERE generated_at < :cutoff"), + {"cutoff": cutoff} + ) + await db.commit() + + +async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") -> Dict[str, Any]: + """ + Core insight generation logic. Used by both scheduler and manual trigger. + Returns result dict with success/error status. + """ + config = await get_config(db) + + # Check enabled + if config.get('ai_insights_enabled', 'false').lower() not in ('true', '1', 'yes'): + return {"success": False, "error": "AI insights disabled"} + + # Check API key + api_key = config.get('ai_insights_api_key') + if not api_key: + return {"success": False, "error": "No API key configured"} + + model = config.get('ai_insights_model', DEFAULT_MODEL) + budget = int(config.get('ai_insights_daily_token_budget', str(DEFAULT_DAILY_TOKEN_BUDGET))) + + # Check daily budget + within_budget, used_today = await check_daily_budget(db, budget) + if not within_budget: + return { + "success": False, + "error": f"Daily token budget exceeded ({used_today}/{budget} tokens used today)" + } + + # Gather data + logger.info("Gathering data for AI insight...") + occupancy = await gather_occupancy_data(db) + revenue = await gather_revenue_data(db) + budgets = await gather_budget_data(db) + competitor = await gather_competitor_data(db) + + if not occupancy and not revenue: + return {"success": False, "error": "No forecast data available"} + + # Build prompt + system_msg, user_msg = build_prompt(occupancy, revenue, budgets, competitor) + + # Store data snapshot for debugging + data_snapshot = { + "occupancy_days": len(occupancy), + "revenue_days": len(revenue), + "competitor_dates": len(competitor.get('own_booking', {})), + "parity_flags": sum(1 for d in competitor.get('rack', {}) + if competitor.get('own_booking', {}).get(d, {}).get('rate') + and competitor['rack'].get(d) + and abs((competitor['own_booking'][d]['rate'] - competitor['rack'][d]) / competitor['rack'][d] * 100) > 5), + "prompt_preview": user_msg[:500], + } + + # Call LLM + logger.info(f"Calling {model} for AI insight...") + try: + result = await call_llm(api_key, system_msg, user_msg, model) + except Exception as e: + logger.error(f"LLM call failed: {e}") + return {"success": False, "error": f"LLM call failed: {str(e)}"} + + # Save + await save_insight( + db, + content=result["content"], + model=result["model"], + input_tokens=result["input_tokens"], + output_tokens=result["output_tokens"], + data_snapshot=data_snapshot, + triggered_by=triggered_by, + ) + + logger.info( + f"AI insight generated: {result['input_tokens']} input, " + f"{result['output_tokens']} output tokens ({triggered_by})" + ) + + # Cleanup old insights + try: + await cleanup_old_insights(db) + except Exception as e: + logger.warning(f"Old insight cleanup failed: {e}") + + return { + "success": True, + "content": result["content"], + "input_tokens": result["input_tokens"], + "output_tokens": result["output_tokens"], + "model": result["model"], + } + + +async def run_ai_insights_generation(): + """Scheduled job entry point.""" + async with AsyncSessionLocal() as db: + try: + result = await generate_insight(db, triggered_by="scheduler") + if result.get("success"): + logger.info("Scheduled AI insight generation completed") + else: + logger.info(f"Scheduled AI insight skipped: {result.get('error')}") + except Exception as e: + logger.error(f"Scheduled AI insight generation failed: {e}", exc_info=True) diff --git a/backend/jobs/batch_backtest.py b/backend/jobs/batch_backtest.py new file mode 100644 index 0000000..e1cccd2 --- /dev/null +++ b/backend/jobs/batch_backtest.py @@ -0,0 +1,1647 @@ +""" +Batch Backtest Job +Runs forecasts from multiple perception dates and stores results for accuracy analysis. +""" +import logging +from datetime import date, datetime, timedelta +from typing import List, Optional +import pandas as pd +import numpy as np +from xgboost import XGBRegressor +import warnings + +from sqlalchemy import text +from database import SyncSessionLocal + +from api.special_dates import resolve_special_date + +logger = logging.getLogger(__name__) + +warnings.filterwarnings('ignore') + + +def get_metric_query_info(metric: str) -> dict: + """ + Get SQL query information for each metric. + Returns dict with: + - column_expr: SQL expression for the metric value + - needs_revenue_join: whether to join with newbook_net_revenue_data + - is_pct_metric: whether it's a percentage metric (0-100) + - is_revenue_metric: whether it's a revenue/rate metric + """ + metric_info = { + 'occupancy': { + 'column_expr': 's.booking_count', + 'needs_revenue_join': False, + 'is_pct_metric': True, # Will be converted to percentage + 'is_revenue_metric': False, + }, + 'rooms': { + 'column_expr': 's.booking_count', + 'needs_revenue_join': False, + 'is_pct_metric': False, + 'is_revenue_metric': False, + }, + 'guests': { + 'column_expr': 's.guests_count', + 'needs_revenue_join': False, + 'is_pct_metric': False, + 'is_revenue_metric': False, + }, + 'ave_guest_rate': { + 'column_expr': 'CASE WHEN s.booking_count > 0 THEN s.guest_rate_total / s.booking_count ELSE NULL END', + 'needs_revenue_join': False, + 'is_pct_metric': False, + 'is_revenue_metric': True, + }, + 'arr': { + 'column_expr': 'CASE WHEN s.booking_count > 0 THEN r.accommodation / s.booking_count ELSE NULL END', + 'needs_revenue_join': True, + 'is_pct_metric': False, + 'is_revenue_metric': True, + }, + 'net_accom': { + 'column_expr': 'r.accommodation', + 'needs_revenue_join': True, + 'is_pct_metric': False, + 'is_revenue_metric': True, + }, + 'net_dry': { + 'column_expr': 'r.dry', + 'needs_revenue_join': True, + 'is_pct_metric': False, + 'is_revenue_metric': True, + }, + 'net_wet': { + 'column_expr': 'r.wet', + 'needs_revenue_join': True, + 'is_pct_metric': False, + 'is_revenue_metric': True, + }, + } + return metric_info.get(metric, metric_info['rooms']) + + +def get_mondays_in_range(start_date: date, end_date: date) -> List[date]: + """Get all Mondays between start and end date.""" + mondays = [] + current = start_date + # Move to first Monday + while current.weekday() != 0: + current += timedelta(days=1) + # Collect all Mondays + while current <= end_date: + mondays.append(current) + current += timedelta(days=7) + return mondays + + +def get_lead_time_column(days_out: int) -> str: + """Map days out to the appropriate booking_pace column.""" + if days_out <= 30: + return f"d{days_out}" + elif days_out <= 37: + return "d37" + elif days_out <= 44: + return "d44" + elif days_out <= 51: + return "d51" + elif days_out <= 58: + return "d58" + elif days_out <= 65: + return "d65" + elif days_out <= 72: + return "d72" + elif days_out <= 79: + return "d79" + elif days_out <= 86: + return "d86" + elif days_out <= 93: + return "d93" + elif days_out <= 100: + return "d100" + elif days_out <= 107: + return "d107" + elif days_out <= 114: + return "d114" + elif days_out <= 121: + return "d121" + elif days_out <= 128: + return "d128" + elif days_out <= 135: + return "d135" + elif days_out <= 142: + return "d142" + elif days_out <= 149: + return "d149" + elif days_out <= 156: + return "d156" + elif days_out <= 163: + return "d163" + elif days_out <= 170: + return "d170" + elif days_out <= 177: + return "d177" + elif days_out <= 210: + return "d210" + elif days_out <= 240: + return "d240" + elif days_out <= 270: + return "d270" + elif days_out <= 300: + return "d300" + elif days_out <= 330: + return "d330" + else: + return "d365" + + +async def run_batch_backtest( + start_perception: date, + end_perception: date, + forecast_days: int = 365, + metric: str = "occupancy", + models: Optional[List[str]] = None, + training_start: Optional[date] = None +) -> dict: + """ + Run backtests from multiple perception dates (every Monday in range). + + Args: + start_perception: First Monday to use as perception date + end_perception: Last Monday to use as perception date + forecast_days: How many days ahead to forecast from each perception date + metric: 'occupancy' or 'rooms' + models: List of models to run (default: ['xgboost']) + training_start: Optional cutoff date for training data (e.g., 2021-05-01 to exclude COVID) + Results stored with '_postcovid' suffix when set. + + Returns: + Summary of results + """ + if models is None: + models = ['xgboost'] + + perception_dates = get_mondays_in_range(start_perception, end_perception) + suffix = "_postcovid" if training_start else "" + logger.info(f"Running batch backtest for {len(perception_dates)} perception dates{f' (training from {training_start})' if training_start else ''}") + + db = SyncSessionLocal() + total_snapshots = 0 + errors = [] + + try: + for perception_date in perception_dates: + logger.info(f"Processing perception_date: {perception_date}") + + for model in models: + try: + # Model name with suffix for post-COVID training + model_name = f"{model}{suffix}" + + if model == 'xgboost': + count = await run_xgboost_backtest( + db, perception_date, forecast_days, metric, + training_start=training_start, model_name=model_name + ) + total_snapshots += count + elif model == 'prophet': + count = await run_prophet_backtest( + db, perception_date, forecast_days, metric, + training_start=training_start, model_name=model_name + ) + total_snapshots += count + elif model == 'pickup': + count = await run_pickup_backtest( + db, perception_date, forecast_days, metric, + training_start=training_start, model_name=model_name + ) + total_snapshots += count + elif model == 'pickup_avg': + count = await run_pickup_avg_backtest( + db, perception_date, forecast_days, metric, + training_start=training_start, model_name=model_name + ) + total_snapshots += count + elif model == 'catboost': + count = await run_catboost_backtest( + db, perception_date, forecast_days, metric, + training_start=training_start, model_name=model_name + ) + total_snapshots += count + elif model == 'blended': + count = await run_blended_backtest( + db, perception_date, forecast_days, metric, + training_start=training_start, model_name=model_name + ) + total_snapshots += count + else: + logger.warning(f"Unknown model: {model}") + except Exception as e: + error_msg = f"Error for {perception_date}/{model}: {str(e)}" + logger.error(error_msg) + errors.append(error_msg) + + db.commit() + + # Backfill actuals after all backtests complete + logger.info("Backfilling actual values...") + backfill_count = await backfill_actuals() + logger.info(f"Backfilled {backfill_count} actual values") + + return { + "perception_dates_processed": len(perception_dates), + "total_snapshots": total_snapshots, + "actuals_backfilled": backfill_count, + "errors": errors + } + + finally: + db.close() + + +async def run_xgboost_backtest( + db, + perception_date: date, + forecast_days: int, + metric: str, + training_start: Optional[date] = None, + model_name: str = "xgboost" +) -> int: + """ + Run XGBoost forecast from a specific perception date and store snapshots. + Returns count of snapshots stored. + + For occupancy/rooms: Uses pace data (OTB at different lead times) as features. + For other metrics: Uses time-series features only (like Prophet but with XGBoost). + + Args: + training_start: Optional cutoff date - only use training data from this date forward + model_name: Name to store in snapshots (e.g., 'xgboost' or 'xgboost_postcovid') + """ + today = perception_date + + # Check if this metric has pace data + pace_metrics = ['occupancy', 'rooms'] + use_pace = metric in pace_metrics + + # Get metric query info + metric_info = get_metric_query_info(metric) + column_expr = metric_info['column_expr'] + needs_revenue_join = metric_info['needs_revenue_join'] + is_pct_metric = metric_info['is_pct_metric'] + is_revenue_metric = metric_info['is_revenue_metric'] + + # Get bookable rooms + bookable_result = db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count IS NOT NULL AND date < :today + ORDER BY date DESC + LIMIT 1 + """), {"today": today}) + bookable_row = bookable_result.fetchone() + total_rooms = int(bookable_row.bookable_count) if bookable_row else 25 + + # Get historical data for training (2 years before perception date, or from training_start) + history_start = today - timedelta(days=730) + if training_start and training_start > history_start: + history_start = training_start + + # Build query based on metric type + if use_pace: + # Pace-based query for occupancy/rooms + history_result = db.execute(text(""" + SELECT s.date as ds, s.booking_count as final, + p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30 + FROM newbook_bookings_stats s + LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + elif needs_revenue_join: + # Revenue metrics + history_result = db.execute(text(f""" + SELECT s.date as ds, {column_expr} as final + FROM newbook_bookings_stats s + LEFT JOIN newbook_net_revenue_data r ON s.date = r.date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + else: + # Stats-based metrics (guests, ave_guest_rate) + history_result = db.execute(text(f""" + SELECT s.date as ds, {column_expr} as final + FROM newbook_bookings_stats s + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + logger.warning(f"Insufficient data for perception_date {perception_date}") + return 0 + + # Load special dates + special_date_set = set() + try: + special_dates_result = db.execute(text( + "SELECT * FROM special_dates WHERE is_active = TRUE" + )) + special_dates_rows = special_dates_result.fetchall() + years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1} + for row in special_dates_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 + } + for year in years_needed: + resolved_dates = resolve_special_date(sd, year) + for d in resolved_dates: + special_date_set.add(d) + except Exception: + pass + + # Build lookup dicts + final_by_date = {} + for row in history_rows: + if row.final is not None: + final_by_date[row.ds] = row.final + + if use_pace: + # Pace-based training for occupancy/rooms + train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30] + pace_by_date = {} + for row in history_rows: + pace_by_date[row.ds] = { + 0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7, + 14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30 + } + + # Build training examples with pace features + training_rows = [] + for row in history_rows: + ds = row.ds + if row.final is None: + continue + final = float(row.final) + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + continue + + for lead_time in train_lead_times: + current_otb = pace_by_date.get(ds, {}).get(lead_time) + if current_otb is None: + continue + + prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time) + if prior_otb is None: + prior_otb = 0 + + otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0 + + training_rows.append({ + 'ds': ds, + 'y': final, + 'days_out': lead_time, + 'current_otb': float(current_otb), + 'prior_otb_same_lead': float(prior_otb), + 'lag_364': float(prior_final), + 'otb_pct_of_prior_final': otb_pct_of_prior_final + }) + + if len(training_rows) < 30: + logger.warning(f"Insufficient training data for perception_date {perception_date}") + return 0 + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + # Convert to occupancy if needed + if metric == "occupancy" and total_rooms > 0: + df["y"] = (df["y"] / total_rooms) * 100 + df["current_otb"] = (df["current_otb"] / total_rooms) * 100 + df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / total_rooms) * 100 + df["lag_364"] = (df["lag_364"] / total_rooms) * 100 + + feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', + 'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final'] + else: + # Non-pace training for other metrics (time features + lag only) + training_rows = [] + for row in history_rows: + ds = row.ds + if row.final is None: + continue + final = float(row.final) + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + prior_final = final # Use same value as fallback + + training_rows.append({ + 'ds': ds, + 'y': final, + 'lag_364': float(prior_final), + }) + + if len(training_rows) < 30: + logger.warning(f"Insufficient training data for perception_date {perception_date}") + return 0 + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', 'lag_364'] + + # Create time features (common to both) + df['day_of_week'] = df['ds'].dt.dayofweek + df['month'] = df['ds'].dt.month + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['day_of_week'] >= 5).astype(int) + df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0) + + df_train = df.dropna() + + if len(df_train) < 30: + return 0 + + X_train = df_train[feature_cols] + y_train = df_train['y'] + + # Determine training cap for capping predictions + if is_pct_metric: + training_cap = 100 + elif metric == 'rooms': + training_cap = total_rooms + elif metric == 'guests': + training_cap = df_train["y"].max() * 1.5 if len(df_train) > 0 and df_train["y"].max() > 0 else total_rooms * 3 + else: + # Revenue/rate metrics - use 99th percentile * 1.5 + training_cap = df_train["y"].quantile(0.99) * 1.5 if len(df_train) > 0 and df_train["y"].quantile(0.99) > 0 else 10000 + + # Train model + xgb_model = XGBRegressor( + n_estimators=100, + max_depth=6, + learning_rate=0.1, + objective='reg:squarederror', + random_state=42, + n_jobs=-1 + ) + xgb_model.fit(X_train, y_train) + + # Generate forecasts + snapshots_stored = 0 + end_date = today + timedelta(days=forecast_days) + + current_date = today + while current_date <= end_date: + lead_days = (current_date - today).days + prior_year_date = current_date - timedelta(days=364) + + if use_pace: + # Pace-based prediction for occupancy/rooms + lead_col = get_lead_time_column(lead_days) + + # Get current OTB from booking_pace at that lead time + otb_result = db.execute(text(f""" + SELECT {lead_col} as current_otb + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """), {"arrival_date": current_date}) + otb_row = otb_result.fetchone() + + # Get prior year OTB at same lead + prior_otb_result = db.execute(text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """), {"prior_date": prior_year_date}) + prior_otb_row = prior_otb_result.fetchone() + + # Get prior year final + prior_final_result = db.execute(text(""" + SELECT booking_count as prior_final + FROM newbook_bookings_stats + WHERE date = :prior_date + """), {"prior_date": prior_year_date}) + prior_final_row = prior_final_result.fetchone() + + current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else 0 + prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else 0 + + # Convert to occupancy + if metric == "occupancy" and total_rooms > 0: + current_otb = (current_otb / total_rooms) * 100 + prior_otb = (prior_otb / total_rooms) * 100 + prior_final = (prior_final / total_rooms) * 100 + + lag_364_val = prior_final if prior_final else 0 + otb_pct_of_prior_final = (current_otb / lag_364_val * 100) if lag_364_val > 0 else 0 + + # Build features + forecast_dt = pd.Timestamp(current_date) + features = pd.DataFrame([{ + 'day_of_week': forecast_dt.dayofweek, + 'month': forecast_dt.month, + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if current_date in special_date_set else 0, + 'days_out': lead_days, + 'current_otb': current_otb, + 'prior_otb_same_lead': prior_otb, + 'lag_364': lag_364_val, + 'otb_pct_of_prior_final': otb_pct_of_prior_final, + }]) + else: + # Non-pace prediction for other metrics + # Get prior year value for lag feature + lag_364_val = final_by_date.get(prior_year_date) + if lag_364_val is None: + lag_364_val = float(df_train["y"].mean()) + else: + lag_364_val = float(lag_364_val) + + forecast_dt = pd.Timestamp(current_date) + features = pd.DataFrame([{ + 'day_of_week': int(forecast_dt.dayofweek), + 'month': int(forecast_dt.month), + 'week_of_year': int(forecast_dt.isocalendar().week), + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if current_date in special_date_set else 0, + 'lag_364': lag_364_val, + }]) + + # Predict + yhat = float(xgb_model.predict(features)[0]) + + # Cap based on metric type + if is_pct_metric: + yhat = min(max(yhat, 0), 100.0) + elif metric == 'rooms': + yhat = round(min(max(yhat, 0), float(total_rooms))) + elif metric == 'guests': + yhat = round(max(yhat, 0)) + else: + # Revenue/rate metrics + yhat = round(max(yhat, 0), 2) + + # Store snapshot + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": current_date, + "model_name": model_name, + "metric": metric, + "days_out": lead_days, + "forecast_value": round(yhat, 2) + }) + + snapshots_stored += 1 + current_date += timedelta(days=1) + + return snapshots_stored + + +async def run_pickup_backtest( + db, + perception_date: date, + forecast_days: int, + metric: str, + training_start: Optional[date] = None, + model_name: str = "pickup" +) -> int: + """ + Run Pickup (additive) forecast from a specific perception date. + Uses: current_otb + (prior_final - prior_otb) = forecast + + Args: + training_start: Not used by pickup model (no training), but accepted for API consistency + model_name: Name to store in snapshots (e.g., 'pickup' or 'pickup_postcovid') + + Returns count of snapshots stored. + """ + today = perception_date + + # Get bookable rooms + bookable_result = db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count IS NOT NULL AND date < :today + ORDER BY date DESC + LIMIT 1 + """), {"today": today}) + bookable_row = bookable_result.fetchone() + total_rooms = int(bookable_row.bookable_count) if bookable_row else 25 + + snapshots_stored = 0 + end_date = today + timedelta(days=forecast_days) + + current_date = today + while current_date <= end_date: + lead_days = (current_date - today).days + lead_col = get_lead_time_column(lead_days) + prior_year_date = current_date - timedelta(days=364) + + # Get current OTB from booking_pace + otb_result = db.execute(text(f""" + SELECT {lead_col} as current_otb + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """), {"arrival_date": current_date}) + otb_row = otb_result.fetchone() + + # Get prior year OTB at same lead + prior_otb_result = db.execute(text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """), {"prior_date": prior_year_date}) + prior_otb_row = prior_otb_result.fetchone() + + # Get prior year final + prior_final_result = db.execute(text(""" + SELECT booking_count as prior_final + FROM newbook_bookings_stats + WHERE date = :prior_date + """), {"prior_date": prior_year_date}) + prior_final_row = prior_final_result.fetchone() + + current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else 0 + prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else 0 + + # Convert to occupancy if needed + if metric == "occupancy" and total_rooms > 0: + current_otb = (current_otb / total_rooms) * 100 + prior_otb = (prior_otb / total_rooms) * 100 + prior_final = (prior_final / total_rooms) * 100 + + # Additive pickup: forecast = current_otb + (prior_final - prior_otb) + if prior_final > 0 and prior_otb is not None: + prior_pickup = prior_final - prior_otb + yhat = current_otb + prior_pickup + else: + yhat = current_otb + + # Floor to current OTB + yhat = max(yhat, current_otb) + + # Cap at max + if metric == "occupancy": + yhat = min(max(yhat, 0), 100.0) + else: + yhat = round(min(max(yhat, 0), float(total_rooms))) + + # Store snapshot + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": current_date, + "model_name": model_name, + "metric": metric, + "days_out": lead_days, + "forecast_value": round(yhat, 2) + }) + + snapshots_stored += 1 + current_date += timedelta(days=1) + + return snapshots_stored + + +def get_same_dow_prior_year(target_date: date, years_back: int) -> date: + """ + Get the date from N years ago that matches the same day of week. + Uses ISO week number and weekday to properly align across leap years. + + Example: Monday Jan 6, 2025 -> Monday Jan 8, 2024 (same ISO week, same DOW) + """ + iso_cal = target_date.isocalendar() + target_week = iso_cal.week + target_dow = iso_cal.weekday # 1=Monday, 7=Sunday + + prior_year = target_date.year - years_back + + # Find the first day of the target ISO week in the prior year + # ISO week 1 is the week containing Jan 4th + jan4 = date(prior_year, 1, 4) + jan4_iso = jan4.isocalendar() + + # Calculate days from Jan 4 to the start of week 1 + days_to_week1_start = (jan4_iso.weekday - 1) # Days from Monday of week 1 to Jan 4 + week1_monday = jan4 - timedelta(days=days_to_week1_start) + + # Now find the Monday of the target week + target_week_monday = week1_monday + timedelta(weeks=target_week - 1) + + # Add days to get to the correct day of week + prior_date = target_week_monday + timedelta(days=target_dow - 1) + + return prior_date + + +async def run_pickup_avg_backtest( + db, + perception_date: date, + forecast_days: int, + metric: str, + training_start: Optional[date] = None, + model_name: str = "pickup_avg" +) -> int: + """ + Run Pickup (weighted 2-year average) forecast from a specific perception date. + Uses weighted average pickup from last 2 years: + current_otb + (0.7 * year1_pickup + 0.3 * year2_pickup) + + Weights: 70% year 1, 30% year 2 + This focuses on recent years and excludes COVID-affected periods. + + Also calculates confidence bounds using min/max pickup across the 2 years. + + Uses ISO week matching to ensure same day-of-week alignment across years, + properly handling leap years. + + Args: + training_start: Not used by pickup model (no training), but accepted for API consistency + model_name: Name to store in snapshots (e.g., 'pickup_avg') + + Returns count of snapshots stored. + """ + today = perception_date + + # Get bookable rooms + bookable_result = db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count IS NOT NULL AND date < :today + ORDER BY date DESC + LIMIT 1 + """), {"today": today}) + bookable_row = bookable_result.fetchone() + total_rooms = int(bookable_row.bookable_count) if bookable_row else 25 + + snapshots_stored = 0 + end_date = today + timedelta(days=forecast_days) + + current_date = today + while current_date <= end_date: + lead_days = (current_date - today).days + lead_col = get_lead_time_column(lead_days) + + # Get current OTB from booking_pace + otb_result = db.execute(text(f""" + SELECT {lead_col} as current_otb + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """), {"arrival_date": current_date}) + otb_row = otb_result.fetchone() + current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0 + + # Convert current OTB to occupancy if needed + if metric == "occupancy" and total_rooms > 0: + current_otb = (current_otb / total_rooms) * 100 + + # Collect pickup values from 2 years with weights (using proper DOW alignment) + # Weights: 70% year 1, 30% year 2 + year_weights = {1: 0.7, 2: 0.3} + pickups = [] # List of (pickup_value, weight) tuples + pickup_values = [] # Just values for min/max + + for years_back in [1, 2]: + prior_date = get_same_dow_prior_year(current_date, years_back) + + # Get prior year OTB at same lead + prior_otb_result = db.execute(text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """), {"prior_date": prior_date}) + prior_otb_row = prior_otb_result.fetchone() + + # Get prior year final + prior_final_result = db.execute(text(""" + SELECT booking_count as prior_final + FROM newbook_bookings_stats + WHERE date = :prior_date + """), {"prior_date": prior_date}) + prior_final_row = prior_final_result.fetchone() + + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else None + prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else None + + if prior_final is not None and prior_otb is not None: + # Convert to occupancy if needed + if metric == "occupancy" and total_rooms > 0: + prior_otb = (prior_otb / total_rooms) * 100 + prior_final = (prior_final / total_rooms) * 100 + + pickup = prior_final - prior_otb + pickups.append((pickup, year_weights[years_back])) + pickup_values.append(pickup) + + # Calculate weighted average, min, max pickup + if pickups: + # Normalize weights if not all years have data + total_weight = sum(w for _, w in pickups) + avg_pickup = sum(p * w for p, w in pickups) / total_weight + min_pickup = min(pickup_values) + max_pickup = max(pickup_values) + else: + avg_pickup = 0 + min_pickup = 0 + max_pickup = 0 + + # Forecast using average pickup + yhat = current_otb + avg_pickup + yhat_lower = current_otb + min_pickup + yhat_upper = current_otb + max_pickup + + # Floor to current OTB + yhat = max(yhat, current_otb) + yhat_lower = max(yhat_lower, current_otb) + yhat_upper = max(yhat_upper, current_otb) + + # Cap at max + if metric == "occupancy": + yhat = min(max(yhat, 0), 100.0) + yhat_lower = min(max(yhat_lower, 0), 100.0) + yhat_upper = min(max(yhat_upper, 0), 100.0) + else: + yhat = round(min(max(yhat, 0), float(total_rooms))) + yhat_lower = round(min(max(yhat_lower, 0), float(total_rooms))) + yhat_upper = round(min(max(yhat_upper, 0), float(total_rooms))) + + # Store snapshot (main forecast) + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": current_date, + "model_name": model_name, + "metric": metric, + "days_out": lead_days, + "forecast_value": round(yhat, 2) + }) + + # Store lower bound + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": current_date, + "model_name": f"{model_name}_lower", + "metric": metric, + "days_out": lead_days, + "forecast_value": round(yhat_lower, 2) + }) + + # Store upper bound + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": current_date, + "model_name": f"{model_name}_upper", + "metric": metric, + "days_out": lead_days, + "forecast_value": round(yhat_upper, 2) + }) + + snapshots_stored += 1 + current_date += timedelta(days=1) + + return snapshots_stored + + +async def run_prophet_backtest( + db, + perception_date: date, + forecast_days: int, + metric: str, + training_start: Optional[date] = None, + model_name: str = "prophet" +) -> int: + """ + Run Prophet forecast from a specific perception date. + Uses Facebook Prophet for time series forecasting. + + Args: + training_start: Optional cutoff date - only use training data from this date forward + model_name: Name to store in snapshots (e.g., 'prophet' or 'prophet_postcovid') + + Returns count of snapshots stored. + """ + from prophet import Prophet + + today = perception_date + + # Get metric query info + metric_info = get_metric_query_info(metric) + column_expr = metric_info['column_expr'] + needs_revenue_join = metric_info['needs_revenue_join'] + is_pct_metric = metric_info['is_pct_metric'] + is_revenue_metric = metric_info['is_revenue_metric'] + + # Get bookable rooms + bookable_result = db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count IS NOT NULL AND date < :today + ORDER BY date DESC + LIMIT 1 + """), {"today": today}) + bookable_row = bookable_result.fetchone() + total_rooms = int(bookable_row.bookable_count) if bookable_row else 25 + + # Get historical data (2 years before perception date, or from training_start) + history_start = today - timedelta(days=730) + if training_start and training_start > history_start: + history_start = training_start + + # Build query based on metric type + if needs_revenue_join: + query = f""" + SELECT s.date as ds, {column_expr} as y + FROM newbook_bookings_stats s + LEFT JOIN newbook_net_revenue_data r ON s.date = r.date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """ + else: + query = f""" + SELECT s.date as ds, {column_expr} as y + FROM newbook_bookings_stats s + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """ + + history_result = db.execute(text(query), {"history_start": history_start, "today": today}) + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + logger.warning(f"Insufficient data for Prophet at perception_date {perception_date}") + return 0 + + # Build training dataframe, filtering out NULL values + df = pd.DataFrame([{"ds": row.ds, "y": float(row.y)} for row in history_rows if row.y is not None]) + df['ds'] = pd.to_datetime(df['ds']) + + if len(df) < 30: + logger.warning(f"Insufficient non-null data for Prophet at perception_date {perception_date}") + return 0 + + # Convert to occupancy percentage if needed + if metric == "occupancy" and total_rooms > 0: + df["y"] = (df["y"] / total_rooms) * 100 + + # Determine cap for logistic growth based on metric type + if is_pct_metric: + training_cap = 100 + elif metric == 'rooms': + training_cap = total_rooms + elif metric == 'guests': + training_cap = df["y"].max() * 1.5 if len(df) > 0 and df["y"].max() > 0 else total_rooms * 3 + else: + # Revenue/rate metrics - use 99th percentile * 1.5 + training_cap = df["y"].quantile(0.99) * 1.5 if len(df) > 0 and df["y"].quantile(0.99) > 0 else 10000 + + df["floor"] = 0 + df["cap"] = training_cap + + # Train Prophet model with logistic growth + model = Prophet( + growth='logistic', + yearly_seasonality=True, + weekly_seasonality=True, + daily_seasonality=False, + seasonality_mode='multiplicative' + ) + model.fit(df) + + # Create future dataframe + future = model.make_future_dataframe(periods=forecast_days) + future = future[future['ds'] >= pd.Timestamp(today)] + future["floor"] = 0 + future["cap"] = training_cap + + # Predict + forecast = model.predict(future) + + # Store snapshots + snapshots_stored = 0 + for _, row in forecast.iterrows(): + target_date = row['ds'].date() + lead_days = (target_date - today).days + yhat = float(row['yhat']) + + # Cap based on metric type + if is_pct_metric: + yhat = min(max(yhat, 0), 100.0) + elif metric == 'rooms': + yhat = round(min(max(yhat, 0), float(total_rooms))) + elif metric == 'guests': + yhat = round(max(yhat, 0)) # No upper cap for guests, just floor at 0 + else: + # Revenue/rate metrics - round to 2 decimal places + yhat = round(max(yhat, 0), 2) + + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": target_date, + "model_name": model_name, + "metric": metric, + "days_out": lead_days, + "forecast_value": round(yhat, 2) + }) + + snapshots_stored += 1 + + return snapshots_stored + + +async def run_catboost_backtest( + db, + perception_date: date, + forecast_days: int, + metric: str, + training_start: Optional[date] = None, + model_name: str = "catboost" +) -> int: + """ + Run CatBoost forecast from a specific perception date and store snapshots. + + For occupancy/rooms: Uses pace data (OTB at different lead times) as features. + For other metrics: Uses time-series features only. + + Args: + training_start: Optional cutoff date - only use training data from this date forward + model_name: Name to store in snapshots (e.g., 'catboost' or 'catboost_postcovid') + + Returns count of snapshots stored. + """ + from catboost import CatBoostRegressor + + today = perception_date + + # Check if this metric has pace data + pace_metrics = ['occupancy', 'rooms'] + use_pace = metric in pace_metrics + + # Get metric query info + metric_info = get_metric_query_info(metric) + column_expr = metric_info['column_expr'] + needs_revenue_join = metric_info['needs_revenue_join'] + is_pct_metric = metric_info['is_pct_metric'] + is_revenue_metric = metric_info['is_revenue_metric'] + + # Get bookable rooms + bookable_result = db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count IS NOT NULL AND date < :today + ORDER BY date DESC + LIMIT 1 + """), {"today": today}) + bookable_row = bookable_result.fetchone() + total_rooms = int(bookable_row.bookable_count) if bookable_row else 25 + + # Get historical data for training (2 years before perception date, or from training_start) + history_start = today - timedelta(days=730) + if training_start and training_start > history_start: + history_start = training_start + + # Build query based on metric type + if use_pace: + history_result = db.execute(text(""" + SELECT s.date as ds, s.booking_count as final, + p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30 + FROM newbook_bookings_stats s + LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + elif needs_revenue_join: + history_result = db.execute(text(f""" + SELECT s.date as ds, {column_expr} as final + FROM newbook_bookings_stats s + LEFT JOIN newbook_net_revenue_data r ON s.date = r.date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + else: + history_result = db.execute(text(f""" + SELECT s.date as ds, {column_expr} as final + FROM newbook_bookings_stats s + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + logger.warning(f"Insufficient data for CatBoost at perception_date {perception_date}") + return 0 + + # Load special dates + special_date_set = set() + try: + special_dates_result = db.execute(text( + "SELECT * FROM special_dates WHERE is_active = TRUE" + )) + special_dates_rows = special_dates_result.fetchall() + years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1} + for row in special_dates_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 + } + for year in years_needed: + resolved_dates = resolve_special_date(sd, year) + for d in resolved_dates: + special_date_set.add(d) + except Exception: + pass + + # Build lookup dicts + final_by_date = {} + for row in history_rows: + if row.final is not None: + final_by_date[row.ds] = row.final + + if use_pace: + # Pace-based training + train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30] + pace_by_date = {} + for row in history_rows: + pace_by_date[row.ds] = { + 0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7, + 14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30 + } + + training_rows = [] + for row in history_rows: + ds = row.ds + if row.final is None: + continue + final = float(row.final) + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + continue + + for lead_time in train_lead_times: + current_otb = pace_by_date.get(ds, {}).get(lead_time) + if current_otb is None: + continue + + prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time) + if prior_otb is None: + prior_otb = 0 + + otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0 + + training_rows.append({ + 'ds': ds, + 'y': final, + 'days_out': lead_time, + 'current_otb': float(current_otb), + 'prior_otb_same_lead': float(prior_otb), + 'lag_364': float(prior_final), + 'otb_pct_of_prior_final': otb_pct_of_prior_final + }) + + if len(training_rows) < 30: + logger.warning(f"Insufficient training data for CatBoost at perception_date {perception_date}") + return 0 + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + # Convert to occupancy if needed + if metric == "occupancy" and total_rooms > 0: + df["y"] = (df["y"] / total_rooms) * 100 + df["current_otb"] = (df["current_otb"] / total_rooms) * 100 + df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / total_rooms) * 100 + df["lag_364"] = (df["lag_364"] / total_rooms) * 100 + + categorical_features = ['day_of_week', 'month'] + numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', + 'days_out', 'current_otb', 'prior_otb_same_lead', + 'lag_364', 'otb_pct_of_prior_final'] + feature_cols = categorical_features + numerical_features + else: + # Non-pace training for other metrics + training_rows = [] + for row in history_rows: + ds = row.ds + if row.final is None: + continue + final = float(row.final) + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + prior_final = final + + training_rows.append({ + 'ds': ds, + 'y': final, + 'lag_364': float(prior_final), + }) + + if len(training_rows) < 30: + logger.warning(f"Insufficient training data for CatBoost at perception_date {perception_date}") + return 0 + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + categorical_features = ['day_of_week', 'month'] + numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', 'lag_364'] + feature_cols = categorical_features + numerical_features + + # Create time features - CatBoost uses categorical features natively + df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical for CatBoost + df['month'] = df['ds'].dt.month.astype(str) # Categorical for CatBoost + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int) + df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0) + + df_train = df.dropna() + + if len(df_train) < 30: + return 0 + + # Determine training cap for predictions + if is_pct_metric: + training_cap = 100 + elif metric == 'rooms': + training_cap = total_rooms + elif metric == 'guests': + training_cap = df_train["y"].max() * 1.5 if len(df_train) > 0 and df_train["y"].max() > 0 else total_rooms * 3 + else: + training_cap = df_train["y"].quantile(0.99) * 1.5 if len(df_train) > 0 and df_train["y"].quantile(0.99) > 0 else 10000 + + X_train = df_train[feature_cols] + y_train = df_train['y'] + + # Train CatBoost model + cat_model = CatBoostRegressor( + iterations=200, + depth=6, + learning_rate=0.1, + loss_function='RMSE', + cat_features=categorical_features, + verbose=False, + random_seed=42 + ) + cat_model.fit(X_train, y_train) + + # Generate forecasts + snapshots_stored = 0 + end_date = today + timedelta(days=forecast_days) + + current_date = today + while current_date <= end_date: + lead_days = (current_date - today).days + prior_year_date = current_date - timedelta(days=364) + + if use_pace: + lead_col = get_lead_time_column(lead_days) + + # Get current OTB from booking_pace at that lead time + otb_result = db.execute(text(f""" + SELECT {lead_col} as current_otb + FROM newbook_booking_pace + WHERE arrival_date = :arrival_date + """), {"arrival_date": current_date}) + otb_row = otb_result.fetchone() + + # Get prior year OTB at same lead + prior_otb_result = db.execute(text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """), {"prior_date": prior_year_date}) + prior_otb_row = prior_otb_result.fetchone() + + # Get prior year final + prior_final_result = db.execute(text(""" + SELECT booking_count as prior_final + FROM newbook_bookings_stats + WHERE date = :prior_date + """), {"prior_date": prior_year_date}) + prior_final_row = prior_final_result.fetchone() + + current_otb = otb_row.current_otb if otb_row and otb_row.current_otb else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb else 0 + prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final else 0 + + # Convert to occupancy + if metric == "occupancy" and total_rooms > 0: + current_otb = (current_otb / total_rooms) * 100 + prior_otb = (prior_otb / total_rooms) * 100 + prior_final = (prior_final / total_rooms) * 100 + + lag_364_val = prior_final if prior_final else 0 + otb_pct_of_prior_final = (current_otb / lag_364_val * 100) if lag_364_val > 0 else 0 + + # Build features + forecast_dt = pd.Timestamp(current_date) + features = pd.DataFrame([{ + 'day_of_week': str(forecast_dt.dayofweek), + 'month': str(forecast_dt.month), + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if current_date in special_date_set else 0, + 'days_out': lead_days, + 'current_otb': current_otb, + 'prior_otb_same_lead': prior_otb, + 'lag_364': lag_364_val, + 'otb_pct_of_prior_final': otb_pct_of_prior_final, + }]) + else: + # Non-pace prediction + lag_364_val = final_by_date.get(prior_year_date) + if lag_364_val is None: + lag_364_val = float(df_train["y"].mean()) + else: + lag_364_val = float(lag_364_val) + + forecast_dt = pd.Timestamp(current_date) + features = pd.DataFrame([{ + 'day_of_week': str(forecast_dt.dayofweek), + 'month': str(forecast_dt.month), + 'week_of_year': int(forecast_dt.isocalendar().week), + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if current_date in special_date_set else 0, + 'lag_364': lag_364_val, + }]) + + # Predict + yhat = float(cat_model.predict(features)[0]) + + # Cap based on metric type + if is_pct_metric: + yhat = min(max(yhat, 0), 100.0) + elif metric == 'rooms': + yhat = round(min(max(yhat, 0), float(total_rooms))) + elif metric == 'guests': + yhat = round(max(yhat, 0)) + else: + yhat = round(max(yhat, 0), 2) + + # Store snapshot + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": current_date, + "model_name": model_name, + "metric": metric, + "days_out": lead_days, + "forecast_value": round(yhat, 2) + }) + + snapshots_stored += 1 + current_date += timedelta(days=1) + + return snapshots_stored + + +async def run_blended_backtest( + db, + perception_date: date, + forecast_days: int, + metric: str, + training_start: Optional[date] = None, + model_name: str = "blended" +) -> int: + """ + Create blended forecast by averaging Prophet, XGBoost, and CatBoost predictions. + + This function reads existing forecasts from forecast_snapshots and creates + a blended average. Run this AFTER running the individual models. + + Args: + training_start: Used to determine if we should look for _postcovid model variants + model_name: Name to store in snapshots (e.g., 'blended' or 'blended_postcovid') + + Returns count of snapshots stored. + """ + suffix = "_postcovid" if training_start else "" + models_to_blend = [f"prophet{suffix}", f"xgboost{suffix}", f"catboost{suffix}"] + + # Get all forecasts from the three models for this perception date and metric + result = db.execute(text(""" + SELECT target_date, model, forecast_value + FROM forecast_snapshots + WHERE perception_date = :perception_date + AND metric_code = :metric + AND model IN :models + ORDER BY target_date + """), { + "perception_date": perception_date, + "metric": metric, + "models": tuple(models_to_blend) + }) + rows = result.fetchall() + + if not rows: + logger.warning(f"No model forecasts found for blending at {perception_date}/{metric}") + return 0 + + # Group by target_date + forecasts_by_date = {} + for row in rows: + if row.target_date not in forecasts_by_date: + forecasts_by_date[row.target_date] = {} + forecasts_by_date[row.target_date][row.model] = float(row.forecast_value) + + # Calculate blended average for each target date + snapshots_stored = 0 + for target_date, model_forecasts in forecasts_by_date.items(): + # Only blend if we have at least 2 models + if len(model_forecasts) < 2: + continue + + # Calculate average + values = list(model_forecasts.values()) + blended_value = sum(values) / len(values) + + days_out = (target_date - perception_date).days + + # Store blended snapshot + db.execute(text(""" + INSERT INTO forecast_snapshots + (perception_date, target_date, model, metric_code, days_out, forecast_value) + VALUES + (:perception_date, :target_date, :model_name, :metric, :days_out, :forecast_value) + ON CONFLICT (perception_date, target_date, model, metric_code) + DO UPDATE SET forecast_value = :forecast_value, created_at = NOW() + """), { + "perception_date": perception_date, + "target_date": target_date, + "model_name": model_name, + "metric": metric, + "days_out": days_out, + "forecast_value": round(blended_value, 2) + }) + + snapshots_stored += 1 + + return snapshots_stored + + +async def backfill_actuals(): + """ + Backfill actual_value in forecast_snapshots from newbook_bookings_stats and newbook_net_revenue_data. + Run this after target_date has passed. + + Handles all metrics: + - occupancy: booking_count / bookable_count * 100 + - rooms: booking_count + - guests: guests_count + - ave_guest_rate: guest_rate_total / booking_count + - arr: accommodation / booking_count (from revenue data) + - net_accom, net_dry, net_wet: from revenue data + """ + db = SyncSessionLocal() + + try: + # First, update stats-based metrics (occupancy, rooms, guests, ave_guest_rate) + result1 = db.execute(text(""" + UPDATE forecast_snapshots fs + SET actual_value = CASE + WHEN fs.metric_code = 'occupancy' THEN + (s.booking_count::decimal / NULLIF(s.bookable_count, 0)) * 100 + WHEN fs.metric_code = 'rooms' THEN + s.booking_count + WHEN fs.metric_code = 'guests' THEN + s.guests_count + WHEN fs.metric_code = 'ave_guest_rate' THEN + s.guest_rate_total / NULLIF(s.booking_count, 0) + ELSE NULL + END + FROM newbook_bookings_stats s + WHERE fs.target_date = s.date + AND fs.actual_value IS NULL + AND fs.target_date < CURRENT_DATE + AND fs.metric_code IN ('occupancy', 'rooms', 'guests', 'ave_guest_rate') + AND s.booking_count IS NOT NULL + """)) + + # Then, update revenue-based metrics (arr, net_accom, net_dry, net_wet) + result2 = db.execute(text(""" + UPDATE forecast_snapshots fs + SET actual_value = CASE + WHEN fs.metric_code = 'arr' THEN + r.accommodation / NULLIF(s.booking_count, 0) + WHEN fs.metric_code = 'net_accom' THEN + r.accommodation + WHEN fs.metric_code = 'net_dry' THEN + r.dry + WHEN fs.metric_code = 'net_wet' THEN + r.wet + ELSE NULL + END + FROM newbook_net_revenue_data r + JOIN newbook_bookings_stats s ON r.date = s.date + WHERE fs.target_date = r.date + AND fs.actual_value IS NULL + AND fs.target_date < CURRENT_DATE + AND fs.metric_code IN ('arr', 'net_accom', 'net_dry', 'net_wet') + """)) + + db.commit() + count = result1.rowcount + result2.rowcount + logger.info(f"Backfilled {count} actual values") + return count + + finally: + db.close() diff --git a/backend/jobs/bookings_aggregation.py b/backend/jobs/bookings_aggregation.py new file mode 100644 index 0000000..c689151 --- /dev/null +++ b/backend/jobs/bookings_aggregation.py @@ -0,0 +1,847 @@ +""" +Bookings aggregation job - aggregates newbook_bookings_data into: +- newbook_bookings_stats: daily aggregated stats with JSONB category breakdowns +- newbook_booking_pace: lead-time snapshots for forecasting pickup patterns + +Triggered automatically after bookings sync completes. +""" +import json +import logging +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import List, Set, Dict, Any, Optional + +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + +# Valid booking statuses for aggregation +VALID_STATUSES = ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + +# All tracked pace intervals +PACE_INTERVALS = [ + # Monthly (months 7-12) + 365, 330, 300, 270, 240, 210, + # Weekly (weeks 5-25) + 177, 170, 163, 156, 149, 142, 135, 128, 121, 114, + 107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37, + # Daily (days 0-30) + 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 +] + + +def get_config_value(db, key: str) -> Optional[str]: + """Get a configuration value from system_config table.""" + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + return row.config_value if row else None + + +async def run_bookings_aggregation(triggered_by: str = "manual"): + """ + Aggregate bookings into newbook_bookings_stats. + + Flow: + 1. Find bookings changed since last_bookings_aggregation_at + 2. Calculate affected dates (arrival_date <= date < departure_date) + 3. Reaggregate each affected date + 4. Update booking pace table + 5. Update last_bookings_aggregation_at + """ + logger.info(f"Starting bookings aggregation (triggered_by={triggered_by})") + + db = next(iter([SyncSessionLocal()])) + + try: + # Get last aggregation timestamp + last_aggregation = get_config_value(db, 'last_bookings_aggregation_at') + if last_aggregation: + try: + last_ts = datetime.fromisoformat(last_aggregation) + except ValueError: + last_ts = datetime.min + else: + last_ts = datetime.min + + logger.info(f"Last aggregation: {last_ts}") + + # Find bookings changed since last aggregation + result = db.execute( + text(""" + SELECT newbook_id, arrival_date, departure_date + FROM newbook_bookings_data + WHERE fetched_at > :last_ts + """), + {"last_ts": last_ts} + ) + changed_bookings = result.fetchall() + + if not changed_bookings: + logger.info("No changed bookings to aggregate") + # Still update pace table + await update_booking_pace(db) + db.commit() + return + + logger.info(f"Found {len(changed_bookings)} changed bookings") + + # Calculate affected dates + affected_dates: Set[date] = set() + for booking in changed_bookings: + if booking.arrival_date and booking.departure_date: + current = booking.arrival_date + # < not <= (departure is checkout day, guest not staying that night) + while current < booking.departure_date: + affected_dates.add(current) + current += timedelta(days=1) + + logger.info(f"Reaggregating {len(affected_dates)} affected dates") + + # Get accommodation VAT rate + vat_rate_str = get_config_value(db, 'accommodation_vat_rate') + vat_rate = Decimal(vat_rate_str) if vat_rate_str else Decimal('0.20') + + # Aggregate each affected date + for target_date in sorted(affected_dates): + await aggregate_date(db, target_date, vat_rate) + + # Fill any dates with occupancy data but no bookings (e.g., closed periods) + await fill_occupancy_only_dates(db, vat_rate) + + # Update booking pace table + await update_booking_pace(db) + + # Update last aggregation timestamp + db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, updated_at) + VALUES ('last_bookings_aggregation_at', :now, NOW()) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :now, + updated_at = NOW() + """), + {"now": datetime.now().isoformat()} + ) + + db.commit() + logger.info(f"Bookings aggregation completed: {len(affected_dates)} dates processed") + + except Exception as e: + logger.error(f"Bookings aggregation failed: {e}") + db.rollback() + raise + finally: + db.close() + + +async def aggregate_date(db, target_date: date, vat_rate: Decimal): + """ + Aggregate all bookings for a specific date into newbook_bookings_stats. + + Includes room availability from newbook_occupancy_report_data and + booking stats from newbook_bookings_data. + """ + # Step 1: Get room availability from occupancy report (included categories only) + result = db.execute( + text(""" + SELECT + o.category_id, + COALESCE(o.available, 0) as available, + COALESCE(o.maintenance, 0) as maintenance + FROM newbook_occupancy_report_data o + JOIN newbook_room_categories c ON o.category_id = c.site_id + WHERE o.date = :target_date + AND c.is_included = true + """), + {"target_date": target_date} + ) + occupancy_rows = result.fetchall() + + # Build availability by category + availability_by_category: Dict[str, Dict[str, Any]] = {} + rooms_count = 0 + maintenance_count = 0 + + for row in occupancy_rows: + cat_id = row.category_id + available = row.available or 0 + maintenance = row.maintenance or 0 + bookable = available - maintenance + + rooms_count += available + maintenance_count += maintenance + + availability_by_category[cat_id] = { + "rooms_count": available, + "maintenance_count": maintenance, + "bookable_count": bookable, + "booking_count": 0, + "total_occupancy_pct": None, + "bookable_occupancy_pct": None + } + + bookable_count = rooms_count - maintenance_count + + # Fallback: If no occupancy data (bookable_count=0), use last known bookable_count + # This prevents division-by-zero issues in forecast models when occupancy report is missing + if bookable_count <= 0: + fallback_result = db.execute( + text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count > 5 AND date < :target_date + ORDER BY date DESC + LIMIT 1 + """), + {"target_date": target_date} + ) + fallback_row = fallback_result.fetchone() + if fallback_row and fallback_row.bookable_count: + bookable_count = fallback_row.bookable_count + rooms_count = bookable_count # Assume same for rooms_count + logger.info(f"Using fallback bookable_count={bookable_count} for {target_date}") + + # Step 2: Get booking stats (bookings staying this night) + # A booking is "in house" if: arrival_date <= date < departure_date + # Only counts bookings for categories marked as is_included=true in settings + result = db.execute( + text(""" + SELECT + b.newbook_id, + b.category_id, + COALESCE(b.adults, 0) + COALESCE(b.children, 0) + COALESCE(b.infants, 0) as guests, + COALESCE(b.adults, 0) as adults, + COALESCE(b.children, 0) as children, + COALESCE(b.infants, 0) as infants, + b.raw_json + FROM newbook_bookings_data b + JOIN newbook_room_categories c ON b.category_id = c.site_id + WHERE b.arrival_date <= :target_date + AND b.departure_date > :target_date + AND b.status IN :valid_statuses + AND c.is_included = true + """), + {"target_date": target_date, "valid_statuses": VALID_STATUSES} + ) + bookings = result.fetchall() + + # Aggregate bookings + booking_count = 0 + guests_count = 0 + adults_count = 0 + children_count = 0 + infants_count = 0 + guest_rate_total = Decimal('0') + net_booking_rev_total = Decimal('0') + + occupancy_by_category: Dict[str, Dict[str, Any]] = {} + revenue_by_category: Dict[str, Dict[str, Any]] = {} + rate_stats_by_category: Dict[str, Dict[str, Any]] = {} # Pickup-V2: min/max/adr per category + + for booking in bookings: + booking_count += 1 + guests_count += booking.guests or 0 + adults_count += booking.adults or 0 + children_count += booking.children or 0 + infants_count += booking.infants or 0 + + cat_id = booking.category_id or 'unknown' + + # Initialize category dicts if needed + if cat_id not in occupancy_by_category: + occupancy_by_category[cat_id] = { + "booking_count": 0, + "guests": 0, + "adults": 0, + "children": 0, + "infants": 0 + } + if cat_id not in revenue_by_category: + revenue_by_category[cat_id] = { + "guest_rate_total": Decimal('0'), + "net_booking_rev_total": Decimal('0') + } + if cat_id not in rate_stats_by_category: + rate_stats_by_category[cat_id] = { + "rates": [], # Collect all net rates for min/max/adr calculation + "rooms": 0 + } + + # Update occupancy by category + occupancy_by_category[cat_id]["booking_count"] += 1 + occupancy_by_category[cat_id]["guests"] += booking.guests or 0 + occupancy_by_category[cat_id]["adults"] += booking.adults or 0 + occupancy_by_category[cat_id]["children"] += booking.children or 0 + occupancy_by_category[cat_id]["infants"] += booking.infants or 0 + + # Update availability by category booking count + if cat_id in availability_by_category: + availability_by_category[cat_id]["booking_count"] += 1 + + # Get revenue from tariffs_quoted for this date + calculated_amount, net_amount = get_rate_for_date( + booking.raw_json, target_date, vat_rate + ) + guest_rate_total += calculated_amount + net_booking_rev_total += net_amount + + revenue_by_category[cat_id]["guest_rate_total"] += calculated_amount + revenue_by_category[cat_id]["net_booking_rev_total"] += net_amount + + # Pickup-V2: Collect net rate for rate stats (only if rate > 0) + if net_amount > 0: + rate_stats_by_category[cat_id]["rates"].append(float(net_amount)) + rate_stats_by_category[cat_id]["rooms"] += 1 + + # Calculate occupancy percentages + total_occupancy_pct = None + bookable_occupancy_pct = None + + if rooms_count > 0: + total_occupancy_pct = round(float(booking_count) / rooms_count * 100, 2) + if bookable_count > 0: + bookable_occupancy_pct = round(float(booking_count) / bookable_count * 100, 2) + + # Calculate per-category occupancy percentages + for cat_id, avail in availability_by_category.items(): + cat_bookings = avail["booking_count"] + cat_rooms = avail["rooms_count"] + cat_bookable = avail["bookable_count"] + + if cat_rooms > 0: + avail["total_occupancy_pct"] = round(float(cat_bookings) / cat_rooms * 100, 2) + if cat_bookable > 0: + avail["bookable_occupancy_pct"] = round(float(cat_bookings) / cat_bookable * 100, 2) + + # Convert Decimal to float for JSON serialization + def decimal_to_float(d: Dict) -> Dict: + return { + k: (float(v) if isinstance(v, Decimal) else v) + for k, v in d.items() + } + + # Pickup-V2: Calculate min/max/adr from collected rates + rate_stats_final: Dict[str, Dict[str, Any]] = {} + for cat_id, stats in rate_stats_by_category.items(): + rates = stats["rates"] + if rates: + rate_stats_final[cat_id] = { + "min_net": round(min(rates), 2), + "max_net": round(max(rates), 2), + "adr_net": round(sum(rates) / len(rates), 2), + "rooms": stats["rooms"] + } + + occupancy_json = json.dumps({ + k: decimal_to_float(v) for k, v in occupancy_by_category.items() + }) + revenue_json = json.dumps({ + k: decimal_to_float(v) for k, v in revenue_by_category.items() + }) + availability_json = json.dumps(availability_by_category) + rate_stats_json = json.dumps(rate_stats_final) + + # Upsert into newbook_bookings_stats + db.execute( + text(""" + INSERT INTO newbook_bookings_stats ( + date, rooms_count, maintenance_count, bookable_count, + booking_count, guests_count, adults_count, children_count, infants_count, + total_occupancy_pct, bookable_occupancy_pct, + guest_rate_total, net_booking_rev_total, + occupancy_by_category, revenue_by_category, availability_by_category, + rate_stats_by_category, + aggregated_at + ) VALUES ( + :date, :rooms_count, :maintenance_count, :bookable_count, + :booking_count, :guests_count, :adults_count, :children_count, :infants_count, + :total_occupancy_pct, :bookable_occupancy_pct, + :guest_rate_total, :net_booking_rev_total, + :occupancy_by_category, :revenue_by_category, :availability_by_category, + :rate_stats_by_category, + NOW() + ) + ON CONFLICT (date) DO UPDATE SET + rooms_count = :rooms_count, + maintenance_count = :maintenance_count, + bookable_count = :bookable_count, + booking_count = :booking_count, + guests_count = :guests_count, + adults_count = :adults_count, + children_count = :children_count, + infants_count = :infants_count, + total_occupancy_pct = :total_occupancy_pct, + bookable_occupancy_pct = :bookable_occupancy_pct, + guest_rate_total = :guest_rate_total, + net_booking_rev_total = :net_booking_rev_total, + occupancy_by_category = :occupancy_by_category, + revenue_by_category = :revenue_by_category, + availability_by_category = :availability_by_category, + rate_stats_by_category = :rate_stats_by_category, + aggregated_at = NOW() + """), + { + "date": target_date, + "rooms_count": rooms_count, + "maintenance_count": maintenance_count, + "bookable_count": bookable_count, + "booking_count": booking_count, + "guests_count": guests_count, + "adults_count": adults_count, + "children_count": children_count, + "infants_count": infants_count, + "total_occupancy_pct": total_occupancy_pct, + "bookable_occupancy_pct": bookable_occupancy_pct, + "guest_rate_total": float(guest_rate_total), + "net_booking_rev_total": float(net_booking_rev_total), + "occupancy_by_category": occupancy_json, + "revenue_by_category": revenue_json, + "availability_by_category": availability_json, + "rate_stats_by_category": rate_stats_json + } + ) + + +def get_rate_for_date(raw_json: dict, target_date: date, vat_rate: Decimal) -> tuple: + """ + Extract rate from tariffs_quoted for specific stay_date. + + Returns tuple of (calculated_amount, net_amount). + calculated_amount = gross rate guest paid (for AGR) + net_amount = amount after VAT deduction + """ + if not raw_json: + return Decimal('0'), Decimal('0') + + tariffs = raw_json.get("tariffs_quoted", []) + target_str = target_date.strftime("%Y-%m-%d") + + for tariff in tariffs: + if tariff.get("stay_date") == target_str: + calculated_amount = Decimal(str(tariff.get("calculated_amount", 0) or 0)) + charge_amount = Decimal(str(tariff.get("charge_amount", 0) or 0)) + + # Try to get net from taxes array if available + taxes = tariff.get("taxes", []) + if taxes and charge_amount > 0: + tax_amount = sum(Decimal(str(t.get("tax_amount", 0) or 0)) for t in taxes) + net_amount = charge_amount - tax_amount + else: + # Fallback: calculate net using VAT rate + net_amount = charge_amount / (1 + vat_rate) + + return calculated_amount, net_amount + + return Decimal('0'), Decimal('0') + + +async def update_booking_pace(db): + """ + Update booking pace table with current snapshots. + + For each tracked interval, snapshot the current OCCUPANCY count for that stay_date. + Occupancy = arrivals + stayovers (guests already checked in from earlier dates). + + This counts bookings where: arrival_date <= stay_date < departure_date + Also ensures all dates in the forecast window have rows (prevents gaps when job misses a day). + """ + logger.info("Updating booking pace snapshots (occupancy-based)") + + today = date.today() + updates = 0 + + # Step 1: Update tracked interval columns + for interval in PACE_INTERVALS: + stay_date = today + timedelta(days=interval) + + # Count OCCUPANCY for this stay_date (arrivals + stayovers) + # A booking occupies a date if: arrival_date <= stay_date < departure_date + result = db.execute( + text(""" + SELECT COUNT(*) as count + FROM newbook_bookings_data b + JOIN newbook_room_categories c ON b.category_id = c.site_id + WHERE b.arrival_date <= :stay_date + AND b.departure_date > :stay_date + AND b.status IN :valid_statuses + AND c.is_included = true + """), + {"stay_date": stay_date, "valid_statuses": VALID_STATUSES} + ) + row = result.fetchone() + booking_count = row.count if row else 0 + + # Upsert to pace table (column still named arrival_date for backwards compat) + column_name = f"d{interval}" + + # Build dynamic SQL for upsert + db.execute( + text(f""" + INSERT INTO newbook_booking_pace (arrival_date, {column_name}, updated_at) + VALUES (:stay_date, :count, NOW()) + ON CONFLICT (arrival_date) DO UPDATE + SET {column_name} = :count, updated_at = NOW() + """), + {"stay_date": stay_date, "count": booking_count} + ) + updates += 1 + + # Step 2: Update gap dates (31-36, 38-43, etc.) with their bracketed column + # These dates fall between tracked intervals and need their nearest column updated + gap_updates = 0 + for days_out in range(31, 90): # Cover the gap range where intervals are weekly + if days_out in PACE_INTERVALS: + continue # Already handled in step 1 + + stay_date = today + timedelta(days=days_out) + + # Find the bracketed column (round up to next interval) + bracket_col = None + for interval in sorted(PACE_INTERVALS): + if interval >= days_out: + bracket_col = f"d{interval}" + break + + if not bracket_col: + continue + + # Count OCCUPANCY (arrivals + stayovers) + result = db.execute( + text(""" + SELECT COUNT(*) as count + FROM newbook_bookings_data b + JOIN newbook_room_categories c ON b.category_id = c.site_id + WHERE b.arrival_date <= :stay_date + AND b.departure_date > :stay_date + AND b.status IN :valid_statuses + AND c.is_included = true + """), + {"stay_date": stay_date, "valid_statuses": VALID_STATUSES} + ) + row = result.fetchone() + booking_count = row.count if row else 0 + + # Upsert with the bracketed column + db.execute( + text(f""" + INSERT INTO newbook_booking_pace (arrival_date, {bracket_col}, updated_at) + VALUES (:stay_date, :count, NOW()) + ON CONFLICT (arrival_date) DO UPDATE + SET {bracket_col} = :count, updated_at = NOW() + """), + {"stay_date": stay_date, "count": booking_count} + ) + gap_updates += 1 + + logger.info(f"Updated {updates} pace snapshots + {gap_updates} gap dates (occupancy-based)") + + +async def fill_occupancy_only_dates(db, vat_rate: Decimal = None): + """ + Create stats rows for dates that have occupancy data but no bookings. + + This ensures dates like closed periods (all rooms in maintenance) get proper + stats rows with bookable_count=0, so forecasts can cap correctly. + """ + if vat_rate is None: + vat_rate_str = get_config_value(db, 'accommodation_vat_rate') + vat_rate = Decimal(vat_rate_str) if vat_rate_str else Decimal('0.20') + + # Find dates with occupancy data but no stats row + result = db.execute( + text(""" + SELECT DISTINCT o.date + FROM newbook_occupancy_report_data o + JOIN newbook_room_categories c ON o.category_id = c.site_id + WHERE c.is_included = true + AND NOT EXISTS ( + SELECT 1 FROM newbook_bookings_stats s WHERE s.date = o.date + ) + ORDER BY o.date + """) + ) + missing_dates = [row.date for row in result.fetchall()] + + if not missing_dates: + logger.info("No occupancy-only dates to fill") + return 0 + + logger.info(f"Filling {len(missing_dates)} occupancy-only dates (no bookings)") + + for target_date in missing_dates: + await aggregate_date(db, target_date, vat_rate) + + logger.info(f"Filled {len(missing_dates)} occupancy-only dates") + return len(missing_dates) + + +async def backfill_aggregation(db=None): + """ + Backfill historical data into newbook_bookings_stats and newbook_booking_pace. + + - Stats: Aggregates all dates that have bookings staying + - Pace: Reconstructs historical snapshots using booking_placed timestamps + """ + import sys + print("[BACKFILL] Starting backfill aggregation...", flush=True) + sys.stdout.flush() + + close_db = False + if db is None: + db = next(iter([SyncSessionLocal()])) + close_db = True + + try: + # Get VAT rate + vat_rate_str = get_config_value(db, 'accommodation_vat_rate') + vat_rate = Decimal(vat_rate_str) if vat_rate_str else Decimal('0.20') + + # Step 1: Get all unique stay dates from bookings + print("[BACKFILL] Finding all stay dates...", flush=True) + result = db.execute( + text(""" + SELECT DISTINCT d::date as stay_date + FROM newbook_bookings_data b, + generate_series(b.arrival_date, b.departure_date - interval '1 day', '1 day') d + WHERE b.status IN :valid_statuses + ORDER BY stay_date + """), + {"valid_statuses": VALID_STATUSES} + ) + stay_dates = [row.stay_date for row in result.fetchall()] + print(f"[BACKFILL] Found {len(stay_dates)} stay dates to aggregate", flush=True) + + # Step 2: Aggregate each stay date into stats + for i, target_date in enumerate(stay_dates): + if i % 100 == 0: + print(f"[BACKFILL] Aggregating stats: {i}/{len(stay_dates)} dates...", flush=True) + db.commit() # Commit periodically + await aggregate_date(db, target_date, vat_rate) + + db.commit() + print(f"[BACKFILL] Stats aggregation complete: {len(stay_dates)} dates", flush=True) + + # Step 2b: Fill in dates with occupancy data but no bookings (e.g., closed periods) + print("[BACKFILL] Filling occupancy-only dates (no bookings)...", flush=True) + filled_count = await fill_occupancy_only_dates(db, vat_rate) + db.commit() + print(f"[BACKFILL] Filled {filled_count} occupancy-only dates", flush=True) + + # Step 3: Get ALL dates from stats for pace backfill + # This includes dates with 0 bookings (closed periods, future dates) + # Critical: Without pace entries, models may predict 100% occupancy + print("[BACKFILL] Finding all stats dates for pace...", flush=True) + result = db.execute( + text(""" + SELECT date as stay_date + FROM newbook_bookings_stats + ORDER BY date + """) + ) + stay_dates_for_pace = [row.stay_date for row in result.fetchall()] + print(f"[BACKFILL] Found {len(stay_dates_for_pace)} stats dates for pace backfill", flush=True) + + # Step 4: Backfill pace for each stay date (occupancy-based) + today = date.today() + for i, stay_date in enumerate(stay_dates_for_pace): + if i % 100 == 0: + print(f"[BACKFILL] Backfilling pace: {i}/{len(stay_dates_for_pace)} dates...", flush=True) + db.commit() + + await backfill_pace_for_date(db, stay_date, today) + + db.commit() + print(f"[BACKFILL] Pace backfill complete: {len(stay_dates_for_pace)} dates (occupancy-based)", flush=True) + + # Update last aggregation timestamp + db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, updated_at) + VALUES ('last_bookings_aggregation_at', :now, NOW()) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :now, + updated_at = NOW() + """), + {"now": datetime.now().isoformat()} + ) + db.commit() + + print("[BACKFILL] Backfill complete!", flush=True) + logger.info("Backfill aggregation completed successfully") + + except Exception as e: + print(f"[BACKFILL] FAILED: {e}", flush=True) + logger.error(f"Backfill aggregation failed: {e}") + db.rollback() + raise + finally: + if close_db: + db.close() + + +async def backfill_pace_for_date(db, stay_date: date, today: date): + """ + Backfill pace snapshots for a single stay date using booking_placed timestamps. + + Tracks OCCUPANCY (arrivals + stayovers), not just arrivals. + For historical stays: Reconstruct what occupancy would have been at each lead time + For future stays: Use current count for today's lead time + """ + # For each interval, calculate what the occupancy count was at that point + # Using booking_placed to determine when each booking was created + pace_values = {} + + for interval in PACE_INTERVALS: + # The snapshot date is when we would have taken this measurement + snapshot_date = stay_date - timedelta(days=interval) + + if snapshot_date > today: + # This snapshot hasn't happened yet - skip + continue + + if snapshot_date < date(2020, 1, 1): + # Don't go too far back - skip ancient dates + continue + + # Count OCCUPANCY that existed at the snapshot date + # A booking contributes to occupancy if: + # - arrival_date <= stay_date < departure_date (booking spans this night) + # - booking_placed <= snapshot_date (booking existed at measurement time) + # Only counts categories with is_included = true + result = db.execute( + text(""" + SELECT COUNT(*) as count + FROM newbook_bookings_data b + JOIN newbook_room_categories c ON b.category_id = c.site_id + WHERE b.arrival_date <= :stay_date + AND b.departure_date > :stay_date + AND b.status IN :valid_statuses + AND c.is_included = true + AND b.booking_placed IS NOT NULL + AND b.booking_placed::date <= :snapshot_date + """), + { + "stay_date": stay_date, + "valid_statuses": VALID_STATUSES, + "snapshot_date": snapshot_date + } + ) + row = result.fetchone() + pace_values[f"d{interval}"] = row.count if row else 0 + + if not pace_values: + return + + # Build dynamic upsert for all columns we have values for + columns = list(pace_values.keys()) + set_clauses = ", ".join([f"{col} = :{col}" for col in columns]) + insert_cols = ", ".join(columns) + insert_vals = ", ".join([f":{col}" for col in columns]) + + db.execute( + text(f""" + INSERT INTO newbook_booking_pace (arrival_date, {insert_cols}, updated_at) + VALUES (:stay_date, {insert_vals}, NOW()) + ON CONFLICT (arrival_date) DO UPDATE SET + {set_clauses}, updated_at = NOW() + """), + {"stay_date": stay_date, **pace_values} + ) + + +async def fill_missing_pace_entries(db=None): + """ + Fill pace entries for all stats dates that don't have pace rows. + + This fixes gaps where dates exist in stats (with 0 or more bookings) + but have no pace data, causing models to predict incorrectly. + """ + import sys + print("[PACE-FILL] Finding dates missing pace entries...", flush=True) + + close_db = False + if db is None: + db = next(iter([SyncSessionLocal()])) + close_db = True + + try: + # Find dates in stats but not in pace + result = db.execute( + text(""" + SELECT s.date as stay_date + FROM newbook_bookings_stats s + LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date + WHERE p.arrival_date IS NULL + ORDER BY s.date + """) + ) + missing_dates = [row.stay_date for row in result.fetchall()] + + if not missing_dates: + print("[PACE-FILL] No missing pace entries found", flush=True) + return 0 + + print(f"[PACE-FILL] Found {len(missing_dates)} dates missing pace entries", flush=True) + + today = date.today() + for i, stay_date in enumerate(missing_dates): + if i % 100 == 0: + print(f"[PACE-FILL] Processing: {i}/{len(missing_dates)} dates...", flush=True) + db.commit() + + await backfill_pace_for_date(db, stay_date, today) + + db.commit() + print(f"[PACE-FILL] Filled {len(missing_dates)} missing pace entries", flush=True) + return len(missing_dates) + + except Exception as e: + print(f"[PACE-FILL] FAILED: {e}", flush=True) + db.rollback() + raise + finally: + if close_db: + db.close() + + +def get_pace_interval(days_out: int) -> str: + """ + Get the pace column to use for a given lead time. + Uses round-up logic (next higher interval for conservative estimates). + + Examples: + - 25 days out → d25 (exact daily match) + - 35 days out → d37 (rounds up to next weekly) + - 200 days out → d210 (rounds up to next monthly) + """ + # Monthly thresholds (7-12 months) + if days_out >= 365: + return "d365" + if days_out >= 330: + return "d365" + if days_out >= 300: + return "d330" + if days_out >= 270: + return "d300" + if days_out >= 240: + return "d270" + if days_out >= 210: + return "d240" + + # Weekly thresholds (5-25 weeks) + weekly = [177, 170, 163, 156, 149, 142, 135, 128, 121, 114, + 107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37] + for i, threshold in enumerate(weekly): + if days_out >= threshold: + return f"d{weekly[i - 1]}" if i > 0 else "d210" + + # Daily (0-30 days) - exact match available + if days_out > 30: + return "d37" # Round up to first weekly + return f"d{days_out}" diff --git a/backend/jobs/data_sync.py b/backend/jobs/data_sync.py new file mode 100644 index 0000000..e6cb605 --- /dev/null +++ b/backend/jobs/data_sync.py @@ -0,0 +1,1252 @@ +""" +Data sync job - pulls data from Newbook and Resos APIs +""" +import json +import logging +from datetime import date, datetime, timedelta +from typing import Optional, Dict, Set + +from sqlalchemy import text +from database import SyncSessionLocal +from services.newbook_client import NewbookClient +from services.resos_client import ResosClient + +logger = logging.getLogger(__name__) + + +def get_config_value(db, key: str) -> Optional[str]: + """Get a configuration value from system_config table.""" + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + return row.config_value if row else None + + +def load_newbook_credentials(db) -> dict: + """Load Newbook API credentials from database config.""" + import base64 + + def decrypt(value: str) -> str: + """Decrypt base64 encoded value""" + if not value: + return None + try: + return base64.b64decode(value.encode()).decode() + except: + return value + + # Get credentials (some are encrypted) + api_key_result = db.execute( + text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = 'newbook_api_key'") + ) + api_key_row = api_key_result.fetchone() + + password_result = db.execute( + text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = 'newbook_password'") + ) + password_row = password_result.fetchone() + + username = get_config_value(db, 'newbook_username') + region = get_config_value(db, 'newbook_region') + + api_key = None + if api_key_row and api_key_row.config_value: + api_key = decrypt(api_key_row.config_value) if api_key_row.is_encrypted else api_key_row.config_value + + password = None + if password_row and password_row.config_value: + password = decrypt(password_row.config_value) if password_row.is_encrypted else password_row.config_value + + return { + 'api_key': api_key, + 'username': username, + 'password': password, + 'region': region + } + + +def load_resos_credentials(db) -> dict: + """Load Resos API credentials from database config.""" + import base64 + + def decrypt(value: str) -> str: + """Decrypt base64 encoded value""" + if not value: + return None + try: + return base64.b64decode(value.encode()).decode() + except: + return value + + # Get API key (may be encrypted) + api_key_result = db.execute( + text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = 'resos_api_key'") + ) + api_key_row = api_key_result.fetchone() + + api_key = None + if api_key_row and api_key_row.config_value: + api_key = decrypt(api_key_row.config_value) if api_key_row.is_encrypted else api_key_row.config_value + + return {'api_key': api_key} + + +def load_gl_config(db) -> tuple: + """ + Load GL code configuration for identifying breakfast/dinner items. + + Returns: + tuple: (breakfast_codes, dinner_codes, breakfast_vat, dinner_vat, gl_mapping) + """ + # Load configured GL codes + breakfast_gl_codes = get_config_value(db, 'newbook_breakfast_gl_codes') or '' + dinner_gl_codes = get_config_value(db, 'newbook_dinner_gl_codes') or '' + + # Parse into sets + breakfast_codes = set(c.strip() for c in breakfast_gl_codes.split(',') if c.strip()) + dinner_codes = set(c.strip() for c in dinner_gl_codes.split(',') if c.strip()) + + # Load VAT rates + breakfast_vat = float(get_config_value(db, 'newbook_breakfast_vat_rate') or 0.20) + dinner_vat = float(get_config_value(db, 'newbook_dinner_vat_rate') or 0.20) + + # Build GL account ID → GL code mapping from cached lookup table + gl_mapping: Dict[str, str] = {} + result = db.execute(text("SELECT gl_account_id, gl_code FROM newbook_gl_accounts")) + for row in result.fetchall(): + if row.gl_account_id and row.gl_code: + gl_mapping[row.gl_account_id] = row.gl_code + + logger.info(f"Loaded GL config: {len(breakfast_codes)} breakfast codes, {len(dinner_codes)} dinner codes, {len(gl_mapping)} mappings") + + return breakfast_codes, dinner_codes, breakfast_vat, dinner_vat, gl_mapping + + +def process_inventory_items( + inventory_items: list, + gl_mapping: Dict[str, str], + breakfast_codes: Set[str], + dinner_codes: Set[str], + breakfast_vat: float, + dinner_vat: float +) -> dict: + """ + Process inventory items and categorize by GL code. + + Returns dict with keys: breakfast_gross, breakfast_net, dinner_gross, dinner_net, other_items + """ + breakfast_gross = 0.0 + dinner_gross = 0.0 + other_items = [] + + for item in inventory_items: + gl_account_id = item.get('gl_account_id') + # Try to get GL code from mapping, fall back to gl_account_code in the item + gl_code = gl_mapping.get(gl_account_id) or item.get('gl_account_code') or '' + amount = float(item.get('amount', 0) or 0) + + if gl_code in breakfast_codes: + breakfast_gross += amount + elif gl_code in dinner_codes: + dinner_gross += amount + else: + # Store non-breakfast/dinner items for reference + other_items.append({ + 'item_name': item.get('item_name'), + 'gl_account_id': gl_account_id, + 'gl_code': gl_code, + 'amount': amount + }) + + # Calculate net values (gross / (1 + VAT rate)) + breakfast_net = breakfast_gross / (1 + breakfast_vat) if breakfast_vat else breakfast_gross + dinner_net = dinner_gross / (1 + dinner_vat) if dinner_vat else dinner_gross + + return { + 'breakfast_gross': round(breakfast_gross, 2), + 'breakfast_net': round(breakfast_net, 2), + 'dinner_gross': round(dinner_gross, 2), + 'dinner_net': round(dinner_net, 2), + 'other_items': other_items if other_items else None + } + + +async def run_data_sync( + full_sync: bool = False, + triggered_by: str = "scheduler" +): + """ + Main data sync job - runs Newbook bookings, Newbook occupancy report, and Resos sync. + + Args: + full_sync: If True, pulls all bookings. If False, only pulls changes since last sync. + triggered_by: Who/what triggered this sync + """ + logger.info(f"Starting data sync (full_sync={full_sync})") + + try: + # Sync Newbook booking data + await sync_newbook_data(full_sync=full_sync, triggered_by=triggered_by) + + # Sync Newbook occupancy report (provides available rooms, maintenance, official revenue) + # Daily sync: -7 days (catch corrections) to +365 days (future availability for forecasting) + # Can't forecast beyond what's available - need to know maintenance/blocked rooms + occ_from_date = date.today() - timedelta(days=7) + occ_to_date = date.today() + timedelta(days=365) + await sync_newbook_occupancy_report(occ_from_date, occ_to_date, triggered_by) + + # Sync Resos data (still uses date range for now) + from_date = date.today() - timedelta(days=7) + to_date = date.today() + timedelta(days=365) + await sync_resos_data(from_date, to_date, triggered_by) + + logger.info("Data sync completed successfully") + except Exception as e: + logger.error(f"Data sync failed: {e}") + raise + + +async def sync_newbook_data( + full_sync: bool = False, + from_date: Optional[date] = None, + to_date: Optional[date] = None, + triggered_by: str = "scheduler" +): + """ + Sync hotel bookings from Newbook. + + Uses list_type="all" which returns all bookings including cancelled. + - full_sync=True: Fetches entire booking database (initial backfill) + - full_sync=False: Fetches only bookings modified since last successful sync + - from_date/to_date: If provided, fetches bookings staying during this period + """ + import sys + print(f"[SYNC] Starting Newbook sync (full_sync={full_sync})", flush=True) + sys.stdout.flush() + + if from_date and to_date: + logger.info(f"Starting Newbook sync for stay dates {from_date} to {to_date}") + else: + logger.info(f"Starting Newbook sync (full_sync={full_sync})") + + db = next(iter([SyncSessionLocal()])) + + # Load credentials from database + creds = load_newbook_credentials(db) + print(f"[SYNC] Loaded credentials: api_key={'set' if creds['api_key'] else 'empty'}, username={creds['username']}, region={creds['region']}", flush=True) + logger.info(f"Loaded Newbook credentials: api_key={'set' if creds['api_key'] else 'empty'}, username={creds['username']}, region={creds['region']}") + + # Load GL configuration for inventory item categorization + breakfast_codes, dinner_codes, breakfast_vat, dinner_vat, gl_mapping = load_gl_config(db) + + try: + # Get last successful sync timestamp for incremental sync + modified_since = None + if not full_sync: + result = db.execute( + text(""" + SELECT completed_at FROM sync_log + WHERE source = 'newbook' AND status = 'success' + ORDER BY completed_at DESC LIMIT 1 + """) + ) + row = result.fetchone() + if row and row.completed_at: + modified_since = row.completed_at.isoformat() + logger.info(f"Incremental sync: fetching bookings modified since {modified_since}") + else: + logger.info("No previous successful sync found, performing full sync") + + # Log sync start + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, status, triggered_by) + VALUES ('bookings', 'newbook', NOW(), 'running', :triggered_by) + RETURNING id + """), + {"triggered_by": triggered_by} + ) + db.commit() + + print("[SYNC] Creating NewbookClient...", flush=True) + async with NewbookClient( + api_key=creds['api_key'], + username=creds['username'], + password=creds['password'], + region=creds['region'] + ) as client: + # Test connection + print("[SYNC] Testing connection...", flush=True) + if not await client.test_connection(): + print("[SYNC] Connection test FAILED!", flush=True) + raise Exception("Newbook connection failed") + print("[SYNC] Connection test passed", flush=True) + + # Fetch bookings based on sync mode + print(f"[SYNC] Fetching bookings (modified_since={modified_since})...", flush=True) + if from_date and to_date: + # Date range sync - fetch bookings staying during this period + # This is useful for testing or targeted syncs + bookings = await client.get_bookings_by_stay_dates( + from_date=from_date, + to_date=to_date, + list_type="staying" # Gets all bookings staying during this period + ) + print(f"[SYNC] Fetched {len(bookings)} bookings (stay dates: {from_date} to {to_date})", flush=True) + logger.info(f"Fetched {len(bookings)} bookings from Newbook (stay dates: {from_date} to {to_date})") + else: + # Standard sync - all bookings (optionally filtered by modification date) + bookings = await client.get_bookings(modified_since=modified_since) + print(f"[SYNC] Fetched {len(bookings)} bookings from Newbook", flush=True) + logger.info(f"Fetched {len(bookings)} bookings from Newbook") + + records_created = 0 + records_updated = 0 + + print(f"[SYNC] Starting to process {len(bookings)} bookings...", flush=True) + for i, booking in enumerate(bookings): + newbook_id = booking.get("booking_id") + + # Progress every 100 bookings + if i > 0 and i % 100 == 0: + print(f"[SYNC] Processed {i}/{len(bookings)} bookings, {records_created} created", flush=True) + + # Skip bookings without a valid ID + if not newbook_id: + logger.warning(f"Skipping booking without booking_id: {booking.get('booking_reference_id', 'unknown')}") + continue + + # Convert to string for VARCHAR column + newbook_id = str(newbook_id) + + try: + # Create sanitized copy of raw JSON (remove guest PII) + raw_booking = {k: v for k, v in booking.items() if k != "guests"} + raw_json_str = json.dumps(raw_booking) + + # Parse arrival/departure - API returns "2026-02-16 15:00:00" format + arrival_raw = booking.get("booking_arrival") + departure_raw = booking.get("booking_departure") + arrival = arrival_raw.split(" ")[0] if arrival_raw else None # Extract date part only + departure = departure_raw.split(" ")[0] if departure_raw else None + + status = booking.get("booking_status") + category_id = str(booking.get("category_id")) if booking.get("category_id") else None + category_name = booking.get("category_name") + + # Upsert room category to lookup table + if category_id: + db.execute( + text(""" + INSERT INTO room_categories (category_id, category_name) + VALUES (:category_id, :category_name) + ON CONFLICT (category_id) DO UPDATE SET + category_name = COALESCE(:category_name, room_categories.category_name), + updated_at = NOW() + """), + {"category_id": category_id, "category_name": category_name} + ) + + # Upsert booking + result = db.execute( + text(""" + INSERT INTO newbook_bookings ( + newbook_id, booking_reference, arrival_date, departure_date, + nights, adults, children, infants, total_guests, + category_id, room_type, status, total_amount, tariff_name, + booking_source_name, raw_json, fetched_at + ) VALUES ( + :newbook_id, :reference, :arrival, :departure, + :nights, :adults, :children, :infants, :total_guests, + :category_id, :room_type, :status, :total, :tariff_name, + :source, :raw_json, NOW() + ) + ON CONFLICT (newbook_id) DO UPDATE SET + status = :status, + total_amount = :total, + raw_json = :raw_json, + fetched_at = NOW() + """), + { + "newbook_id": newbook_id, + "reference": booking.get("booking_reference_id"), + "arrival": arrival, + "departure": departure, + "nights": booking.get("booking_length"), + "adults": int(booking.get("booking_adults") or 0), + "children": int(booking.get("booking_children") or 0), + "infants": int(booking.get("booking_infants") or 0), + "total_guests": int(booking.get("booking_adults") or 0) + int(booking.get("booking_children") or 0), + "category_id": category_id, + "room_type": category_name, + "status": status, + "total": booking.get("booking_total"), + "tariff_name": booking.get("tariff_name"), + "source": booking.get("booking_source_name"), + "raw_json": raw_json_str + } + ) + + if result.rowcount > 0: + records_created += 1 + + # Get the booking's internal ID for child table references + booking_db_id = None + id_result = db.execute( + text("SELECT id FROM newbook_bookings WHERE newbook_id = :newbook_id"), + {"newbook_id": newbook_id} + ) + id_row = id_result.fetchone() + if id_row: + booking_db_id = id_row.id + + # Get inventory items and group by stay_date + inventory_items = booking.get("inventory_items", []) + inventory_by_date = {} + for item in inventory_items: + stay_date = item.get("stay_date") + if stay_date: + if stay_date not in inventory_by_date: + inventory_by_date[stay_date] = [] + inventory_by_date[stay_date].append(item) + + # Extract and store per-night tariff breakdown + inventory data + tariffs_quoted = booking.get("tariffs_quoted", []) + if booking_db_id and tariffs_quoted: + for tariff in tariffs_quoted: + stay_date = tariff.get("stay_date") + if stay_date: + # Process inventory items for this night using GL code matching + date_inventory = inventory_by_date.get(stay_date, []) + inv_data = process_inventory_items( + date_inventory, + gl_mapping, + breakfast_codes, + dinner_codes, + breakfast_vat, + dinner_vat + ) + + db.execute( + text(""" + INSERT INTO newbook_booking_nights ( + booking_id, stay_date, tariff_quoted_id, tariff_label, + tariff_type_id, tariff_applied_id, original_amount, + calculated_amount, charge_amount, taxes, occupant_charges, + breakfast_gross, breakfast_net, dinner_gross, dinner_net, + other_items, fetched_at + ) VALUES ( + :booking_id, :stay_date, :tariff_quoted_id, :tariff_label, + :tariff_type_id, :tariff_applied_id, :original_amount, + :calculated_amount, :charge_amount, :taxes, :occupant_charges, + :breakfast_gross, :breakfast_net, :dinner_gross, :dinner_net, + :other_items, NOW() + ) + ON CONFLICT (booking_id, stay_date) DO UPDATE SET + tariff_label = :tariff_label, + original_amount = :original_amount, + calculated_amount = :calculated_amount, + charge_amount = :charge_amount, + taxes = :taxes, + occupant_charges = :occupant_charges, + breakfast_gross = :breakfast_gross, + breakfast_net = :breakfast_net, + dinner_gross = :dinner_gross, + dinner_net = :dinner_net, + other_items = :other_items, + fetched_at = NOW() + """), + { + "booking_id": booking_db_id, + "stay_date": stay_date, + "tariff_quoted_id": tariff.get("tariff_quoted_id"), + "tariff_label": tariff.get("label"), + "tariff_type_id": tariff.get("type_id"), + "tariff_applied_id": tariff.get("tariff_applied_id"), + "original_amount": tariff.get("original_amount"), + "calculated_amount": tariff.get("calculated_amount"), + "charge_amount": tariff.get("charge_amount"), + "taxes": json.dumps(tariff.get("taxes", [])), + "occupant_charges": json.dumps(tariff.get("occupant_charges", [])), + "breakfast_gross": inv_data['breakfast_gross'], + "breakfast_net": inv_data['breakfast_net'], + "dinner_gross": inv_data['dinner_gross'], + "dinner_net": inv_data['dinner_net'], + "other_items": json.dumps(inv_data['other_items']) if inv_data['other_items'] else None + } + ) + + # Queue all stay dates for aggregation (arrival to departure-1) + # Each night the guest is "in house" needs recalculating + if arrival and departure: + arrival_date = date.fromisoformat(arrival) if isinstance(arrival, str) else arrival + departure_date = date.fromisoformat(departure) if isinstance(departure, str) else departure + + current = arrival_date + while current < departure_date: + db.execute( + text(""" + INSERT INTO aggregation_queue (date, source, reason, booking_id) + VALUES (:date, 'newbook', :reason, :booking_id) + ON CONFLICT (date, source, booking_id) DO UPDATE SET + queued_at = NOW(), + aggregated_at = NULL + """), + { + "date": current, + "reason": f"booking_{status.lower() if status else 'modified'}", + "booking_id": str(newbook_id) + } + ) + current += timedelta(days=1) + + # Commit this booking immediately + db.commit() + + except Exception as booking_error: + print(f"[SYNC] ERROR processing booking {newbook_id}: {booking_error}", flush=True) + logger.error(f"Error processing booking {newbook_id}: {booking_error}") + db.rollback() # Rollback only this booking's changes + continue # Skip this booking and continue with the next + + # Update sync log + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'success', + records_fetched = :fetched, records_created = :created + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"fetched": len(bookings), "created": records_created} + ) + db.commit() + + print(f"[SYNC] Sync completed: {records_created} records processed", flush=True) + logger.info(f"Newbook sync completed: {records_created} records processed") + + except Exception as e: + print(f"[SYNC] SYNC FAILED: {e}", flush=True) + import traceback + traceback.print_exc() + logger.error(f"Newbook sync failed: {e}") + try: + db.rollback() # Rollback any failed transaction first + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"error": str(e)[:500]} # Truncate error message to avoid issues + ) + db.commit() + except Exception as log_error: + logger.error(f"Failed to update sync_log: {log_error}") + raise + finally: + db.close() + + +def load_resos_custom_field_mappings(db) -> Dict[str, dict]: + """ + Load custom field mappings from resos_custom_field_mapping table. + + Returns dict: {field_id: {"maps_to": "hotel_guest", "value_for_true": "Yes"}} + """ + result = db.execute(text(""" + SELECT field_id, maps_to, value_for_true + FROM resos_custom_field_mapping + WHERE maps_to != 'ignore' + """)) + mappings = {} + for row in result.fetchall(): + mappings[row.field_id] = { + "maps_to": row.maps_to, + "value_for_true": row.value_for_true + } + return mappings + + +async def sync_resos_data( + from_date: date, + to_date: date, + triggered_by: str = "scheduler" +): + """ + Sync restaurant bookings from Resos. + """ + logger.info(f"Starting Resos sync from {from_date} to {to_date}") + + db = next(iter([SyncSessionLocal()])) + + try: + # Log sync start + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by) + VALUES ('bookings', 'resos', NOW(), 'running', :from_date, :to_date, :triggered_by) + """), + {"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by} + ) + db.commit() + + # Load Resos credentials from database + resos_creds = load_resos_credentials(db) + if not resos_creds.get('api_key'): + raise Exception("Resos API key not configured in database") + + # Load custom field mappings from database + cf_mappings = load_resos_custom_field_mappings(db) + logger.info(f"Loaded {len(cf_mappings)} Resos custom field mappings") + + async with ResosClient(api_key=resos_creds['api_key']) as client: + # Test connection + if not await client.test_connection(): + raise Exception("Resos connection failed") + + # Fetch bookings + bookings = await client.get_bookings(from_date, to_date) + logger.info(f"Fetched {len(bookings)} bookings from Resos") + + records_created = 0 + + for booking in bookings: + # Parse guest info + guest = booking.get("guest", {}) + resos_id = booking.get("_id") + booking_date = booking.get("date") + status = booking.get("status") + + # Extract custom fields using configured mappings + custom_fields = booking.get("customFields", []) + is_hotel_guest = None + is_dbb = None + is_package = None + hotel_booking_number = None + allergies = None + + for cf in custom_fields: + # Get field ID - Resos may use 'id' or '_id' + field_id = cf.get("id") or cf.get("_id") or cf.get("fieldId") + # For radio/checkbox fields, use multipleChoiceValueName (human-readable label) + # Fall back to value field for text fields + field_value_label = cf.get("multipleChoiceValueName") or cf.get("value") + field_value = cf.get("value") + + # Check if this field has a configured mapping + if field_id and field_id in cf_mappings: + mapping = cf_mappings[field_id] + maps_to = mapping["maps_to"] + value_for_true = mapping.get("value_for_true") + + # Debug logging for matched mappings (first 5 records only) + if records_created < 5: + logger.info(f"Matched mapping: field_id={field_id}, maps_to={maps_to}, label={field_value_label}, value_for_true={value_for_true}") + + if maps_to == "hotel_guest": + # For boolean fields, check label against value_for_true + if value_for_true: + is_hotel_guest = str(field_value_label) == str(value_for_true) + else: + is_hotel_guest = str(field_value_label).lower() in ("yes", "true", "1") + elif maps_to == "dbb": + if value_for_true: + is_dbb = str(field_value_label) == str(value_for_true) + else: + is_dbb = str(field_value_label).lower() in ("yes", "true", "1") + elif maps_to == "package": + if value_for_true: + is_package = str(field_value) == str(value_for_true) + else: + is_package = str(field_value).lower() in ("yes", "true", "1") + elif maps_to == "booking_number": + hotel_booking_number = str(field_value) if field_value else None + elif maps_to == "allergies": + allergies = str(field_value) if field_value else None + + # Fallback to keyword matching if no mapping configured + elif not cf_mappings: + field_name = cf.get("name", "").lower() + if "hotel" in field_name and "guest" in field_name: + is_hotel_guest = str(field_value).lower() in ("yes", "true", "1") + elif "dbb" in field_name or "dinner bed breakfast" in field_name: + is_dbb = str(field_value).lower() in ("yes", "true", "1") + elif "package" in field_name: + is_package = str(field_value).lower() in ("yes", "true", "1") + elif "booking" in field_name and "number" in field_name: + hotel_booking_number = str(field_value) if field_value else None + elif "allerg" in field_name: + allergies = str(field_value) if field_value else None + + # Upsert booking with full data + db.execute( + text(""" + INSERT INTO resos_bookings ( + resos_id, booking_date, booking_time, covers, + status, source, opening_hour_id, table_name, table_area, + is_hotel_guest, is_dbb, is_package, hotel_booking_number, allergies, + notes, fetched_at + ) VALUES ( + :resos_id, :booking_date, :booking_time, :covers, + :status, :source, :opening_hour_id, :table_name, :table_area, + :is_hotel_guest, :is_dbb, :is_package, :hotel_booking_number, :allergies, + :notes, NOW() + ) + ON CONFLICT (resos_id) DO UPDATE SET + status = :status, + covers = :covers, + is_hotel_guest = COALESCE(:is_hotel_guest, resos_bookings.is_hotel_guest), + is_dbb = COALESCE(:is_dbb, resos_bookings.is_dbb), + is_package = COALESCE(:is_package, resos_bookings.is_package), + fetched_at = NOW() + """), + { + "resos_id": resos_id, + "booking_date": booking_date, + "booking_time": booking.get("time"), + "covers": booking.get("people"), + "status": status, + "source": booking.get("source"), + "opening_hour_id": booking.get("openingHourId"), + "table_name": booking.get("tables", [{}])[0].get("name") if booking.get("tables") else None, + "table_area": booking.get("tables", [{}])[0].get("area", {}).get("name") if booking.get("tables") else None, + "is_hotel_guest": is_hotel_guest, + "is_dbb": is_dbb, + "is_package": is_package, + "hotel_booking_number": hotel_booking_number, + "allergies": allergies, + "notes": str(booking.get("restaurantNotes", [])) + } + ) + records_created += 1 + + # Queue date for aggregation + if booking_date: + db.execute( + text(""" + INSERT INTO aggregation_queue (date, source, reason, booking_id) + VALUES (:date, 'resos', :reason, :booking_id) + ON CONFLICT (date, source, booking_id) DO UPDATE SET + queued_at = NOW(), + aggregated_at = NULL + """), + { + "date": booking_date, + "reason": f"booking_{status.lower() if status else 'modified'}", + "booking_id": str(resos_id) + } + ) + + db.commit() + + # Update sync log + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'success', + records_fetched = :fetched, records_created = :created + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'resos' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"fetched": len(bookings), "created": records_created} + ) + db.commit() + + logger.info(f"Resos sync completed: {records_created} records processed") + + except Exception as e: + logger.error(f"Resos sync failed: {e}") + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'resos' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"error": str(e)} + ) + db.commit() + raise + finally: + db.close() + + +async def sync_newbook_occupancy_report( + from_date: date, + to_date: date, + triggered_by: str = "scheduler" +): + """ + Sync occupancy report from Newbook's reports_occupancy endpoint. + + This provides: + - Available rooms per category (total capacity minus maintenance/offline) + - Official occupied rooms + - Maintenance/offline room counts + - Official revenue figures (gross) + + The 'available' field is crucial for accurate occupancy % calculations + as it accounts for rooms taken offline for maintenance. + + API returns all categories with all dates in a single response (no pagination). + """ + import sys + + print(f"[SYNC] Starting Newbook occupancy report sync ({from_date} to {to_date})", flush=True) + sys.stdout.flush() + + logger.info(f"Starting Newbook occupancy report sync from {from_date} to {to_date}") + + db = next(iter([SyncSessionLocal()])) + + # Load credentials + creds = load_newbook_credentials(db) + + # Get accommodation VAT rate for calculating net revenue + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ) + row = result.fetchone() + accommodation_vat = float(row.config_value) if row and row.config_value else 0.20 + + try: + # Log sync start + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by) + VALUES ('occupancy_report', 'newbook', NOW(), 'running', :from_date, :to_date, :triggered_by) + """), + {"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by} + ) + db.commit() + + print("[SYNC] Creating NewbookClient for occupancy report...", flush=True) + async with NewbookClient( + api_key=creds['api_key'], + username=creds['username'], + password=creds['password'], + region=creds['region'] + ) as client: + # Test connection + if not await client.test_connection(): + raise Exception("Newbook connection failed") + + records_created = 0 + + # Track daily totals as we process categories + # {date: {available: X, occupied: X, maintenance: X, revenue_gross: X, revenue_net: X}} + daily_totals = {} + + print(f"[SYNC] Fetching occupancy report: {from_date} to {to_date}...", flush=True) + report_data = await client.get_occupancy_report(from_date, to_date) + print(f"[SYNC] Received {len(report_data)} categories", flush=True) + + # Response format is a list of categories, each with nested occupancy by date + for category in report_data: + category_id = str(category.get("category_id", "")) + category_name = category.get("category_name", "") + occupancy_data = category.get("occupancy", {}) + + if not category_id: + logger.warning(f"Skipping category without ID: {category}") + continue + + # Process each date in the occupancy data + for date_str, day_data in occupancy_data.items(): + try: + # Parse date (could be "2024-08-01" format) + report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str + + available = int(day_data.get("available", 0) or 0) + occupied = int(day_data.get("occupied", 0) or 0) + maintenance = int(day_data.get("maintenance", 0) or 0) + allotted = int(day_data.get("allotted", 0) or 0) + revenue_gross = float(day_data.get("revenue_gross", 0) or 0) + + # Calculate net revenue (gross / (1 + VAT rate)) + # Use provided revenue_net if available, otherwise calculate + revenue_net = day_data.get("revenue_net") + if revenue_net is None: + revenue_net = revenue_gross / (1 + accommodation_vat) if accommodation_vat else revenue_gross + else: + revenue_net = float(revenue_net) + + # Calculate occupancy percentage + occupancy_pct = (occupied / available * 100) if available > 0 else 0 + + # Upsert into newbook_occupancy_report_data + db.execute( + text(""" + INSERT INTO newbook_occupancy_report_data ( + date, category_id, category_name, + available, occupied, maintenance, allotted, + revenue_gross, revenue_net, occupancy_pct, fetched_at + ) VALUES ( + :date, :category_id, :category_name, + :available, :occupied, :maintenance, :allotted, + :revenue_gross, :revenue_net, :occupancy_pct, NOW() + ) + ON CONFLICT (date, category_id) DO UPDATE SET + category_name = :category_name, + available = :available, + occupied = :occupied, + maintenance = :maintenance, + allotted = :allotted, + revenue_gross = :revenue_gross, + revenue_net = :revenue_net, + occupancy_pct = :occupancy_pct, + fetched_at = NOW() + """), + { + "date": report_date, + "category_id": category_id, + "category_name": category_name, + "available": available, + "occupied": occupied, + "maintenance": maintenance, + "allotted": allotted, + "revenue_gross": round(revenue_gross, 2), + "revenue_net": round(revenue_net, 2), + "occupancy_pct": round(occupancy_pct, 2) + } + ) + records_created += 1 + + # Accumulate daily totals + if report_date not in daily_totals: + daily_totals[report_date] = { + "available": 0, + "occupied": 0, + "maintenance": 0, + "revenue_gross": 0.0, + "revenue_net": 0.0 + } + daily_totals[report_date]["available"] += available + daily_totals[report_date]["occupied"] += occupied + daily_totals[report_date]["maintenance"] += maintenance + daily_totals[report_date]["revenue_gross"] += revenue_gross + daily_totals[report_date]["revenue_net"] += revenue_net + + except Exception as day_error: + logger.error(f"Error processing occupancy for {category_id} on {date_str}: {day_error}") + continue + + db.commit() + + # Update sync log + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'success', + records_fetched = :fetched, records_created = :created + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND sync_type = 'occupancy_report' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"fetched": records_created, "created": records_created} + ) + db.commit() + + print(f"[SYNC] Occupancy report sync completed: {records_created} category records, {len(daily_totals)} daily totals", flush=True) + logger.info(f"Newbook occupancy report sync completed: {records_created} category records, {len(daily_totals)} daily totals") + + except Exception as e: + print(f"[SYNC] Occupancy report sync FAILED: {e}", flush=True) + import traceback + traceback.print_exc() + logger.error(f"Newbook occupancy report sync failed: {e}") + try: + db.rollback() + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND sync_type = 'occupancy_report' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"error": str(e)[:500]} + ) + db.commit() + except Exception as log_error: + logger.error(f"Failed to update sync_log: {log_error}") + raise + finally: + db.close() + + +async def sync_newbook_earned_revenue( + from_date: date, + to_date: date, + triggered_by: str = "scheduler" +): + """ + Sync earned revenue from Newbook's report_earned_revenue endpoint. + + This provides official financial figures by GL account - the declared + accounting revenue that flows into the books. + + Uses accommodation_gl_codes config to identify which GL accounts are + room revenue vs other types (F&B, etc.). + + Fetches day-by-day (API only returns daily breakdown when requesting single days). + Schedule: Historical backfill + daily last 7 days to catch adjustments. + """ + import sys + + print(f"[SYNC] Starting Newbook earned revenue sync ({from_date} to {to_date})", flush=True) + sys.stdout.flush() + + logger.info(f"Starting Newbook earned revenue sync from {from_date} to {to_date}") + + db = next(iter([SyncSessionLocal()])) + + # Load credentials + creds = load_newbook_credentials(db) + + # Load accommodation GL codes configuration + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_gl_codes'") + ) + row = result.fetchone() + accommodation_gl_codes_str = row.config_value if row and row.config_value else "" + accommodation_gl_codes = set(c.strip() for c in accommodation_gl_codes_str.split(',') if c.strip()) + + if not accommodation_gl_codes: + logger.warning("No accommodation_gl_codes configured - all revenue will be marked as 'other'") + print("[SYNC] WARNING: No accommodation_gl_codes configured", flush=True) + + # Get accommodation VAT rate for calculating net if not provided + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ) + row = result.fetchone() + accommodation_vat = float(row.config_value) if row and row.config_value else 0.20 + + try: + # Log sync start + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by) + VALUES ('earned_revenue', 'newbook', NOW(), 'running', :from_date, :to_date, :triggered_by) + """), + {"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by} + ) + db.commit() + + print("[SYNC] Creating NewbookClient for earned revenue...", flush=True) + async with NewbookClient( + api_key=creds['api_key'], + username=creds['username'], + password=creds['password'], + region=creds['region'] + ) as client: + # Test connection + if not await client.test_connection(): + raise Exception("Newbook connection failed") + + records_created = 0 + days_processed = 0 + + # Track daily accommodation totals for updating daily_occupancy + daily_accommodation = {} # {date: {gross: X, net: X}} + + # Track unique GL accounts for caching (for Settings page reference) + gl_accounts_seen = {} # {gl_account_id: {gl_code, gl_name, last_date, total}} + + # Fetch earned revenue (returns dict keyed by date) + print(f"[SYNC] Fetching earned revenue: {from_date} to {to_date}...", flush=True) + revenue_data = await client.get_earned_revenue(from_date, to_date) + print(f"[SYNC] Received data for {len(revenue_data)} days", flush=True) + + for date_str, day_data in revenue_data.items(): + try: + revenue_date = date.fromisoformat(date_str) + days_processed += 1 + + # Initialize daily totals + if revenue_date not in daily_accommodation: + daily_accommodation[revenue_date] = {"gross": 0.0, "net": 0.0} + + # Process GL accounts + # API may return list directly or nested in dict + if isinstance(day_data, list): + gl_accounts = day_data + elif isinstance(day_data, dict): + gl_accounts = day_data.get("gl_accounts", []) or day_data.get("data", []) + else: + gl_accounts = [] + + for gl_item in gl_accounts: + # API field mapping (Newbook reports_earned_revenue response): + # - gl_account_id: internal ID + # - gl_account_code: actual GL code (e.g., "7001") + # - gl_account_description: human-readable name + # - earned_revenue: gross amount (inc. tax) + # - earned_revenue_ex: net amount (exc. tax) + # - earned_revenue_tax: tax amount + gl_account_id = str(gl_item.get("gl_account_id", "")) + gl_code = str(gl_item.get("gl_account_code", "")) + gl_name = gl_item.get("gl_account_description", "") + amount_gross = float(gl_item.get("earned_revenue", 0) or 0) + amount_net = float(gl_item.get("earned_revenue_ex", 0) or 0) + + # Determine revenue type based on GL code + if gl_code in accommodation_gl_codes: + revenue_type = "accommodation" + daily_accommodation[revenue_date]["gross"] += amount_gross + daily_accommodation[revenue_date]["net"] += amount_net + else: + # Future: add food_gl_codes, beverage_gl_codes config + revenue_type = "other" + + # Upsert into newbook_earned_revenue_data + db.execute( + text(""" + INSERT INTO newbook_earned_revenue_data ( + date, gl_account_id, gl_code, gl_name, + amount_gross, amount_net, revenue_type, fetched_at + ) VALUES ( + :date, :gl_account_id, :gl_code, :gl_name, + :amount_gross, :amount_net, :revenue_type, NOW() + ) + ON CONFLICT (date, gl_account_id) DO UPDATE SET + gl_code = :gl_code, + gl_name = :gl_name, + amount_gross = :amount_gross, + amount_net = :amount_net, + revenue_type = :revenue_type, + fetched_at = NOW() + """), + { + "date": revenue_date, + "gl_account_id": gl_account_id, + "gl_code": gl_code, + "gl_name": gl_name, + "amount_gross": round(amount_gross, 2), + "amount_net": round(amount_net, 2), + "revenue_type": revenue_type + } + ) + records_created += 1 + + # Track GL account for caching + if gl_account_id and gl_account_id not in gl_accounts_seen: + gl_accounts_seen[gl_account_id] = { + "gl_code": gl_code, + "gl_name": gl_name, + "last_date": revenue_date, + "total": amount_gross + } + elif gl_account_id: + gl_accounts_seen[gl_account_id]["total"] += amount_gross + if revenue_date > gl_accounts_seen[gl_account_id]["last_date"]: + gl_accounts_seen[gl_account_id]["last_date"] = revenue_date + + except Exception as day_error: + logger.error(f"Error processing earned revenue for {date_str}: {day_error}") + continue + + db.commit() + + # Cache GL accounts for Settings page reference + print(f"[SYNC] Caching {len(gl_accounts_seen)} GL accounts...", flush=True) + for gl_id, gl_info in gl_accounts_seen.items(): + db.execute( + text(""" + INSERT INTO newbook_gl_accounts ( + gl_account_id, gl_code, gl_name, last_seen_date, total_amount, fetched_at + ) VALUES ( + :gl_account_id, :gl_code, :gl_name, :last_date, :total, NOW() + ) + ON CONFLICT (gl_account_id) DO UPDATE SET + gl_code = :gl_code, + gl_name = :gl_name, + last_seen_date = GREATEST(newbook_gl_accounts.last_seen_date, :last_date), + total_amount = newbook_gl_accounts.total_amount + :total, + fetched_at = NOW() + """), + { + "gl_account_id": gl_id, + "gl_code": gl_info["gl_code"], + "gl_name": gl_info["gl_name"], + "last_date": gl_info["last_date"], + "total": round(gl_info["total"], 2) + } + ) + db.commit() + + # Update sync log + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'success', + records_fetched = :fetched, records_created = :created + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND sync_type = 'earned_revenue' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"fetched": days_processed, "created": records_created} + ) + db.commit() + + print(f"[SYNC] Earned revenue sync completed: {records_created} GL records, {days_processed} days", flush=True) + logger.info(f"Newbook earned revenue sync completed: {records_created} GL records, {days_processed} days") + + # Trigger revenue aggregation after successful sync + try: + from jobs.revenue_aggregation import aggregate_revenue + print("[SYNC] Running revenue aggregation...", flush=True) + result = await aggregate_revenue() + print(f"[SYNC] Revenue aggregation complete: {result.get('dates_processed', 0)} dates", flush=True) + except Exception as agg_err: + print(f"[SYNC] Revenue aggregation failed (non-fatal): {agg_err}", flush=True) + logger.warning(f"Revenue aggregation failed after sync: {agg_err}") + + except Exception as e: + print(f"[SYNC] Earned revenue sync FAILED: {e}", flush=True) + import traceback + traceback.print_exc() + logger.error(f"Newbook earned revenue sync failed: {e}") + try: + db.rollback() + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'newbook' AND sync_type = 'earned_revenue' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"error": str(e)[:500]} + ) + db.commit() + except Exception as log_error: + logger.error(f"Failed to update sync_log: {log_error}") + raise + finally: + db.close() \ No newline at end of file diff --git a/backend/jobs/fetch_current_rates.py b/backend/jobs/fetch_current_rates.py new file mode 100644 index 0000000..6c10a75 --- /dev/null +++ b/backend/jobs/fetch_current_rates.py @@ -0,0 +1,359 @@ +""" +Fetch Current Rates Job + +Fetches current rack rates from Newbook API and populates newbook_current_rates table. +These rates are used by pickup-v2 model for upper bound calculations in confidence shading. + +Uses a snapshot model - only inserts new rows when rates change, otherwise updates last_verified_at. +This allows tracking rate history over time. + +Schedule: Daily at 5:20 AM (before pace snapshot runs) + +Processing: Day-by-day with progressive DB commits. Each date is fully processed +(single-night fetch + inline multi-night verification) and saved before moving to the next. +If the job fails partway, all previously processed dates are preserved. +""" +import json +import logging +import re +from datetime import date, timedelta +from decimal import Decimal +from typing import Dict, Any, Optional, Set + +import asyncio +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + +COMMIT_BATCH_SIZE = 10 # Commit to DB every N days + + +def rates_changed(old_rate: Optional[Dict], new_rate: Dict) -> bool: + """ + Compare old and new rates to determine if they've changed. + + Compares gross rate, net rate, and tariff availability status. + Returns True if rates have changed, False if they're the same. + """ + if old_rate is None: + return True # No existing rate, need to insert + + # Compare gross and net rates + old_gross = float(old_rate.get('rate_gross') or 0) + new_gross = float(new_rate.get('gross_rate') or 0) + if abs(old_gross - new_gross) > 0.01: + return True + + old_net = float(old_rate.get('rate_net') or 0) + new_net = float(new_rate.get('net_rate') or 0) + if abs(old_net - new_net) > 0.01: + return True + + # Compare tariff availability + old_tariffs = old_rate.get('tariffs_data', {}) + if isinstance(old_tariffs, str): + try: + old_tariffs = json.loads(old_tariffs) + except json.JSONDecodeError: + old_tariffs = {} + + new_tariffs = new_rate.get('tariffs_data', {}) + + old_tariff_list = old_tariffs.get('tariffs', []) + new_tariff_list = new_tariffs.get('tariffs', []) + + # Different number of tariffs + if len(old_tariff_list) != len(new_tariff_list): + return True + + # Compare each tariff's key attributes + for old_t, new_t in zip(old_tariff_list, new_tariff_list): + # Name changed + if old_t.get('name') != new_t.get('name'): + return True + # Availability status changed + if old_t.get('success') != new_t.get('success'): + return True + # Rate changed significantly + old_rate_val = float(old_t.get('rate') or 0) + new_rate_val = float(new_t.get('rate') or 0) + if abs(old_rate_val - new_rate_val) > 0.01: + return True + # Min stay changed + if old_t.get('min_stay') != new_t.get('min_stay'): + return True + # Multi-night availability changed + if old_t.get('available_for_min_stay') != new_t.get('available_for_min_stay'): + return True + + return False + + +def save_rate_snapshot(db, category_id: str, rate_date: date, rate: Dict) -> str: + """ + Save rate to database using snapshot logic. + + If rate has changed from latest version, insert new row. + If rate is the same, just update last_verified_at. + + Returns: 'inserted', 'verified', or 'error' + """ + gross_rate = rate.get('gross_rate') + net_rate = rate.get('net_rate') + tariffs_data = rate.get('tariffs_data', {}) + + # Get the latest rate for this category/date + existing = db.execute( + text(""" + SELECT id, rate_gross, rate_net, tariffs_data + FROM newbook_current_rates + WHERE category_id = :category_id AND rate_date = :rate_date + ORDER BY valid_from DESC + LIMIT 1 + """), + {"category_id": category_id, "rate_date": rate_date} + ).fetchone() + + if existing: + existing_dict = { + 'rate_gross': existing.rate_gross, + 'rate_net': existing.rate_net, + 'tariffs_data': existing.tariffs_data + } + else: + existing_dict = None + + if rates_changed(existing_dict, rate): + # Rates changed - insert new snapshot + db.execute( + text(""" + INSERT INTO newbook_current_rates + (category_id, rate_date, rate_gross, rate_net, tariffs_data, valid_from, last_verified_at) + VALUES (:category_id, :rate_date, :rate_gross, :rate_net, + CAST(:tariffs_data AS jsonb), NOW(), NOW()) + """), + { + "category_id": category_id, + "rate_date": rate_date, + "rate_gross": gross_rate, + "rate_net": net_rate, + "tariffs_data": json.dumps(tariffs_data) + } + ) + return 'inserted' + else: + # Rates unchanged - just verify + db.execute( + text(""" + UPDATE newbook_current_rates + SET last_verified_at = NOW() + WHERE id = :id + """), + {"id": existing.id} + ) + return 'verified' + + +def needs_multi_night_check(tariff: Dict, days_ahead: int) -> Optional[int]: + """ + Check if a tariff needs multi-night verification. + + Returns the min_stay value if a multi-night check is needed, None otherwise. + Skips tariffs with advance booking restrictions that aren't met. + """ + min_stay = tariff.get('min_stay') + if not min_stay or min_stay <= 1: + return None + if tariff.get('success', False): + return None # Already available as single-night, no recheck needed + + # Check for advance booking requirement + message = tariff.get('message', '') or '' + advance_match = re.search(r'(\d+)\s*days?\s*in\s*advance', message, re.IGNORECASE) + if advance_match: + min_advance_days = int(advance_match.group(1)) + if days_ahead < min_advance_days: + return None # Within advance period - recheck won't help + + return min_stay + + +async def run_fetch_current_rates(horizon_days: int = 720, start_date: date = None): + """ + Fetch current rates for all included categories and store in database. + + Args: + horizon_days: Number of days ahead to fetch (default 720 for scheduled, configurable for manual) + start_date: Start date for fetch (default today) + + Processing: Day-by-day with progressive commits. + For each date: + 1. Fetch single-night rates (all categories in one API call) + 2. Check if any tariffs need multi-night verification + 3. If so, run multi-night check immediately for that date + 4. Save all rates for that date to DB + 5. Commit every COMMIT_BATCH_SIZE days + + This means if the job fails at day 400, the first 390+ days are already saved. + """ + logger.info(f"Starting current rates fetch ({horizon_days} days)") + + db = next(iter([SyncSessionLocal()])) + today = start_date or date.today() + + try: + # Get VAT rate from config + vat_result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ).fetchone() + vat_rate_str = vat_result.config_value if vat_result and vat_result.config_value else '0.20' + + # Get all included room 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()) + + if not included_categories: + logger.warning("No included room categories found") + return + + logger.info(f"Fetching rates for {len(included_categories)} categories") + + # Import rates client + import base64 + from services.newbook_rates_client import NewbookRatesClient + + # Get credentials from config (decrypt encrypted values) + config_result = db.execute( + text(""" + SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted + FROM system_config + WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region') + """) + ) + config = {} + for row in config_result.fetchall(): + value = row.config_value + if row.is_encrypted and value: + try: + value = base64.b64decode(value.encode()).decode() + except Exception: + pass # Use raw value if decryption fails + config[row.config_key] = value + + if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']): + logger.error("Newbook credentials not configured") + return + + # Create client + client = NewbookRatesClient( + api_key=config['newbook_api_key'], + username=config['newbook_username'], + password=config['newbook_password'], + region=config['newbook_region'], + vat_rate=Decimal(vat_rate_str) + ) + + async with client: + inserted_total = 0 + verified_total = 0 + multi_night_checks = 0 + skipped_advance = 0 + current_date = today + day_count = 0 + + while current_date <= today + timedelta(days=horizon_days): + day_count += 1 + days_ahead = (current_date - today).days + + try: + # Step 1: Fetch single-night rates for all categories on this date + day_rates = await client.fetch_single_date_all_categories( + current_date, guests_adults=2, guests_children=0 + ) + + # Step 2: Check for multi-night verification needs and run inline + # Collect unique min_stay values needed for this date + nights_needed: Set[int] = set() + for cat_id, rates in day_rates.items(): + if cat_id not in included_categories: + continue + for rate in rates: + for tariff in rate.get('tariffs_data', {}).get('tariffs', []): + check = needs_multi_night_check(tariff, days_ahead) + if check: + nights_needed.add(check) + elif tariff.get('min_stay') and tariff['min_stay'] > 1 and not tariff.get('success', False): + skipped_advance += 1 + + # Step 3: Run multi-night checks for this date if needed + multi_night_results: Dict[int, Dict[str, Dict[str, bool]]] = {} + for nights in sorted(nights_needed): + try: + result = await client.fetch_multi_night_for_date( + current_date, nights + ) + multi_night_results[nights] = result + multi_night_checks += 1 + await asyncio.sleep(1.0) # Rate limiting + except Exception as e: + logger.warning(f"Multi-night check failed for {current_date} ({nights}n): {e}") + + # Step 4: Update tariffs with multi-night results and save to DB + for cat_id, rates in day_rates.items(): + if cat_id not in included_categories: + continue + for rate in rates: + tariffs_data = rate.get('tariffs_data', {}) + # Apply multi-night results to tariffs + for tariff in tariffs_data.get('tariffs', []): + min_stay = tariff.get('min_stay') + if min_stay and min_stay > 1 and min_stay in multi_night_results: + cat_availability = multi_night_results[min_stay].get(cat_id, {}) + tariff_name = tariff.get('name', '') + tariff['available_for_min_stay'] = cat_availability.get(tariff_name, False) + + # Save to DB + result = save_rate_snapshot(db, cat_id, current_date, rate) + if result == 'inserted': + inserted_total += 1 + elif result == 'verified': + verified_total += 1 + + if day_count % 50 == 0 or nights_needed: + logger.info( + f"Day {day_count}/{horizon_days}: {current_date}" + f" | {inserted_total} new, {verified_total} verified" + f"{f' | {len(nights_needed)} multi-night checks' if nights_needed else ''}" + ) + + except Exception as e: + logger.warning(f"Failed to fetch rates for {current_date}: {e}") + + # Step 5: Commit periodically + if day_count % COMMIT_BATCH_SIZE == 0: + db.commit() + + current_date += timedelta(days=1) + await asyncio.sleep(1.0) # Rate limiting between days + + # Final commit for remaining days + db.commit() + + if skipped_advance > 0: + logger.info(f"Skipped {skipped_advance} multi-night checks (advance booking restriction)") + logger.info( + f"Complete: {inserted_total} new snapshots, {verified_total} verified unchanged, " + f"{multi_night_checks} multi-night checks" + ) + + logger.info("Current rates fetch completed successfully") + + except Exception as e: + logger.error(f"Current rates fetch failed: {e}") + db.rollback() + raise + finally: + db.close() diff --git a/backend/jobs/forecast_daily.py b/backend/jobs/forecast_daily.py new file mode 100644 index 0000000..aac7e85 --- /dev/null +++ b/backend/jobs/forecast_daily.py @@ -0,0 +1,276 @@ +""" +Daily forecast generation job +Runs Prophet, XGBoost, and Pickup models +""" +import json +import logging +import uuid +from datetime import date, timedelta +from typing import List, Optional + +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + + +async def run_daily_forecast( + horizon_days: int = 14, + start_days: int = 0, + models: Optional[List[str]] = None, + triggered_by: str = "scheduler" +): + """ + Run daily forecast update for specified horizon. + + Args: + horizon_days: How many days ahead to forecast + start_days: Start from N days in the future (for medium/long term) + models: Which models to run (default: all) + triggered_by: Who triggered this run + """ + if models is None: + models = ['prophet', 'xgboost', 'pickup', 'catboost'] + + run_id = str(uuid.uuid4()) + forecast_from = date.today() + timedelta(days=start_days) + forecast_to = date.today() + timedelta(days=horizon_days) + + logger.info(f"Starting forecast run {run_id}: {forecast_from} to {forecast_to}, models: {models}") + + db = next(iter([SyncSessionLocal()])) + + try: + # Log run start + db.execute( + text(""" + INSERT INTO forecast_runs ( + run_id, run_type, started_at, status, + forecast_from, forecast_to, models_run, triggered_by + ) VALUES ( + :run_id, 'scheduled', NOW(), 'running', + :forecast_from, :forecast_to, :models, :triggered_by + ) + """), + { + "run_id": run_id, + "forecast_from": forecast_from, + "forecast_to": forecast_to, + "models": json.dumps(models), + "triggered_by": triggered_by + } + ) + db.commit() + + # Get metrics to forecast + result = db.execute( + text(""" + SELECT metric_code, use_prophet, use_xgboost, use_pickup, + COALESCE(use_catboost, TRUE) as use_catboost + FROM forecast_metrics + WHERE is_active = TRUE + """) + ) + metrics = result.fetchall() + + forecasts_generated = 0 + + for metric in metrics: + metric_code = metric.metric_code + + # Run Prophet if applicable + if 'prophet' in models and metric.use_prophet: + try: + from services.forecasting.prophet_model import run_prophet_forecast + prophet_forecasts = await run_prophet_forecast( + db, metric_code, forecast_from, forecast_to + ) + forecasts_generated += len(prophet_forecasts) + except Exception as e: + logger.error(f"Prophet forecast failed for {metric_code}: {e}") + db.rollback() # Rollback failed transaction + + # Run XGBoost if applicable + if 'xgboost' in models and metric.use_xgboost: + try: + from services.forecasting.xgboost_model import run_xgboost_forecast + xgboost_forecasts = await run_xgboost_forecast( + db, metric_code, forecast_from, forecast_to + ) + forecasts_generated += len(xgboost_forecasts) + except Exception as e: + logger.error(f"XGBoost forecast failed for {metric_code}: {e}") + db.rollback() # Rollback failed transaction + + # Run Pickup if applicable (only for short-term) + if 'pickup' in models and metric.use_pickup and start_days < 30: + try: + from services.forecasting.pickup_model import run_pickup_forecast + pickup_forecasts = await run_pickup_forecast( + db, metric_code, forecast_from, forecast_to + ) + forecasts_generated += len(pickup_forecasts) + except Exception as e: + logger.error(f"Pickup forecast failed for {metric_code}: {e}") + db.rollback() # Rollback failed transaction + + # Run CatBoost if applicable + if 'catboost' in models and getattr(metric, 'use_catboost', True): + try: + from services.forecasting.catboost_model import run_catboost_forecast + catboost_forecasts = await run_catboost_forecast( + db, metric_code, forecast_from, forecast_to + ) + forecasts_generated += len(catboost_forecasts) + except Exception as e: + logger.error(f"CatBoost forecast failed for {metric_code}: {e}") + db.rollback() # Rollback failed transaction + + # Run blended model (accuracy-weighted average of prophet, xgboost, catboost) + if 'blended' in models: + try: + logger.info("Generating blended forecasts with accuracy-based weighting") + + # Get accuracy scores for model weighting (from last 90 days) + # Calculate weights per metric + metric_weights = {} + for metric in metrics: + metric_code = metric.metric_code + try: + accuracy_result = db.execute( + text(""" + SELECT + AVG(ABS(prophet_pct_error)) as prophet_mape, + AVG(ABS(xgboost_pct_error)) as xgboost_mape, + AVG(ABS(catboost_pct_error)) as catboost_mape + FROM actual_vs_forecast + WHERE date >= CURRENT_DATE - INTERVAL '90 days' + AND date < CURRENT_DATE + AND metric_type = :metric + AND actual_value IS NOT NULL + """), + {"metric": metric_code} + ) + accuracy_row = accuracy_result.fetchone() + + # Calculate inverse-MAPE weights (lower MAPE = higher weight) + if accuracy_row and accuracy_row.prophet_mape and accuracy_row.xgboost_mape and accuracy_row.catboost_mape: + prophet_mape = float(accuracy_row.prophet_mape) or 10 + xgboost_mape = float(accuracy_row.xgboost_mape) or 10 + catboost_mape = float(accuracy_row.catboost_mape) or 10 + + inv_prophet = 1 / max(prophet_mape, 0.1) + inv_xgboost = 1 / max(xgboost_mape, 0.1) + inv_catboost = 1 / max(catboost_mape, 0.1) + total_inv = inv_prophet + inv_xgboost + inv_catboost + + metric_weights[metric_code] = { + 'prophet': inv_prophet / total_inv, + 'xgboost': inv_xgboost / total_inv, + 'catboost': inv_catboost / total_inv + } + else: + # Equal weights if no accuracy data + metric_weights[metric_code] = {'prophet': 1/3, 'xgboost': 1/3, 'catboost': 1/3} + except Exception: + # Default to equal weights on error + metric_weights[metric_code] = {'prophet': 1/3, 'xgboost': 1/3, 'catboost': 1/3} + + # Get all forecasts from the three models for this run + result = db.execute( + text(""" + SELECT forecast_date, forecast_type, model_type, predicted_value + FROM forecasts + WHERE run_id = :run_id + AND model_type IN ('prophet', 'xgboost', 'catboost') + ORDER BY forecast_date, forecast_type + """), + {"run_id": run_id} + ) + rows = result.fetchall() + + if rows: + # Group by forecast_date and forecast_type + forecasts_by_date_type = {} + for row in rows: + key = (row.forecast_date, row.forecast_type) + if key not in forecasts_by_date_type: + forecasts_by_date_type[key] = {} + forecasts_by_date_type[key][row.model_type] = float(row.predicted_value) + + # Calculate weighted blended forecast for each date/type combination + blended_count = 0 + for (forecast_date, forecast_type), model_forecasts in forecasts_by_date_type.items(): + # Only blend if we have at least 2 models + if len(model_forecasts) >= 2: + # Get weights for this metric + weights = metric_weights.get(forecast_type, {'prophet': 1/3, 'xgboost': 1/3, 'catboost': 1/3}) + + # Calculate weighted average + weighted_sum = 0 + weight_total = 0 + for model, value in model_forecasts.items(): + weight = weights.get(model, 0) + weighted_sum += value * weight + weight_total += weight + + blended_value = weighted_sum / weight_total if weight_total > 0 else sum(model_forecasts.values()) / len(model_forecasts) + + # Insert blended forecast + db.execute( + text(""" + INSERT INTO forecasts + (run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at) + VALUES + (:run_id, :forecast_date, :forecast_type, 'blended', :predicted_value, NOW()) + """), + { + "run_id": run_id, + "forecast_date": forecast_date, + "forecast_type": forecast_type, + "predicted_value": round(blended_value, 2) + } + ) + blended_count += 1 + + db.commit() + forecasts_generated += blended_count + logger.info(f"Generated {blended_count} accuracy-weighted blended forecasts") + else: + logger.warning("No individual model forecasts found for blending") + + except Exception as e: + logger.error(f"Blended forecast generation failed: {e}") + + # Update run status + db.execute( + text(""" + UPDATE forecast_runs + SET completed_at = NOW(), status = 'success' + WHERE run_id = :run_id + """), + {"run_id": run_id} + ) + db.commit() + + logger.info(f"Forecast run {run_id} completed: {forecasts_generated} forecasts generated") + + except Exception as e: + logger.error(f"Forecast run {run_id} failed: {e}") + # Rollback the failed transaction first + db.rollback() + try: + db.execute( + text(""" + UPDATE forecast_runs + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE run_id = :run_id + """), + {"run_id": run_id, "error": str(e)} + ) + db.commit() + except Exception as update_error: + logger.error(f"Failed to update error status: {update_error}") + raise + finally: + db.close() diff --git a/backend/jobs/metrics_aggregation.py b/backend/jobs/metrics_aggregation.py new file mode 100644 index 0000000..547d76d --- /dev/null +++ b/backend/jobs/metrics_aggregation.py @@ -0,0 +1,150 @@ +""" +Metrics aggregation job for forecast_data database +Populates daily_metrics from newbook_bookings_stats + +This is the data source for forecasting models (Prophet, XGBoost, CatBoost). +""" +import logging +from datetime import date, timedelta +from typing import List, Optional + +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + + +async def run_metrics_aggregation( + from_date: Optional[date] = None, + to_date: Optional[date] = None +): + """ + Populate daily_metrics table from newbook_bookings_stats. + + This provides the historical actuals needed for forecasting models. + + Args: + from_date: Start date (defaults to 2 years ago) + to_date: End date (defaults to yesterday) + """ + logger.info("Starting metrics aggregation job") + + db = next(iter([SyncSessionLocal()])) + + try: + # Default date range: 2 years of history + if from_date is None: + from_date = date.today() - timedelta(days=730) + if to_date is None: + to_date = date.today() - timedelta(days=1) + + logger.info(f"Aggregating metrics from {from_date} to {to_date}") + + # Get data from newbook_bookings_stats + result = db.execute( + text(""" + SELECT + date, + booking_count, -- room nights (occupied rooms) + total_occupancy_pct, -- occupancy percentage + guests_count, -- total guests + adults_count, + children_count, + rooms_count, -- available rooms + bookable_count -- bookable rooms (rooms - maintenance) + FROM newbook_bookings_stats + WHERE date BETWEEN :from_date AND :to_date + ORDER BY date + """), + {"from_date": from_date, "to_date": to_date} + ) + stats_rows = result.fetchall() + + if not stats_rows: + logger.warning("No data found in newbook_bookings_stats") + return + + logger.info(f"Found {len(stats_rows)} days of data to aggregate") + + # Metrics to populate + metrics_count = 0 + + for row in stats_rows: + d = row.date + + # Define metrics from newbook_bookings_stats + metrics_to_insert = [] + + # Room nights (occupied rooms) + if row.booking_count is not None: + metrics_to_insert.append(("hotel_room_nights", row.booking_count)) + + # Occupancy percentage + if row.total_occupancy_pct is not None: + metrics_to_insert.append(("hotel_occupancy_pct", float(row.total_occupancy_pct))) + + # Guest count + if row.guests_count is not None: + metrics_to_insert.append(("hotel_guests", row.guests_count)) + + # Insert/update all metrics + for metric_code, actual_value in metrics_to_insert: + db.execute( + text(""" + INSERT INTO daily_metrics (date, metric_code, actual_value, source, updated_at) + VALUES (:date, :metric_code, :actual_value, 'newbook', NOW()) + ON CONFLICT (date, metric_code) DO UPDATE SET + actual_value = :actual_value, + updated_at = NOW() + """), + { + "date": d, + "metric_code": metric_code, + "actual_value": actual_value + } + ) + metrics_count += 1 + + db.commit() + logger.info(f"Aggregated {metrics_count} metric records from {len(stats_rows)} days") + + except Exception as e: + logger.error(f"Metrics aggregation failed: {e}") + db.rollback() + raise + finally: + db.close() + + +async def backfill_daily_metrics(): + """ + Backfill all available history from newbook_bookings_stats to daily_metrics. + Call this once when setting up forecasting on forecast_data database. + """ + logger.info("Starting full backfill of daily_metrics") + + db = next(iter([SyncSessionLocal()])) + + try: + # Find the earliest date in newbook_bookings_stats + result = db.execute(text("SELECT MIN(date) as min_date FROM newbook_bookings_stats")) + row = result.fetchone() + + if not row or not row.min_date: + logger.warning("No data in newbook_bookings_stats to backfill") + return + + from_date = row.min_date + to_date = date.today() - timedelta(days=1) + + logger.info(f"Backfilling from {from_date} to {to_date}") + + await run_metrics_aggregation(from_date=from_date, to_date=to_date) + + logger.info("Backfill completed successfully") + + except Exception as e: + logger.error(f"Backfill failed: {e}") + raise + finally: + db.close() diff --git a/backend/jobs/pace_snapshot_v2.py b/backend/jobs/pace_snapshot_v2.py new file mode 100644 index 0000000..82c5cc5 --- /dev/null +++ b/backend/jobs/pace_snapshot_v2.py @@ -0,0 +1,439 @@ +""" +Pace Snapshot V2 - Enhanced pace capture for pickup-v2 model + +Captures: +1. Per-category room counts at each lead time (category_booking_pace) +2. Total booked accommodation revenue at each lead time (revenue_pace) + +This job runs alongside the existing pickup_snapshot job. +Uses 364-day offset for prior year comparison (52 weeks = day-of-week alignment). +""" +import logging +from datetime import date, timedelta +from decimal import Decimal +from typing import Dict, List, Optional + +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + +# Valid booking statuses for aggregation +VALID_STATUSES = ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + +# All tracked pace intervals (same as booking_pace table structure) +PACE_INTERVALS = [ + # Monthly (months 7-12) + 365, 330, 300, 270, 240, 210, + # Weekly (weeks 5-25) + 177, 170, 163, 156, 149, 142, 135, 128, 121, 114, + 107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37, + # Daily (days 0-30) + 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 +] + + +def get_lead_time_column(lead_days: int) -> str: + """ + Map lead days to the appropriate column in pace tables. + Uses round-up logic for days between tracked intervals. + """ + if lead_days <= 0: + return "d0" + elif lead_days <= 30: + return f"d{lead_days}" + elif lead_days <= 177: + # Weekly intervals - find next higher + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + for col in weekly_cols: + if lead_days <= col: + return f"d{col}" + return "d177" + else: + # Monthly intervals + monthly_cols = [210, 240, 270, 300, 330, 365] + for col in monthly_cols: + if lead_days <= col: + return f"d{col}" + return "d365" + + +def get_rate_for_date(raw_json: dict, target_date: date, vat_rate: Decimal) -> Decimal: + """ + Extract net accommodation rate from tariffs_quoted for a specific stay_date. + Returns net amount (after VAT deduction). + """ + if not raw_json: + return Decimal('0') + + tariffs = raw_json.get("tariffs_quoted", []) + target_str = target_date.strftime("%Y-%m-%d") + + for tariff in tariffs: + if tariff.get("stay_date") == target_str: + charge_amount = Decimal(str(tariff.get("charge_amount", 0) or 0)) + + # Try to get net from taxes array if available + taxes = tariff.get("taxes", []) + if taxes and charge_amount > 0: + tax_amount = sum(Decimal(str(t.get("amount", 0) or 0)) for t in taxes) + net_amount = charge_amount - tax_amount + else: + # Fallback: calculate net using VAT rate + net_amount = charge_amount / (1 + vat_rate) + + return net_amount + + return Decimal('0') + + +async def run_pace_snapshot_v2(): + """ + Capture per-category room counts and total revenue at each lead time. + + Updates: + - category_booking_pace: room counts by category for each future date + - revenue_pace: total booked accommodation revenue for each future date + """ + logger.info("Starting pace snapshot v2 capture") + + db = next(iter([SyncSessionLocal()])) + today = date.today() + + try: + # Get accommodation VAT rate from config + vat_result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ).fetchone() + vat_rate = Decimal(vat_result.config_value) if vat_result and vat_result.config_value else Decimal('0.20') + + # Get all included room categories + cat_result = db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + logger.warning("No included room categories found") + return + + # Process each tracked interval + for interval in PACE_INTERVALS: + stay_date = today + timedelta(days=interval) + column_name = f"d{interval}" + + # === 1. Capture per-category room counts === + cat_counts = await capture_category_counts(db, stay_date, included_categories) + + for category_id, count in cat_counts.items(): + db.execute( + text(f""" + INSERT INTO category_booking_pace (arrival_date, category_id, {column_name}, updated_at) + VALUES (:stay_date, :category_id, :count, NOW()) + ON CONFLICT (arrival_date, category_id) DO UPDATE + SET {column_name} = :count, updated_at = NOW() + """), + {"stay_date": stay_date, "category_id": category_id, "count": count} + ) + + # === 2. Capture total booked revenue === + total_revenue = await capture_booked_revenue(db, stay_date, vat_rate, included_categories) + + db.execute( + text(f""" + INSERT INTO revenue_pace (stay_date, {column_name}, updated_at) + VALUES (:stay_date, :revenue, NOW()) + ON CONFLICT (stay_date) DO UPDATE + SET {column_name} = :revenue, updated_at = NOW() + """), + {"stay_date": stay_date, "revenue": float(total_revenue)} + ) + + # Also fill gap dates (31-36, 38-43, etc.) with their bracketed column + await fill_gap_dates(db, today, vat_rate, included_categories) + + db.commit() + logger.info(f"Pace snapshot v2 completed for {today}") + + except Exception as e: + logger.error(f"Pace snapshot v2 failed: {e}") + db.rollback() + raise + finally: + db.close() + + +async def capture_category_counts(db, stay_date: date, included_categories: List[str]) -> Dict[str, int]: + """ + Count rooms booked per category for a given stay date. + Returns dict of {category_id: count} + """ + result = db.execute( + text(""" + SELECT category_id, COUNT(*) as count + FROM newbook_bookings_data + WHERE arrival_date <= :stay_date + AND departure_date > :stay_date + AND status IN :valid_statuses + AND category_id IN :categories + GROUP BY category_id + """), + {"stay_date": stay_date, "valid_statuses": VALID_STATUSES, "categories": tuple(included_categories)} + ) + + counts = {cat: 0 for cat in included_categories} # Initialize all categories with 0 + for row in result.fetchall(): + counts[row.category_id] = row.count + + return counts + + +async def capture_booked_revenue(db, stay_date: date, vat_rate: Decimal, included_categories: List[str]) -> Decimal: + """ + Calculate total booked accommodation revenue (net) for a given stay date. + Sums up tariffs from all active bookings that span this date. + """ + result = db.execute( + text(""" + SELECT raw_json + FROM newbook_bookings_data + WHERE arrival_date <= :stay_date + AND departure_date > :stay_date + AND status IN :valid_statuses + AND category_id IN :categories + """), + {"stay_date": stay_date, "valid_statuses": VALID_STATUSES, "categories": tuple(included_categories)} + ) + + total_revenue = Decimal('0') + for row in result.fetchall(): + if row.raw_json: + revenue = get_rate_for_date(row.raw_json, stay_date, vat_rate) + total_revenue += revenue + + return total_revenue + + +async def fill_gap_dates(db, today: date, vat_rate: Decimal, included_categories: List[str]): + """ + Fill gap dates (between tracked intervals) with their bracketed column value. + These dates fall between weekly intervals and need the next higher column updated. + """ + gap_updates = 0 + + for days_out in range(31, 90): # Cover the gap range where intervals are weekly + if days_out in PACE_INTERVALS: + continue # Already handled in main loop + + stay_date = today + timedelta(days=days_out) + bracket_col = get_lead_time_column(days_out) + + # Capture category counts + cat_counts = await capture_category_counts(db, stay_date, included_categories) + for category_id, count in cat_counts.items(): + db.execute( + text(f""" + INSERT INTO category_booking_pace (arrival_date, category_id, {bracket_col}, updated_at) + VALUES (:stay_date, :category_id, :count, NOW()) + ON CONFLICT (arrival_date, category_id) DO UPDATE + SET {bracket_col} = :count, updated_at = NOW() + """), + {"stay_date": stay_date, "category_id": category_id, "count": count} + ) + + # Capture revenue + total_revenue = await capture_booked_revenue(db, stay_date, vat_rate, included_categories) + db.execute( + text(f""" + INSERT INTO revenue_pace (stay_date, {bracket_col}, updated_at) + VALUES (:stay_date, :revenue, NOW()) + ON CONFLICT (stay_date) DO UPDATE + SET {bracket_col} = :revenue, updated_at = NOW() + """), + {"stay_date": stay_date, "revenue": float(total_revenue)} + ) + + gap_updates += 1 + + logger.info(f"Filled {gap_updates} gap dates for category pace and revenue pace") + + +async def backfill_pace_v2(db=None): + """ + Backfill historical pace v2 data using booking_placed timestamps. + + Reconstructs what category counts and revenue would have been at each lead time + for historical dates. + """ + import sys + print("[PACE-V2-BACKFILL] Starting backfill...", flush=True) + + close_db = False + if db is None: + db = next(iter([SyncSessionLocal()])) + close_db = True + + try: + # Get VAT rate + vat_result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ).fetchone() + vat_rate = Decimal(vat_result.config_value) if vat_result and vat_result.config_value else Decimal('0.20') + + # Get included categories + cat_result = db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + print("[PACE-V2-BACKFILL] No included categories found", flush=True) + return + + # Get all unique stay dates from bookings_stats + result = db.execute( + text(""" + SELECT date as stay_date + FROM newbook_bookings_stats + WHERE date >= CURRENT_DATE - INTERVAL '2 years' + ORDER BY date + """) + ) + stay_dates = [row.stay_date for row in result.fetchall()] + print(f"[PACE-V2-BACKFILL] Found {len(stay_dates)} dates to process", flush=True) + + today = date.today() + + for i, stay_date in enumerate(stay_dates): + if i % 100 == 0: + print(f"[PACE-V2-BACKFILL] Processing: {i}/{len(stay_dates)} dates...", flush=True) + db.commit() + + await backfill_pace_v2_for_date(db, stay_date, today, vat_rate, included_categories) + + db.commit() + print(f"[PACE-V2-BACKFILL] Complete: {len(stay_dates)} dates processed", flush=True) + + except Exception as e: + print(f"[PACE-V2-BACKFILL] FAILED: {e}", flush=True) + db.rollback() + raise + finally: + if close_db: + db.close() + + +async def backfill_pace_v2_for_date( + db, + stay_date: date, + today: date, + vat_rate: Decimal, + included_categories: List[str] +): + """ + Backfill pace v2 data for a single date using booking_placed timestamps. + """ + pace_category_values: Dict[str, Dict[str, int]] = {cat: {} for cat in included_categories} + pace_revenue_values: Dict[str, Decimal] = {} + + for interval in PACE_INTERVALS: + snapshot_date = stay_date - timedelta(days=interval) + + if snapshot_date > today: + continue # This snapshot hasn't happened yet + if snapshot_date < date(2020, 1, 1): + continue # Don't go too far back + + column_name = f"d{interval}" + + # Count per-category bookings that existed at snapshot_date + result = db.execute( + text(""" + SELECT category_id, COUNT(*) as count + FROM newbook_bookings_data + WHERE arrival_date <= :stay_date + AND departure_date > :stay_date + AND status IN :valid_statuses + AND category_id IN :categories + AND booking_placed IS NOT NULL + AND booking_placed::date <= :snapshot_date + GROUP BY category_id + """), + { + "stay_date": stay_date, + "valid_statuses": VALID_STATUSES, + "categories": tuple(included_categories), + "snapshot_date": snapshot_date + } + ) + + for row in result.fetchall(): + pace_category_values[row.category_id][column_name] = row.count + + # Calculate revenue that was booked at snapshot_date + result = db.execute( + text(""" + SELECT raw_json + FROM newbook_bookings_data + WHERE arrival_date <= :stay_date + AND departure_date > :stay_date + AND status IN :valid_statuses + AND category_id IN :categories + AND booking_placed IS NOT NULL + AND booking_placed::date <= :snapshot_date + """), + { + "stay_date": stay_date, + "valid_statuses": VALID_STATUSES, + "categories": tuple(included_categories), + "snapshot_date": snapshot_date + } + ) + + total_revenue = Decimal('0') + for row in result.fetchall(): + if row.raw_json: + revenue = get_rate_for_date(row.raw_json, stay_date, vat_rate) + total_revenue += revenue + + pace_revenue_values[column_name] = total_revenue + + # Upsert category pace values + for category_id, columns in pace_category_values.items(): + if not columns: + continue + + col_names = list(columns.keys()) + set_clauses = ", ".join([f"{col} = :{col}" for col in col_names]) + insert_cols = ", ".join(col_names) + insert_vals = ", ".join([f":{col}" for col in col_names]) + + db.execute( + text(f""" + INSERT INTO category_booking_pace (arrival_date, category_id, {insert_cols}, updated_at) + VALUES (:stay_date, :category_id, {insert_vals}, NOW()) + ON CONFLICT (arrival_date, category_id) DO UPDATE SET + {set_clauses}, updated_at = NOW() + """), + {"stay_date": stay_date, "category_id": category_id, **columns} + ) + + # Upsert revenue pace values + if pace_revenue_values: + col_names = list(pace_revenue_values.keys()) + float_values = {k: float(v) for k, v in pace_revenue_values.items()} + set_clauses = ", ".join([f"{col} = :{col}" for col in col_names]) + insert_cols = ", ".join(col_names) + insert_vals = ", ".join([f":{col}" for col in col_names]) + + db.execute( + text(f""" + INSERT INTO revenue_pace (stay_date, {insert_cols}, updated_at) + VALUES (:stay_date, {insert_vals}, NOW()) + ON CONFLICT (stay_date) DO UPDATE SET + {set_clauses}, updated_at = NOW() + """), + {"stay_date": stay_date, **float_values} + ) diff --git a/backend/jobs/pickup_snapshot.py b/backend/jobs/pickup_snapshot.py new file mode 100644 index 0000000..50f5495 --- /dev/null +++ b/backend/jobs/pickup_snapshot.py @@ -0,0 +1,208 @@ +""" +Pickup snapshot job - captures daily on-the-books values +Used by the pickup model for pace comparison + +Prior year comparison uses 364 days (52 weeks) for day-of-week alignment: +- Monday compares to Monday +- Saturday compares to Saturday +""" +import logging +from datetime import date, timedelta + +from sqlalchemy import text +from database import SyncSessionLocal +from utils.time_alignment import get_prior_year_daily, SQL_PRIOR_YEAR_OFFSET + +logger = logging.getLogger(__name__) + + +async def run_pickup_snapshot(): + """ + Capture daily on-the-books snapshot for future dates. + Stores OTB values at various lead times for pickup model. + """ + logger.info("Starting pickup snapshot capture") + + db = next(iter([SyncSessionLocal()])) + snapshot_date = date.today() + + # Overflow room category (category_id=5) is used for chargeable no-shows/cancellations + # and should be excluded from room night counts + overflow_category_id = '5' + + try: + # Capture OTB for next 365 days (extended from 60 for longer-term forecasting) + for days_out in range(1, 366): + stay_date = snapshot_date + timedelta(days=days_out) + + # Get total available rooms for this date from occupancy report + rooms_result = db.execute( + text(""" + SELECT COALESCE(SUM(available), 25) as total_rooms + FROM newbook_occupancy_report + WHERE date = :stay_date + """), + {"stay_date": stay_date} + ).fetchone() + total_rooms = rooms_result.total_rooms if rooms_result and rooms_result.total_rooms else 25 + + # Get hotel occupancy OTB (count rooms on the books for this stay date) + # EXCLUDES overflow category (chargeable no-shows) + hotel_result = db.execute( + text(""" + SELECT COUNT(DISTINCT newbook_id) as bookings, + SUM(CASE WHEN LOWER(status) IN ('confirmed', 'provisional', 'unconfirmed', 'arrived') THEN 1 ELSE 0 END) as active_bookings + FROM newbook_bookings + WHERE arrival_date <= :stay_date AND departure_date > :stay_date + AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist') + AND (category_id IS NULL OR category_id != :overflow_cat) + """), + {"stay_date": stay_date, "overflow_cat": overflow_category_id} + ) + hotel_row = hotel_result.fetchone() + hotel_otb = hotel_row.active_bookings or 0 + + # Get dinner covers OTB + dinner_result = db.execute( + text(""" + SELECT COUNT(*) as bookings, COALESCE(SUM(covers), 0) as total_covers + FROM resos_bookings + WHERE booking_date = :stay_date + AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show') + AND booking_time >= '15:00' + """), + {"stay_date": stay_date} + ) + dinner_row = dinner_result.fetchone() + dinner_otb = dinner_row.total_covers or 0 + + # Get lunch covers OTB + lunch_result = db.execute( + text(""" + SELECT COUNT(*) as bookings, COALESCE(SUM(covers), 0) as total_covers + FROM resos_bookings + WHERE booking_date = :stay_date + AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show') + AND booking_time < '15:00' + """), + {"stay_date": stay_date} + ) + lunch_row = lunch_result.fetchone() + lunch_otb = lunch_row.total_covers or 0 + + # Get prior year comparison data (same day of week, exactly 52 weeks ago) + # Uses SQL_PRIOR_YEAR_OFFSET (364 days = 52 weeks) for Mon→Mon, Sat→Sat alignment + prior_year_stay_date = get_prior_year_daily(stay_date) + prior_year_snapshot_date = get_prior_year_daily(snapshot_date) # Same lead time last year + + # Calculate prior year hotel OTB at same lead time using booking_placed + # EXCLUDES overflow category + prior_hotel_otb_result = db.execute( + text(""" + SELECT COUNT(DISTINCT newbook_id) as otb_count + FROM newbook_bookings + WHERE arrival_date <= :prior_stay_date + AND departure_date > :prior_stay_date + AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist') + AND (raw_json->>'booking_placed')::timestamp <= :prior_snapshot_date + AND (category_id IS NULL OR category_id != :overflow_cat) + """), + {"prior_stay_date": prior_year_stay_date, "prior_snapshot_date": prior_year_snapshot_date, "overflow_cat": overflow_category_id} + ).fetchone() + prior_hotel_otb = prior_hotel_otb_result.otb_count if prior_hotel_otb_result else None + + # Store snapshots for each metric + # For hotel_occupancy_pct: store room count, convert to % for otb_value + # For hotel_room_nights: store raw room count (no conversion) + # For restaurant: store cover counts directly + for metric_type, otb_raw, otb_bookings_count in [ + ('hotel_occupancy_pct', hotel_otb, hotel_row.bookings), + ('hotel_room_nights', hotel_otb, hotel_row.bookings), # Same count, stored as-is + ('resos_dinner_covers', dinner_otb, dinner_row.bookings), + ('resos_lunch_covers', lunch_otb, lunch_row.bookings) + ]: + # Convert to percentage for occupancy metric, keep raw counts for others + if metric_type == 'hotel_occupancy_pct': + otb_value = (otb_raw / total_rooms) * 100 if total_rooms > 0 else 0 + # Use 'is not None' check - 0 is valid data (no bookings at that lead time) + prior_otb_value = (prior_hotel_otb / total_rooms) * 100 if prior_hotel_otb is not None and total_rooms > 0 else None + elif metric_type == 'hotel_room_nights': + otb_value = otb_raw # Raw room count + prior_otb_value = prior_hotel_otb # Raw room count from prior year + else: + otb_value = otb_raw + prior_otb_value = None # Will try historical snapshots below + + # Get prior year ACTUAL from daily_metrics (the final outcome) + prior_final_result = db.execute( + text(""" + SELECT actual_value + FROM daily_metrics + WHERE date = :prior_date AND metric_code = :metric + """), + {"prior_date": prior_year_stay_date, "metric": metric_type} + ).fetchone() + prior_final = float(prior_final_result.actual_value) if prior_final_result and prior_final_result.actual_value else None + + # Use reconstructed prior year OTB for hotel metrics, or try historical snapshots + if metric_type in ('hotel_occupancy_pct', 'hotel_room_nights') and prior_otb_value is not None: + prior_otb = prior_otb_value + else: + # For restaurant metrics, fall back to historical snapshots + prior_otb_result = db.execute( + text(""" + SELECT otb_value + FROM pickup_snapshots + WHERE stay_date = :prior_stay_date + AND metric_type = :metric + AND days_out = :days_out + ORDER BY snapshot_date DESC LIMIT 1 + """), + {"prior_stay_date": prior_year_stay_date, "metric": metric_type, "days_out": days_out} + ).fetchone() + # Use 'is not None' - 0 is valid OTB data + prior_otb = float(prior_otb_result.otb_value) if prior_otb_result and prior_otb_result.otb_value is not None else None + + # Calculate pace vs prior year if we have comparison data + pace_pct = None + if prior_otb and prior_otb > 0: + pace_pct = ((otb_value - prior_otb) / prior_otb) * 100 + + db.execute( + text(""" + INSERT INTO pickup_snapshots ( + snapshot_date, stay_date, days_out, metric_type, + otb_value, otb_bookings, prior_year_otb, prior_year_final, + pace_vs_prior_pct, created_at + ) VALUES ( + :snapshot_date, :stay_date, :days_out, :metric_type, + :otb_value, :otb_bookings, :prior_year_otb, :prior_year_final, + :pace_pct, NOW() + ) + ON CONFLICT (snapshot_date, stay_date, metric_type) DO UPDATE SET + otb_value = :otb_value, + prior_year_otb = :prior_year_otb, + pace_vs_prior_pct = :pace_pct + """), + { + "snapshot_date": snapshot_date, + "stay_date": stay_date, + "days_out": days_out, + "metric_type": metric_type, + "otb_value": round(otb_value, 2), # % for hotel, covers for restaurant + "otb_bookings": otb_raw, # Raw count (rooms or covers) + "prior_year_otb": round(prior_otb, 2) if prior_otb is not None else None, + "prior_year_final": prior_final, + "pace_pct": round(pace_pct, 2) if pace_pct else None + } + ) + + db.commit() + logger.info(f"Pickup snapshot completed for {snapshot_date}") + + except Exception as e: + logger.error(f"Pickup snapshot failed: {e}") + db.rollback() + raise + finally: + db.close() diff --git a/backend/jobs/resos_aggregation.py b/backend/jobs/resos_aggregation.py new file mode 100644 index 0000000..8112365 --- /dev/null +++ b/backend/jobs/resos_aggregation.py @@ -0,0 +1,476 @@ +""" +Resos Bookings Aggregation Job +Aggregates resos_bookings_data into: +- resos_bookings_stats: daily aggregated stats with period/source breakdowns +- resos_booking_pace: lead-time snapshots for pickup forecasting (3 types) +""" +import json +import logging +from datetime import date, datetime, timedelta +from typing import Set, Dict, Any, Optional, Tuple, List +from collections import defaultdict + +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + +# Valid booking statuses for aggregation +# Note: Resos uses 'approved' for confirmed future reservations, 'left' for completed meals +VALID_STATUSES = ('approved', 'arrived', 'seated', 'left') + +# All tracked pace intervals (same as Newbook) +PACE_INTERVALS = [ + # Monthly (months 7-12) + 365, 330, 300, 270, 240, 210, + # Weekly (weeks 5-25) + 177, 170, 163, 156, 149, 142, 135, 128, 121, 114, + 107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37, + # Daily (days 0-30) + 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 +] + + +def parse_group_exclude_field(group_exclude_field: Optional[str], primary_booking_number: Optional[str]) -> Tuple[List[str], List[str]]: + """ + Parse group_exclude_field to extract linked bookings and exclude markers. + Returns: (all_booking_numbers, exclude_numbers) + """ + all_booking_numbers = [] + exclude_numbers = [] + + # Always include primary booking number + if primary_booking_number: + all_booking_numbers.append(primary_booking_number) + + if not group_exclude_field: + return all_booking_numbers, exclude_numbers + + # Parse comma-separated entries + parts = group_exclude_field.split(',') + for part in parts: + part = part.strip() + + if part.upper().startswith('NOT-#'): + # Exclude marker: NOT-#56748 → NB56748 + booking_num = part[5:] # Remove "NOT-#" + exclude_numbers.append(f"NB{booking_num}") + + elif part.startswith('#'): + # Additional booking: #12346 → NB12346 + booking_num = part[1:] # Remove "#" + all_booking_numbers.append(f"NB{booking_num}") + + return all_booking_numbers, exclude_numbers + + +async def aggregate_resos_bookings(triggered_by: str = "manual"): + """ + Aggregate Resos bookings into resos_bookings_stats. + + Flow: + 1. Find bookings changed since last_resos_aggregation_at + 2. Calculate affected dates + 3. Reaggregate each affected date + 4. Update booking pace table (3 types) + 5. Update last_resos_aggregation_at + """ + logger.info(f"Starting Resos bookings aggregation (triggered_by={triggered_by})") + + db = SyncSessionLocal() + + try: + # Get last aggregation timestamp + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'last_resos_aggregation_at'") + ) + row = result.fetchone() + if row and row.config_value: + try: + last_ts = datetime.fromisoformat(row.config_value) + except ValueError: + last_ts = datetime.min + else: + last_ts = datetime.min + + logger.info(f"Last aggregation: {last_ts}") + + # Find bookings changed since last aggregation + result = db.execute( + text(""" + SELECT resos_id, booking_date + FROM resos_bookings_data + WHERE fetched_at > :last_ts + """), + {"last_ts": last_ts} + ) + changed_bookings = result.fetchall() + + if not changed_bookings: + logger.info("No changed bookings to aggregate") + # Still update pace table + await update_resos_booking_pace(db) + db.commit() + + # Update timestamp + db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, updated_at) + VALUES ('last_resos_aggregation_at', :now, NOW()) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :now, + updated_at = NOW() + """), + {"now": datetime.now().isoformat()} + ) + db.commit() + return + + logger.info(f"Found {len(changed_bookings)} changed bookings") + + # Calculate affected dates + affected_dates: Set[date] = set() + for booking in changed_bookings: + if booking.booking_date: + affected_dates.add(booking.booking_date) + + logger.info(f"Reaggregating {len(affected_dates)} affected dates") + + # Aggregate each affected date + for target_date in sorted(affected_dates): + await aggregate_date(db, target_date) + + # Update booking pace table (3 types) + await update_resos_booking_pace(db) + + # Update last aggregation timestamp + db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, updated_at) + VALUES ('last_resos_aggregation_at', :now, NOW()) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :now, + updated_at = NOW() + """), + {"now": datetime.now().isoformat()} + ) + + db.commit() + logger.info(f"Resos bookings aggregation completed: {len(affected_dates)} dates processed") + + except Exception as e: + logger.error(f"Resos bookings aggregation failed: {e}", exc_info=True) + db.rollback() + raise + finally: + db.close() + + +async def aggregate_date(db, target_date: date): + """ + Aggregate all Resos bookings for a specific date into resos_bookings_stats. + """ + # Get all valid bookings for this date + result = db.execute( + text(""" + SELECT + resos_id, + period_type, + covers, + source, + opening_hour_id, + is_hotel_guest, + is_dbb, + is_package, + total_guests, + hotel_booking_number, + group_exclude_field + FROM resos_bookings_data + WHERE booking_date = :target_date + AND status IN :valid_statuses + """), + {"target_date": target_date, "valid_statuses": VALID_STATUSES} + ) + bookings = result.fetchall() + + # Initialize counters + breakfast_covers = 0 + lunch_covers = 0 + afternoon_covers = 0 + dinner_covers = 0 + other_covers = 0 + + breakfast_bookings = 0 + lunch_bookings = 0 + afternoon_bookings = 0 + dinner_bookings = 0 + other_bookings = 0 + + hotel_guest_covers = 0 + non_hotel_guest_covers = 0 + dbb_covers = 0 + package_covers = 0 + + covers_by_source: Dict[str, int] = defaultdict(int) + covers_by_period: Dict[str, Dict[str, Any]] = {} + + total_party_sizes = [] + party_sizes_by_period: Dict[str, list] = defaultdict(list) + + # Build hotel_booking_numbers mapping + hotel_booking_numbers: Dict[str, str] = {} # hotel_booking_number -> resos_id + bookings_with_hotel_link = 0 + + for booking in bookings: + period = booking.period_type or 'other' + covers = booking.covers or 0 + source = booking.source or 'unknown' + opening_hour_id = booking.opening_hour_id + resos_id = booking.resos_id + + # Count by period + if period == 'breakfast': + breakfast_covers += covers + breakfast_bookings += 1 + elif period == 'lunch': + lunch_covers += covers + lunch_bookings += 1 + elif period == 'afternoon': + afternoon_covers += covers + afternoon_bookings += 1 + elif period == 'dinner': + dinner_covers += covers + dinner_bookings += 1 + else: + other_covers += covers + other_bookings += 1 + + # Count by source + covers_by_source[source] += covers + + # Count by period (detailed) + if opening_hour_id: + if opening_hour_id not in covers_by_period: + covers_by_period[opening_hour_id] = { + "period_type": period, + "covers": 0, + "bookings": 0 + } + covers_by_period[opening_hour_id]["covers"] += covers + covers_by_period[opening_hour_id]["bookings"] += 1 + + # Count business segments + if booking.is_hotel_guest: + hotel_guest_covers += covers + else: + non_hotel_guest_covers += covers + + if booking.is_dbb: + dbb_covers += covers + if booking.is_package: + package_covers += covers + + # Track party sizes + if covers > 0: + total_party_sizes.append(covers) + party_sizes_by_period[period].append(covers) + + # Build hotel booking numbers mapping + all_booking_numbers, _ = parse_group_exclude_field( + booking.group_exclude_field, + booking.hotel_booking_number + ) + + if all_booking_numbers: + bookings_with_hotel_link += 1 + for hotel_number in all_booking_numbers: + hotel_booking_numbers[hotel_number] = resos_id + + # Calculate averages + avg_party_size = sum(total_party_sizes) / len(total_party_sizes) if total_party_sizes else None + + avg_by_period = {} + for period, sizes in party_sizes_by_period.items(): + avg_by_period[period] = sum(sizes) / len(sizes) if sizes else None + + total_covers = breakfast_covers + lunch_covers + afternoon_covers + dinner_covers + other_covers + total_bookings = breakfast_bookings + lunch_bookings + afternoon_bookings + dinner_bookings + other_bookings + distinct_hotel_bookings = len(hotel_booking_numbers) + + # Upsert stats + db.execute( + text(""" + INSERT INTO resos_bookings_stats ( + date, + breakfast_covers, lunch_covers, afternoon_covers, dinner_covers, other_covers, total_covers, + breakfast_bookings, lunch_bookings, afternoon_bookings, dinner_bookings, other_bookings, total_bookings, + covers_by_source, covers_by_period, + hotel_guest_covers, non_hotel_guest_covers, dbb_covers, package_covers, + hotel_booking_numbers, distinct_hotel_bookings, bookings_with_hotel_link, + avg_party_size, avg_party_size_by_period, + aggregated_at + ) VALUES ( + :date, + :breakfast_covers, :lunch_covers, :afternoon_covers, :dinner_covers, :other_covers, :total_covers, + :breakfast_bookings, :lunch_bookings, :afternoon_bookings, :dinner_bookings, :other_bookings, :total_bookings, + :covers_by_source, :covers_by_period, + :hotel_guest_covers, :non_hotel_guest_covers, :dbb_covers, :package_covers, + :hotel_booking_numbers, :distinct_hotel_bookings, :bookings_with_hotel_link, + :avg_party_size, :avg_party_size_by_period, + NOW() + ) + ON CONFLICT (date) DO UPDATE SET + breakfast_covers = :breakfast_covers, + lunch_covers = :lunch_covers, + afternoon_covers = :afternoon_covers, + dinner_covers = :dinner_covers, + other_covers = :other_covers, + total_covers = :total_covers, + breakfast_bookings = :breakfast_bookings, + lunch_bookings = :lunch_bookings, + afternoon_bookings = :afternoon_bookings, + dinner_bookings = :dinner_bookings, + other_bookings = :other_bookings, + total_bookings = :total_bookings, + covers_by_source = :covers_by_source, + covers_by_period = :covers_by_period, + hotel_guest_covers = :hotel_guest_covers, + non_hotel_guest_covers = :non_hotel_guest_covers, + dbb_covers = :dbb_covers, + package_covers = :package_covers, + hotel_booking_numbers = :hotel_booking_numbers, + distinct_hotel_bookings = :distinct_hotel_bookings, + bookings_with_hotel_link = :bookings_with_hotel_link, + avg_party_size = :avg_party_size, + avg_party_size_by_period = :avg_party_size_by_period, + aggregated_at = NOW() + """), + { + "date": target_date, + "breakfast_covers": breakfast_covers, + "lunch_covers": lunch_covers, + "afternoon_covers": afternoon_covers, + "dinner_covers": dinner_covers, + "other_covers": other_covers, + "total_covers": total_covers, + "breakfast_bookings": breakfast_bookings, + "lunch_bookings": lunch_bookings, + "afternoon_bookings": afternoon_bookings, + "dinner_bookings": dinner_bookings, + "other_bookings": other_bookings, + "total_bookings": total_bookings, + "covers_by_source": json.dumps(dict(covers_by_source)), + "covers_by_period": json.dumps(covers_by_period), + "hotel_guest_covers": hotel_guest_covers, + "non_hotel_guest_covers": non_hotel_guest_covers, + "dbb_covers": dbb_covers, + "package_covers": package_covers, + "hotel_booking_numbers": json.dumps(hotel_booking_numbers), + "distinct_hotel_bookings": distinct_hotel_bookings, + "bookings_with_hotel_link": bookings_with_hotel_link, + "avg_party_size": avg_party_size, + "avg_party_size_by_period": json.dumps(avg_by_period) + } + ) + db.commit() + + +async def update_resos_booking_pace(db): + """ + Update resos_booking_pace table with lead-time snapshots. + Creates 3 rows per date: total, resident, non_resident + """ + logger.info("Updating Resos booking pace table...") + + today = date.today() + + # Process dates from -30 to +365 (historical + forecast window) + from_date = today - timedelta(days=30) + to_date = today + timedelta(days=365) + + current = from_date + while current <= to_date: + # Calculate pace for each type + 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': + # All valid bookings + result = db.execute( + text(""" + SELECT COALESCE(SUM(covers), 0) as total_covers + FROM resos_bookings_data + WHERE booking_date = :target_date + AND status IN :valid_statuses + AND booking_placed <= :snapshot_date + """), + { + "target_date": current, + "valid_statuses": VALID_STATUSES, + "snapshot_date": snapshot_date + } + ) + elif pace_type == 'resident': + # Hotel guests only + result = db.execute( + text(""" + SELECT COALESCE(SUM(covers), 0) as total_covers + FROM resos_bookings_data + WHERE booking_date = :target_date + AND status IN :valid_statuses + AND booking_placed <= :snapshot_date + AND is_hotel_guest = true + """), + { + "target_date": current, + "valid_statuses": VALID_STATUSES, + "snapshot_date": snapshot_date + } + ) + else: # non_resident + # Non-hotel guests + result = db.execute( + text(""" + SELECT COALESCE(SUM(covers), 0) as total_covers + FROM resos_bookings_data + WHERE booking_date = :target_date + AND status IN :valid_statuses + AND booking_placed <= :snapshot_date + AND (is_hotel_guest = false OR is_hotel_guest IS NULL) + """), + { + "target_date": current, + "valid_statuses": VALID_STATUSES, + "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()]) + + 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} + ) + + current += timedelta(days=1) + + db.commit() + logger.info("Resos booking pace table updated (3 types: total, resident, non_resident)") diff --git a/backend/jobs/resos_bookings_sync.py b/backend/jobs/resos_bookings_sync.py new file mode 100644 index 0000000..f90af66 --- /dev/null +++ b/backend/jobs/resos_bookings_sync.py @@ -0,0 +1,397 @@ +""" +Resos Bookings Data Sync Job +Syncs restaurant bookings to resos_bookings_data table +Pattern: Replicates newbook bookings sync but adapted for Resos covers/stats +""" +import json +import logging +import base64 +from datetime import date, datetime, timedelta +from typing import Optional, Tuple, List, Dict, Any + +from sqlalchemy import text +from database import SyncSessionLocal +from services.resos_client import ResosClient + +logger = logging.getLogger(__name__) + +# Valid booking statuses for aggregation +# Note: Resos uses 'approved' for confirmed future reservations, 'left' for completed meals +VALID_STATUSES = ('approved', 'arrived', 'seated', 'left') + + +def get_config_value(db, key: str) -> Optional[str]: + """Get a configuration value from system_config table.""" + result = db.execute( + text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + if not row or not row.config_value: + return None + + # Decrypt if encrypted + if row.is_encrypted: + try: + return base64.b64decode(row.config_value.encode()).decode() + except Exception as e: + logger.warning(f"Failed to decrypt {key}: {e}") + return row.config_value + + return row.config_value + + +def load_resos_custom_field_mappings(db) -> Dict[str, Dict[str, Any]]: + """ + Load custom field mappings from resos_custom_field_mapping table. + Returns dict: {field_id: {"maps_to": "hotel_guest", "value_for_true": "Yes"}} + """ + result = db.execute(text(""" + SELECT field_id, maps_to, value_for_true + FROM resos_custom_field_mapping + WHERE maps_to != 'ignore' + """)) + mappings = {} + for row in result.fetchall(): + mappings[row.field_id] = { + "maps_to": row.maps_to, + "value_for_true": row.value_for_true + } + logger.info(f"Loaded {len(mappings)} custom field mappings") + return mappings + + +def load_resos_opening_hours_mappings(db) -> Dict[str, Dict[str, str]]: + """ + Load opening hours mappings from resos_opening_hours_mapping table. + Returns dict: {opening_hour_id: {"period_type": "dinner", "display_name": "..."}} + """ + result = db.execute(text(""" + SELECT opening_hour_id, period_type, display_name + FROM resos_opening_hours_mapping + WHERE period_type != 'ignore' + """)) + mappings = {} + for row in result.fetchall(): + mappings[row.opening_hour_id] = { + "period_type": row.period_type, + "display_name": row.display_name + } + logger.info(f"Loaded {len(mappings)} opening hours mappings") + return mappings + + +def parse_group_exclude_field(group_exclude_field: Optional[str], primary_booking_number: Optional[str]) -> Tuple[List[str], List[str]]: + """ + Parse group_exclude_field to extract linked bookings and exclude markers. + + Args: + group_exclude_field: Raw field like "#12346,#12347,NOT-#56748" + primary_booking_number: Primary booking from hotel_booking_number field + + Returns: + (all_booking_numbers, exclude_numbers) + + Example: + Input: "#12346,#12347,NOT-#56748", "NB12345" + Returns: (["NB12345", "NB12346", "NB12347"], ["NB56748"]) + """ + all_booking_numbers = [] + exclude_numbers = [] + + # Always include primary booking number + if primary_booking_number: + all_booking_numbers.append(primary_booking_number) + + if not group_exclude_field: + return all_booking_numbers, exclude_numbers + + # Parse comma-separated entries + parts = group_exclude_field.split(',') + for part in parts: + part = part.strip() + + if part.upper().startswith('NOT-#'): + # Exclude marker: NOT-#56748 → NB56748 + booking_num = part[5:] # Remove "NOT-#" + exclude_numbers.append(f"NB{booking_num}") + + elif part.startswith('#'): + # Additional booking: #12346 → NB12346 + booking_num = part[1:] # Remove "#" + all_booking_numbers.append(f"NB{booking_num}") + + return all_booking_numbers, exclude_numbers + + +def extract_custom_field_value(custom_fields: List[Dict[str, Any]], field_id: str, cf_mappings: Dict[str, Dict[str, Any]]) -> Optional[Any]: + """ + Extract value from Resos custom fields array. + + Args: + custom_fields: Array of custom field objects from Resos API + field_id: The field ID to look for + cf_mappings: Mapping configuration + + Returns: + Extracted value (boolean for hotel_guest/dbb/package, string for booking_number, etc.) + """ + if field_id not in cf_mappings: + return None + + mapping = cf_mappings[field_id] + maps_to = mapping["maps_to"] + value_for_true = mapping.get("value_for_true") + + # Find the field in custom_fields array + field_value = None + field_value_label = None + for cf in custom_fields: + cf_id = cf.get("id") or cf.get("_id") or cf.get("fieldId") + if cf_id == field_id: + field_value = cf.get("value") + field_value_label = cf.get("multipleChoiceValueName") or cf.get("value") + break + + if field_value is None and field_value_label is None: + return None + + # For boolean mappings (hotel_guest, dbb, package) + if maps_to in ("hotel_guest", "dbb", "package"): + if value_for_true: + return str(field_value_label) == str(value_for_true) + else: + # Auto-detect: check for "yes", "true", "1" + return str(field_value_label).lower() in ("yes", "true", "1") + + # For string mappings (booking_number, group_exclude, allergies) + return str(field_value) if field_value else str(field_value_label) if field_value_label else None + + +async def sync_resos_bookings_data( + from_date: date, + to_date: date, + triggered_by: str = "scheduler" +): + """ + Sync Resos bookings to resos_bookings_data. + + Date range: Historical -365 days + Forecast +365 days (daily sync) + PII handling: Remove guest details, store only aggregate covers + """ + logger.info(f"Starting Resos bookings sync from {from_date} to {to_date} (triggered by {triggered_by})") + + db = SyncSessionLocal() + + try: + # Log sync start + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by) + VALUES ('bookings_data', 'resos', NOW(), 'running', :from_date, :to_date, :triggered_by) + """), + {"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by} + ) + db.commit() + + # Load Resos API key + api_key = get_config_value(db, 'resos_api_key') + + if not api_key: + raise Exception("Resos API key not configured") + + # Load mappings + cf_mappings = load_resos_custom_field_mappings(db) + oh_mappings = load_resos_opening_hours_mappings(db) + + async with ResosClient(api_key=api_key) as client: + # Test connection + if not await client.test_connection(): + raise Exception("Resos connection failed") + + # Fetch bookings + logger.info(f"Fetching bookings from Resos API for {from_date} to {to_date}") + bookings = await client.get_bookings(from_date, to_date) + logger.info(f"Fetched {len(bookings)} bookings from Resos") + + records_created = 0 + records_updated = 0 + + for booking in bookings: + resos_id = booking.get("_id") + if not resos_id: + continue + + booking_date_str = booking.get("date") + booking_date_obj = date.fromisoformat(booking_date_str) if booking_date_str else None + + if not booking_date_obj: + continue + + # Extract opening hour ID and map to period type + opening_hour_id = booking.get("openingHourId") + period_type = None + if opening_hour_id and opening_hour_id in oh_mappings: + period_type = oh_mappings[opening_hour_id]["period_type"] + + # Extract custom fields using mappings + custom_fields = booking.get("customFields", []) + + is_hotel_guest = None + is_dbb = None + is_package = None + hotel_booking_number = None + group_exclude_field = None + + for field_id, mapping in cf_mappings.items(): + maps_to = mapping["maps_to"] + + if maps_to == "hotel_guest": + is_hotel_guest = extract_custom_field_value(custom_fields, field_id, cf_mappings) + elif maps_to == "dbb": + is_dbb = extract_custom_field_value(custom_fields, field_id, cf_mappings) + elif maps_to == "package": + is_package = extract_custom_field_value(custom_fields, field_id, cf_mappings) + elif maps_to == "booking_number": + hotel_booking_number = extract_custom_field_value(custom_fields, field_id, cf_mappings) + elif maps_to == "group_exclude": + group_exclude_field = extract_custom_field_value(custom_fields, field_id, cf_mappings) + + # Remove PII from raw JSON (remove guest object) + raw_booking = {k: v for k, v in booking.items() if k != "guest"} + raw_json_str = json.dumps(raw_booking) + + # Extract other booking details + covers = booking.get("people", 0) + status = booking.get("status") + source = booking.get("source") + booking_time_str = booking.get("time") + + # Parse table information + tables = booking.get("tables", []) + table_name = tables[0].get("name") if tables else None + table_area = None + if tables and tables[0].get("area"): + table_area = tables[0]["area"].get("name") + + # Parse booking placed timestamp + booking_placed_str = booking.get("createdAt") + booking_placed = None + if booking_placed_str: + try: + booking_placed = datetime.fromisoformat(booking_placed_str.replace('Z', '+00:00')) + except: + pass + + # Parse notes (sanitized - no PII) + notes_array = booking.get("restaurantNotes", []) + notes = ', '.join([str(note) for note in notes_array]) if notes_array else None + + # Check if record exists + existing = db.execute( + text("SELECT id FROM resos_bookings_data WHERE resos_id = :rid"), + {"rid": resos_id} + ).fetchone() + + # Upsert booking + db.execute( + text(""" + INSERT INTO resos_bookings_data ( + resos_id, booking_date, booking_time, opening_hour_id, period_type, + covers, status, source, table_name, table_area, + is_hotel_guest, is_dbb, is_package, hotel_booking_number, group_exclude_field, + total_guests, booking_placed, notes, raw_json, fetched_at + ) VALUES ( + :resos_id, :booking_date, :booking_time, :opening_hour_id, :period_type, + :covers, :status, :source, :table_name, :table_area, + :is_hotel_guest, :is_dbb, :is_package, :hotel_booking_number, :group_exclude_field, + :total_guests, :booking_placed, :notes, :raw_json, NOW() + ) + ON CONFLICT (resos_id) DO UPDATE SET + status = :status, + covers = :covers, + period_type = :period_type, + is_hotel_guest = COALESCE(:is_hotel_guest, resos_bookings_data.is_hotel_guest), + is_dbb = COALESCE(:is_dbb, resos_bookings_data.is_dbb), + is_package = COALESCE(:is_package, resos_bookings_data.is_package), + hotel_booking_number = COALESCE(:hotel_booking_number, resos_bookings_data.hotel_booking_number), + group_exclude_field = COALESCE(:group_exclude_field, resos_bookings_data.group_exclude_field), + raw_json = :raw_json, + fetched_at = NOW() + """), + { + "resos_id": resos_id, + "booking_date": booking_date_obj, + "booking_time": booking_time_str, + "opening_hour_id": opening_hour_id, + "period_type": period_type, + "covers": covers, + "status": status, + "source": source, + "table_name": table_name, + "table_area": table_area, + "is_hotel_guest": is_hotel_guest, + "is_dbb": is_dbb, + "is_package": is_package, + "hotel_booking_number": hotel_booking_number, + "group_exclude_field": group_exclude_field, + "total_guests": covers, # Same as covers for restaurants + "booking_placed": booking_placed, + "notes": notes, + "raw_json": raw_json_str + } + ) + + if existing: + records_updated += 1 + else: + records_created += 1 + + db.commit() + + # Update sync log + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'success', + records_fetched = :fetched, records_created = :created, records_updated = :updated + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'resos' AND sync_type = 'bookings_data' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"fetched": len(bookings), "created": records_created, "updated": records_updated} + ) + db.commit() + + logger.info(f"Resos bookings sync completed: {records_created} created, {records_updated} updated") + + # Trigger aggregation + logger.info("Triggering Resos bookings aggregation...") + try: + import asyncio + from jobs.resos_aggregation import aggregate_resos_bookings + await aggregate_resos_bookings(triggered_by=triggered_by) + logger.info("Resos bookings aggregation completed") + except Exception as agg_error: + logger.warning(f"Resos aggregation failed (non-fatal): {agg_error}") + + except Exception as e: + logger.error(f"Resos bookings sync failed: {e}", exc_info=True) + db.execute( + text(""" + UPDATE sync_log + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE id = ( + SELECT id FROM sync_log + WHERE source = 'resos' AND sync_type = 'bookings_data' AND status = 'running' + ORDER BY started_at DESC LIMIT 1 + ) + """), + {"error": str(e)[:500]} + ) + db.commit() + raise + finally: + db.close() diff --git a/backend/jobs/revenue_aggregation.py b/backend/jobs/revenue_aggregation.py new file mode 100644 index 0000000..c07448c --- /dev/null +++ b/backend/jobs/revenue_aggregation.py @@ -0,0 +1,153 @@ +""" +Revenue aggregation - consolidates earned revenue by department +""" +import logging +from datetime import datetime +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + + +def get_config_value(db, key: str) -> str | None: + """Get a config value from system_config""" + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + return row.config_value if row else None + + +def set_config_value(db, key: str, value: str): + """Set a config value in system_config""" + db.execute( + text(""" + INSERT INTO system_config (config_key, config_value, updated_at) + VALUES (:key, :value, NOW()) + ON CONFLICT (config_key) DO UPDATE SET + config_value = :value, + updated_at = NOW() + """), + {"key": key, "value": value} + ) + + +async def aggregate_revenue(since_timestamp: str = None): + """ + Aggregate earned revenue data by department into newbook_net_revenue_data. + + - Joins newbook_earned_revenue_data with newbook_gl_accounts to get department + - Sums net amounts by date and department + - Only processes dates with data fetched since last aggregation (or all if first run) + + Args: + since_timestamp: Optional timestamp to process data from (ISO format) + If not provided, uses last_revenue_aggregation_at config + """ + logger.info("Starting revenue aggregation...") + + db = SyncSessionLocal() + try: + # Get last aggregation time if not provided + if since_timestamp is None: + since_timestamp = get_config_value(db, 'last_revenue_aggregation_at') + + # Find dates with new/updated data + if since_timestamp: + logger.info(f"Aggregating revenue data updated since {since_timestamp}") + result = db.execute( + text(""" + SELECT DISTINCT date + FROM newbook_earned_revenue_data + WHERE fetched_at > :since + ORDER BY date + """), + {"since": since_timestamp} + ) + else: + logger.info("Aggregating all revenue data (first run)") + result = db.execute( + text(""" + SELECT DISTINCT date + FROM newbook_earned_revenue_data + ORDER BY date + """) + ) + + dates_to_process = [row.date for row in result.fetchall()] + + if not dates_to_process: + logger.info("No new revenue data to aggregate") + return {"dates_processed": 0, "message": "No new data"} + + logger.info(f"Found {len(dates_to_process)} dates to aggregate") + + # Aggregate each date + for target_date in dates_to_process: + # Get totals by department for this date + result = db.execute( + text(""" + SELECT + COALESCE(g.department, 'other') as department, + SUM(e.amount_net) as total_net + FROM newbook_earned_revenue_data e + LEFT JOIN newbook_gl_accounts g ON e.gl_code = g.gl_code + WHERE e.date = :date + GROUP BY g.department + """), + {"date": target_date} + ) + + totals = {"accommodation": 0, "dry": 0, "wet": 0} + for row in result.fetchall(): + if row.department in totals: + totals[row.department] = float(row.total_net or 0) + + # Upsert into newbook_net_revenue_data + db.execute( + text(""" + INSERT INTO newbook_net_revenue_data (date, accommodation, dry, wet, aggregated_at) + VALUES (:date, :accommodation, :dry, :wet, NOW()) + ON CONFLICT (date) DO UPDATE SET + accommodation = :accommodation, + dry = :dry, + wet = :wet, + aggregated_at = NOW() + """), + { + "date": target_date, + "accommodation": round(totals["accommodation"], 2), + "dry": round(totals["dry"], 2), + "wet": round(totals["wet"], 2) + } + ) + + db.commit() + + # Update last aggregation timestamp + set_config_value(db, 'last_revenue_aggregation_at', datetime.now().isoformat()) + db.commit() + + logger.info(f"Revenue aggregation complete: {len(dates_to_process)} dates") + return { + "dates_processed": len(dates_to_process), + "message": f"Aggregated {len(dates_to_process)} dates" + } + + except Exception as e: + logger.error(f"Revenue aggregation failed: {e}") + db.rollback() + raise + finally: + db.close() + + +async def backfill_revenue_aggregation(): + """ + Backfill all historical revenue data. + Forces re-aggregation of all dates regardless of last run time. + """ + logger.info("Starting revenue backfill aggregation...") + # Pass epoch time to force processing all data + return await aggregate_revenue(since_timestamp="1970-01-01T00:00:00") diff --git a/backend/jobs/scrape_booking_rates.py b/backend/jobs/scrape_booking_rates.py new file mode 100644 index 0000000..e9371c5 --- /dev/null +++ b/backend/jobs/scrape_booking_rates.py @@ -0,0 +1,165 @@ +""" +Scheduled Booking.com Rate Scraping Job + +Priority-based scheduling for 365-day coverage (all queued daily): +- High (priority 10): next 30 days +- Medium (priority 5): days 31-180 +- Low (priority 2): days 181-365 + +Queue processes in priority order. If rate-limited/blocked, lower priority +dates remain queued for the next run. + +Uses a queue-based approach: +1. Populate the queue with dates and priorities +2. Process the queue in priority order +3. Failed dates are retried (up to 3 attempts) +4. On blocking, the queue pauses and resumes after cooldown + +Schedule: Daily at configurable time (default 05:30) +""" +import asyncio +import logging +from datetime import date, timedelta + +from sqlalchemy import text +from database import SyncSessionLocal +from services.booking_scraper import ( + populate_queue, + process_queue, + clear_old_queue_items, + cleanup_stale_batches, + get_scrape_config, +) + +logger = logging.getLogger(__name__) + +# Priority levels (higher = processed first) +PRIORITY_HIGH = 10 # 0-30 days +PRIORITY_MEDIUM = 5 # 31-180 days +PRIORITY_LOW = 2 # 181-365 days + + +def get_high_priority_dates() -> list[date]: + """High priority: today + 30 days.""" + today = date.today() + return [today + timedelta(days=i) for i in range(31)] + + +def get_medium_priority_dates() -> list[date]: + """Medium priority: days 31-180.""" + today = date.today() + return [today + timedelta(days=i) for i in range(31, 181)] + + +def get_low_priority_dates() -> list[date]: + """Low priority: days 181-365.""" + today = date.today() + return [today + timedelta(days=i) for i in range(181, 366)] + + +def compute_next_scrape_for_date(target_date: date) -> tuple[str, date | None]: + """ + For a target date, determine its priority tier and when it will next be scraped. + + Returns (tier, next_scrape_date) where tier is 'high'/'medium'/'low'/'none'. + All dates are queued daily, so next scrape is always today (or tomorrow if + today's run has passed). + """ + today = date.today() + offset = (target_date - today).days + + if offset < 0: + return ('none', None) + if offset > 365: + return ('none', None) + + # All tiers run daily - next scrape is today + if offset <= 30: + return ('high', today) + elif offset <= 180: + return ('medium', today) + else: + return ('low', today) + + +def run_scheduled_booking_scrape(): + """ + Main scheduled job: populate queue with today's dates, then process. + """ + db = SyncSessionLocal() + try: + # Check if scraper is enabled + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_enabled'") + ).fetchone() + if not result or result.config_value != 'true': + logger.debug("Scheduled booking scrape skipped (disabled)") + return + + if not get_scrape_config(db): + logger.warning("Scheduled booking scrape skipped (no location configured)") + return + + # Clean up stale running batches and old queue items + cleanup_stale_batches(db, max_age_minutes=120) + clear_old_queue_items(db, days=3) + + # Gather dates with priorities + high = get_high_priority_dates() + medium = get_medium_priority_dates() + low = get_low_priority_dates() + + priorities = {} + for d in high: + priorities[d] = PRIORITY_HIGH + for d in medium: + priorities[d] = max(priorities.get(d, 0), PRIORITY_MEDIUM) + for d in low: + priorities[d] = max(priorities.get(d, 0), PRIORITY_LOW) + + all_dates = sorted(priorities.keys()) + + if not all_dates: + logger.info("Scheduled booking scrape: no dates to scrape today") + return + + logger.info( + f"Scheduled booking scrape: queuing {len(all_dates)} dates " + f"(high={len(high)}, medium={len(medium)}, low={len(low)})" + ) + + # Populate queue + populate_queue(db, all_dates, priorities) + + # Process queue + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + result = loop.run_until_complete(process_queue(db)) + if result.get('success'): + logger.info( + f"Scheduled booking scrape completed: " + f"{result.get('dates_completed', 0)} dates, " + f"{result.get('rates_scraped', 0)} rates" + ) + elif result.get('blocked'): + logger.warning( + f"Scheduled booking scrape blocked: {result.get('block_reason')}. " + f"Completed {result.get('dates_completed', 0)} dates. " + f"Remaining dates stay queued for retry." + ) + else: + logger.error(f"Scheduled booking scrape failed: {result.get('error')}") + finally: + loop.close() + + except Exception as e: + logger.error(f"Scheduled booking scrape error: {e}", exc_info=True) + finally: + db.close() + + +async def run_scheduled_booking_scrape_async(): + """Async wrapper for APScheduler.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, run_scheduled_booking_scrape) diff --git a/backend/jobs/weekly_forecast_snapshot.py b/backend/jobs/weekly_forecast_snapshot.py new file mode 100644 index 0000000..78463b5 --- /dev/null +++ b/backend/jobs/weekly_forecast_snapshot.py @@ -0,0 +1,197 @@ +""" +Weekly Forecast Snapshot Job +Automatically creates blended forecast snapshots using MAPE-weighted model blending with 60/40 budget/prior year blend. +Uses the blended_tuned_weighted service for accuracy-optimized forecasts. +""" +import logging +import uuid +from datetime import date, datetime, timedelta +from sqlalchemy import text +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + + +def get_config_value(db, key: str, default: str = None) -> str: + """Get a config value from system_config""" + try: + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + if row and row.config_value: + return row.config_value + return default + except Exception as e: + logger.error(f"Error getting config {key}: {e}") + return default + + +def is_forecast_snapshot_enabled(db) -> bool: + """Check if automated forecast snapshots are enabled""" + value = get_config_value(db, "forecast_snapshot_enabled") + if value: + return value.lower() in ('true', '1', 'yes', 'enabled') + return False + + +def get_forecast_snapshot_days_ahead(db) -> int: + """Get number of days ahead to forecast""" + days_str = get_config_value(db, "forecast_snapshot_days_ahead", "90") + try: + return int(days_str) + except ValueError: + return 90 + + +async def run_weekly_forecast_snapshot(): + """ + Run weekly blended forecast snapshot using MAPE-weighted + 60/40 blend. + This is the single source of truth for forecast snapshots. + - Stage 1: MAPE-weighted blend of Prophet, XGBoost, CatBoost (+ Pickup for pace metrics) + - Stage 2: 60% model blend + 40% budget (revenue) or prior year (non-revenue) + """ + db = SyncSessionLocal() + run_id = str(uuid.uuid4()) + + try: + # Check if enabled + if not is_forecast_snapshot_enabled(db): + logger.info("Weekly forecast snapshot is disabled, skipping") + return + + days_ahead = get_forecast_snapshot_days_ahead(db) + forecast_from = date.today() + forecast_to = date.today() + timedelta(days=days_ahead) + + logger.info(f"Starting weekly blended forecast snapshot: {forecast_from} to {forecast_to}") + + # Log run start + db.execute( + text(""" + INSERT INTO forecast_runs ( + run_id, run_type, started_at, status, + forecast_from, forecast_to, models_run, triggered_by + ) VALUES ( + :run_id, 'scheduled', NOW(), 'running', + :forecast_from, :forecast_to, :models, :triggered_by + ) + """), + { + "run_id": run_id, + "forecast_from": forecast_from, + "forecast_to": forecast_to, + "models": '["blended"]', + "triggered_by": "forecast_snapshot" + } + ) + db.commit() + + # Get active metrics + result = db.execute( + text(""" + SELECT metric_code, metric_name + FROM forecast_metrics + WHERE is_active = TRUE + """) + ) + metrics = result.fetchall() + + # Import MAPE-weighted blended model with 60/40 budget blend + from services.forecasting.blended_tuned_weighted import run_blended_tuned_weighted_forecast + + # Run blended forecast for each metric + total_forecasts = 0 + for metric in metrics: + metric_code = metric.metric_code + try: + logger.info(f"Generating MAPE-weighted + 60/40 blended forecast for {metric_code}") + forecasts = await run_blended_tuned_weighted_forecast( + db=db, + metric_code=metric_code, + start_date=forecast_from, + end_date=forecast_to, + save_to_db=True, + run_id=run_id + # apply_60_40_blend defaults to True + ) + total_forecasts += len(forecasts) + logger.info(f"Generated {len(forecasts)} MAPE-weighted + 60/40 forecasts for {metric_code}") + except Exception as e: + logger.error(f"Blended forecast failed for {metric_code}: {e}") + db.rollback() + continue + + # Update run status to success + db.execute( + text(""" + UPDATE forecast_runs + SET completed_at = NOW(), status = 'success' + WHERE run_id = :run_id + """), + {"run_id": run_id} + ) + db.commit() + + # Log completion to sync_log + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, completed_at, status, records_created, triggered_by) + VALUES (:sync_type, :source, :started_at, :completed_at, :status, :records_created, :triggered_by) + """), + { + "sync_type": "forecast_snapshot", + "source": "blended_tuned_weighted", + "started_at": datetime.now(), + "completed_at": datetime.now(), + "status": "success", + "records_created": total_forecasts, + "triggered_by": "scheduler" + } + ) + db.commit() + + logger.info(f"Weekly forecast snapshot completed: {total_forecasts} total forecasts generated") + + except Exception as e: + logger.error(f"Weekly forecast snapshot failed: {e}") + + # Rollback and update run status + db.rollback() + try: + db.execute( + text(""" + UPDATE forecast_runs + SET completed_at = NOW(), status = 'failed', error_message = :error + WHERE run_id = :run_id + """), + {"run_id": run_id, "error": str(e)} + ) + db.commit() + except: + pass + + # Log error to sync_log + try: + db.execute( + text(""" + INSERT INTO sync_log (sync_type, source, started_at, completed_at, status, error_message, triggered_by) + VALUES (:sync_type, :source, :started_at, :completed_at, :status, :error_message, :triggered_by) + """), + { + "sync_type": "forecast_snapshot", + "source": "blended_tuned_weighted", + "started_at": datetime.now(), + "completed_at": datetime.now(), + "status": "error", + "error_message": str(e), + "triggered_by": "scheduler" + } + ) + db.commit() + except: + pass + raise + finally: + db.close() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..1d23bbc --- /dev/null +++ b/backend/main.py @@ -0,0 +1,138 @@ +""" +Forecasting Application — FastAPI Backend +Auth is handled by the central HNF stack cookie (hnf_session). +""" +import logging +import sys +from contextlib import asynccontextmanager + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[logging.StreamHandler(sys.stdout)] +) + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text + +from database import async_engine +from api import ( + forecast, sync, export, budget, accuracy, evolution, crossref, + explain, config, historical, resos, backtest, sync_bookings, + resos_sync, reports, special_dates, backup, public, bookability, + competitor_rates, ai_insights, +) +from scheduler import start_scheduler, shutdown_scheduler + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Apply database schema (idempotent CREATE TABLE IF NOT EXISTS) + try: + import os + from database import SyncSessionLocal + schema_path = os.path.join(os.path.dirname(__file__), 'schema.sql') + if os.path.exists(schema_path): + db = SyncSessionLocal() + try: + with open(schema_path) as f: + sql = f.read() + db.execute(text(sql)) + db.commit() + logging.getLogger(__name__).info("Schema applied successfully") + except Exception as e: + logging.getLogger(__name__).warning(f"Schema init failed: {e}") + db.rollback() + finally: + db.close() + except Exception as e: + logging.getLogger(__name__).warning(f"Schema load failed: {e}") + + # Startup: clean up stale scrape batches + try: + from services.booking_scraper import cleanup_stale_batches + from database import SyncSessionLocal + db = SyncSessionLocal() + try: + cleanup_stale_batches(db, max_age_minutes=10) + finally: + db.close() + except Exception as e: + logging.getLogger(__name__).warning(f"Stale batch cleanup failed: {e}") + + # Ensure ai_insights table exists + try: + from database import SyncSessionLocal + db = SyncSessionLocal() + try: + db.execute(text(""" + CREATE TABLE IF NOT EXISTS ai_insights ( + id SERIAL PRIMARY KEY, + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + insight_type VARCHAR(50) NOT NULL DEFAULT 'daily_summary', + content TEXT NOT NULL, + model VARCHAR(100), + input_tokens INTEGER, + output_tokens INTEGER, + data_snapshot JSONB, + triggered_by VARCHAR(50) DEFAULT 'scheduler' + ) + """)) + db.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_ai_insights_generated + ON ai_insights(generated_at DESC) + """)) + db.commit() + finally: + db.close() + except Exception as e: + logging.getLogger(__name__).warning(f"AI insights table creation failed: {e}") + + start_scheduler() + yield + shutdown_scheduler() + + +app = FastAPI( + title="Forecasting API", + description="Hotel & Restaurant Forecasting Service", + version="2.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# All routes — nginx strips /forecasting/api/ prefix before reaching here +app.include_router(forecast.router, prefix="/forecast", tags=["Forecasts"]) +app.include_router(sync.router, prefix="/sync", tags=["Data Sync"]) +app.include_router(export.router, prefix="/export", tags=["Exports"]) +app.include_router(budget.router, prefix="/budget", tags=["Budgets"]) +app.include_router(accuracy.router, prefix="/accuracy", tags=["Accuracy"]) +app.include_router(evolution.router, prefix="/evolution", tags=["Forecast Evolution"]) +app.include_router(crossref.router, prefix="/crossref", tags=["Cross-Reference"]) +app.include_router(explain.router, prefix="/explain", tags=["Explainability"]) +app.include_router(config.router, prefix="/config", tags=["Configuration"]) +app.include_router(historical.router, prefix="/historical", tags=["Historical Data"]) +app.include_router(resos.router, prefix="/resos", tags=["Resos Mapping"]) +app.include_router(backtest.router, prefix="/backtest", tags=["Backtesting"]) +app.include_router(sync_bookings.router, prefix="/sync", tags=["Data Sync"]) +app.include_router(resos_sync.router, prefix="/sync", tags=["Data Sync"]) +app.include_router(reports.router, prefix="/reports", tags=["Reports"]) +app.include_router(special_dates.router, prefix="/settings", tags=["Settings"]) +app.include_router(backup.router, prefix="/backup", tags=["Backup & Restore"]) +app.include_router(public.router, prefix="/public", tags=["Public API"]) +app.include_router(bookability.router, prefix="/bookability", tags=["Bookability"]) +app.include_router(competitor_rates.router, prefix="/competitor-rates", tags=["Competitor Rates"]) +app.include_router(ai_insights.router, prefix="/ai-insights", tags=["AI Insights"]) + + +@app.get("/health") +async def health_check(): + return {"status": "healthy", "service": "forecasting-api"} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..e6a4a77 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,24 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +sqlalchemy==2.0.25 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.6 +httpx==0.26.0 +pandas==2.2.0 +numpy==1.26.3 +prophet==1.1.4 +xgboost==2.0.3 +catboost==1.2.7 +shap==0.44.1 +scikit-learn==1.4.0 +apscheduler==3.10.4 +openpyxl==3.1.2 +pydantic==2.5.3 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 +python-dateutil==2.8.2 +playwright>=1.40.0 +anthropic>=0.42.0 diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..3eb2647 --- /dev/null +++ b/backend/scheduler.py @@ -0,0 +1,366 @@ +""" +APScheduler configuration for scheduled jobs +""" +import logging +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from sqlalchemy import text + +from jobs.data_sync import ( + sync_newbook_data, + sync_resos_data, + sync_newbook_occupancy_report, + sync_newbook_earned_revenue +) +from jobs.resos_bookings_sync import sync_resos_bookings_data +from api.sync_bookings import run_bookings_data_sync +from jobs.aggregation import run_aggregation +from jobs.forecast_daily import run_daily_forecast +from jobs.pickup_snapshot import run_pickup_snapshot +from jobs.pace_snapshot_v2 import run_pace_snapshot_v2 +from jobs.accuracy_calc import run_accuracy_calculation +from jobs.weekly_forecast_snapshot import run_weekly_forecast_snapshot +from jobs.fetch_current_rates import run_fetch_current_rates +from jobs.scrape_booking_rates import run_scheduled_booking_scrape_async +from jobs.ai_insights import run_ai_insights_generation +from database import SyncSessionLocal + +logger = logging.getLogger(__name__) + +scheduler = AsyncIOScheduler( + job_defaults={ + 'misfire_grace_time': 3600, # Allow jobs to run up to 1 hour late + 'coalesce': True, # If multiple runs were missed, only run once + } +) + + +def get_config_value(key: str, default: str = None) -> str: + """Get a config value from system_config""" + db = SyncSessionLocal() + try: + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = :key"), + {"key": key} + ) + row = result.fetchone() + if row and row.config_value: + return row.config_value + return default + except Exception as e: + logger.error(f"Error getting config {key}: {e}") + return default + finally: + db.close() + + +def is_sync_enabled(source: str) -> bool: + """Check if a sync source is enabled in config""" + value = get_config_value(f"sync_{source}_enabled") + if value: + return value.lower() in ('true', '1', 'yes', 'enabled') + return False + + +def get_sync_time(source: str, default_hour: int = 5, default_minute: int = 0) -> tuple: + """Get sync time for a source, returns (hour, minute) tuple""" + time_str = get_config_value(f"sync_{source}_time") + if time_str: + try: + parts = time_str.split(':') + return (int(parts[0]), int(parts[1])) + except (ValueError, IndexError): + logger.warning(f"Invalid time format for {source}: {time_str}, using default") + return (default_hour, default_minute) + + +async def run_scheduled_newbook_sync(): + """Wrapper to check if Newbook bookings sync is enabled before running""" + if is_sync_enabled("newbook_bookings"): + # Get sync type from config (incremental or full) + sync_type = get_config_value("sync_newbook_bookings_type", "incremental") + logger.info(f"Running scheduled Newbook bookings sync (mode={sync_type})") + # Run synchronous function in thread pool to avoid blocking + import asyncio + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + run_bookings_data_sync, + sync_type, # sync_mode + None, # from_date + None, # to_date + "scheduler" # triggered_by + ) + else: + logger.debug("Scheduled Newbook bookings sync skipped (disabled in settings)") + + +async def run_scheduled_resos_bookings_sync(): + """Wrapper to check if Resos bookings sync is enabled before running""" + from datetime import date, timedelta + if is_sync_enabled("resos_bookings"): + logger.info("Running scheduled Resos bookings sync") + # Daily: -7 days to +365 days (recent history + forecast window) + from_date = date.today() - timedelta(days=7) + to_date = date.today() + timedelta(days=365) + await sync_resos_bookings_data(from_date, to_date, triggered_by="scheduler") + else: + logger.info("Scheduled Resos bookings sync skipped (disabled in settings)") + + +async def run_scheduled_resos_sync(): + """Wrapper to check if Resos sync is enabled before running""" + if is_sync_enabled("resos"): + logger.info("Running scheduled Resos sync") + await sync_resos_data(triggered_by="scheduler") + else: + logger.info("Scheduled Resos sync skipped (disabled in settings)") + + +async def run_scheduled_occupancy_report_sync(): + """Wrapper to run occupancy report sync (uses dedicated occupancy enabled flag)""" + from datetime import date, timedelta + if is_sync_enabled("newbook_occupancy"): + logger.info("Running scheduled Newbook occupancy report sync") + # Daily: -7 days to +365 days + from_date = date.today() - timedelta(days=7) + to_date = date.today() + timedelta(days=365) + await sync_newbook_occupancy_report(from_date, to_date, triggered_by="scheduler") + else: + logger.info("Scheduled Newbook occupancy report sync skipped (disabled in settings)") + + +async def run_scheduled_earned_revenue_sync(): + """Wrapper to run earned revenue sync (uses dedicated enabled flag)""" + from datetime import date, timedelta + if is_sync_enabled("newbook_earned_revenue"): + logger.info("Running scheduled Newbook earned revenue sync") + # Daily: last 7 days only (historical data, catches adjustments) + from_date = date.today() - timedelta(days=7) + to_date = date.today() + await sync_newbook_earned_revenue(from_date, to_date, triggered_by="scheduler") + else: + logger.info("Scheduled Newbook earned revenue sync skipped (disabled in settings)") + + +async def run_scheduled_current_rates_sync(): + """Wrapper to run current rates sync (for pickup-v2 upper bounds)""" + if is_sync_enabled("newbook_current_rates"): + logger.info("Running scheduled Newbook current rates sync") + await run_fetch_current_rates() + else: + logger.debug("Scheduled Newbook current rates sync skipped (disabled in settings)") + + +def reschedule_sync_jobs(): + """ + Read sync times from config and reschedule sync jobs. + Called at startup and daily at 1am to pick up config changes. + """ + logger.info("Rescheduling sync jobs from config...") + + # Newbook bookings sync + nb_hour, nb_min = get_sync_time("newbook_bookings", 5, 0) + scheduler.add_job( + run_scheduled_newbook_sync, + CronTrigger(hour=nb_hour, minute=nb_min), + id="newbook_sync", + name=f"Daily Newbook Bookings Sync ({nb_hour:02d}:{nb_min:02d})", + replace_existing=True + ) + logger.info(f" Newbook bookings sync scheduled for {nb_hour:02d}:{nb_min:02d}") + + # Resos bookings sync + rsb_hour, rsb_min = get_sync_time("resos_bookings", 5, 5) + scheduler.add_job( + run_scheduled_resos_bookings_sync, + CronTrigger(hour=rsb_hour, minute=rsb_min), + id="resos_bookings_sync", + name=f"Daily Resos Bookings Sync ({rsb_hour:02d}:{rsb_min:02d})", + replace_existing=True + ) + logger.info(f" Resos bookings sync scheduled for {rsb_hour:02d}:{rsb_min:02d}") + + # Resos sync - uses general sync_schedule_time for now + rs_time = get_config_value("sync_schedule_time", "05:05") + try: + rs_hour, rs_min = int(rs_time.split(':')[0]), int(rs_time.split(':')[1]) + except: + rs_hour, rs_min = 5, 5 + scheduler.add_job( + run_scheduled_resos_sync, + CronTrigger(hour=rs_hour, minute=rs_min), + id="resos_sync", + name=f"Daily Resos Sync ({rs_hour:02d}:{rs_min:02d})", + replace_existing=True + ) + logger.info(f" Resos sync scheduled for {rs_hour:02d}:{rs_min:02d}") + + # Newbook occupancy report + occ_hour, occ_min = get_sync_time("newbook_occupancy", 5, 8) + scheduler.add_job( + run_scheduled_occupancy_report_sync, + CronTrigger(hour=occ_hour, minute=occ_min), + id="newbook_occupancy_report", + name=f"Daily Newbook Occupancy Report ({occ_hour:02d}:{occ_min:02d})", + replace_existing=True + ) + logger.info(f" Newbook occupancy report scheduled for {occ_hour:02d}:{occ_min:02d}") + + # Newbook earned revenue + rev_hour, rev_min = get_sync_time("newbook_earned_revenue", 5, 10) + scheduler.add_job( + run_scheduled_earned_revenue_sync, + CronTrigger(hour=rev_hour, minute=rev_min), + id="newbook_earned_revenue", + name=f"Daily Newbook Earned Revenue ({rev_hour:02d}:{rev_min:02d})", + replace_existing=True + ) + logger.info(f" Newbook earned revenue scheduled for {rev_hour:02d}:{rev_min:02d}") + + # Aggregation - 15 mins after the latest sync job + latest_sync = max(nb_hour * 60 + nb_min, occ_hour * 60 + occ_min, rev_hour * 60 + rev_min) + agg_mins = latest_sync + 15 + agg_hour, agg_min = agg_mins // 60, agg_mins % 60 + scheduler.add_job( + run_aggregation, + CronTrigger(hour=agg_hour, minute=agg_min), + id="aggregation", + name=f"Daily Aggregation ({agg_hour:02d}:{agg_min:02d})", + replace_existing=True + ) + logger.info(f" Aggregation scheduled for {agg_hour:02d}:{agg_min:02d}") + + logger.info("Sync jobs rescheduled successfully") + + +def start_scheduler(): + """Initialize and start the scheduler""" + logger.info("Starting scheduler...") + + # 1am daily: reschedule sync jobs from config + # This picks up any config changes made during the day + scheduler.add_job( + reschedule_sync_jobs, + CronTrigger(hour=1, minute=0), + id="reschedule_sync_jobs", + name="Daily Reschedule Sync Jobs (01:00)", + replace_existing=True + ) + + # Schedule sync jobs from config (initial schedule) + reschedule_sync_jobs() + + # Pickup snapshot - Daily at 5:30 AM + scheduler.add_job( + run_pickup_snapshot, + CronTrigger(hour=5, minute=30), + id="pickup_snapshot", + name="Daily Pickup Snapshot", + replace_existing=True + ) + + # Pace snapshot v2 - Daily at 5:32 AM + # Captures revenue pace for pickup-v2 model + scheduler.add_job( + run_pace_snapshot_v2, + CronTrigger(hour=5, minute=32), + id="pace_snapshot_v2", + name="Daily Pace Snapshot V2", + replace_existing=True + ) + + # Fetch current rates from Newbook - Daily at 5:20 AM + # Populates newbook_current_rates for pickup-v2 upper bound calculations + scheduler.add_job( + run_scheduled_current_rates_sync, + CronTrigger(hour=5, minute=20), + id="fetch_current_rates", + name="Daily Fetch Current Rates", + replace_existing=True + ) + + # Booking.com rate scraper - Daily at configured time (default 05:30) + # Tiered: daily 30d, weekly 31-180d (Mon-Fri), biweekly 181-365d (Wed) + booking_time = get_config_value("booking_scraper_daily_time", "05:30") + try: + bk_hour, bk_min = int(booking_time.split(':')[0]), int(booking_time.split(':')[1]) + except (ValueError, IndexError): + bk_hour, bk_min = 5, 30 + scheduler.add_job( + run_scheduled_booking_scrape_async, + CronTrigger(hour=bk_hour, minute=bk_min), + id="booking_scrape", + name=f"Daily Booking.com Scrape ({bk_hour:02d}:{bk_min:02d})", + replace_existing=True + ) + logger.info(f" Booking.com scrape scheduled for {bk_hour:02d}:{bk_min:02d}") + + # Daily forecast (0-28 days) - Daily at 6:00 AM + # Prophet, XGBoost, Pickup, and CatBoost for operational planning window + scheduler.add_job( + lambda: run_daily_forecast(horizon_days=28, models=['prophet', 'xgboost', 'pickup', 'catboost']), + CronTrigger(hour=6, minute=0), + id="forecast_daily", + name="Daily Forecast (0-28 days)", + replace_existing=True + ) + + # Long-term forecast (29-365 days) - Weekly on Monday at 6:30 AM + # Prophet and XGBoost only (pickup less useful at long range) + scheduler.add_job( + lambda: run_daily_forecast(horizon_days=365, start_days=29, models=['prophet', 'xgboost']), + CronTrigger(day_of_week="mon", hour=6, minute=30), + id="forecast_weekly", + name="Weekly Forecast (29-365 days)", + replace_existing=True + ) + + # Accuracy calculation - Daily at 7:00 AM + scheduler.add_job( + run_accuracy_calculation, + CronTrigger(hour=7, minute=0), + id="accuracy_calc", + name="Daily Accuracy Calculation", + replace_existing=True + ) + + # Weekly forecast snapshot - Monday at 6:00 AM (default) + # Get time from config (default: Monday 6:00 AM) + snapshot_time = get_config_value("forecast_snapshot_time", "06:00") + try: + snapshot_hour, snapshot_min = int(snapshot_time.split(':')[0]), int(snapshot_time.split(':')[1]) + except: + snapshot_hour, snapshot_min = 6, 0 + + scheduler.add_job( + run_weekly_forecast_snapshot, + CronTrigger(day_of_week="mon", hour=snapshot_hour, minute=snapshot_min), + id="weekly_forecast_snapshot", + name=f"Weekly Forecast Snapshot (Mon {snapshot_hour:02d}:{snapshot_min:02d})", + replace_existing=True + ) + + # AI Insights - Daily at configurable time (default 07:15, after accuracy calc) + ai_time = get_config_value("ai_insights_schedule_time", "07:15") + try: + ai_hour, ai_min = int(ai_time.split(':')[0]), int(ai_time.split(':')[1]) + except (ValueError, IndexError): + ai_hour, ai_min = 7, 15 + scheduler.add_job( + run_ai_insights_generation, + CronTrigger(hour=ai_hour, minute=ai_min), + id="ai_insights", + name=f"Daily AI Insights ({ai_hour:02d}:{ai_min:02d})", + replace_existing=True + ) + logger.info(f" AI Insights scheduled for {ai_hour:02d}:{ai_min:02d}") + + scheduler.start() + logger.info("Scheduler started successfully") + + +def shutdown_scheduler(): + """Shutdown the scheduler""" + logger.info("Shutting down scheduler...") + scheduler.shutdown() diff --git a/backend/schema.sql b/backend/schema.sql new file mode 100644 index 0000000..3d08d2e --- /dev/null +++ b/backend/schema.sql @@ -0,0 +1,870 @@ +-- Forecasting Application Database Schema +-- PostgreSQL 15+ +-- Database: forecast_data (created via POSTGRES_DB env var) + + +-- ============================================ +-- USERS & AUTHENTICATION +-- ============================================ + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Default admin user (password: admin123 - change in production!) +INSERT INTO users (username, password_hash, display_name) VALUES +('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYH0lHXG0Kku', 'Administrator') +ON CONFLICT (username) DO NOTHING; + +-- ============================================ +-- API KEYS (for external integrations) +-- ============================================ + +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA256 hash of key (never store plaintext) + key_prefix VARCHAR(20) NOT NULL, -- First chars for display (e.g., "fk_abc123...") + name VARCHAR(100) NOT NULL, -- Descriptive name (e.g., "Kitchen Flash App") + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + last_used_at TIMESTAMP, + created_by VARCHAR(100) +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); +CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active); + +-- ============================================ +-- SYSTEM CONFIGURATION +-- ============================================ + +CREATE TABLE IF NOT EXISTS system_config ( + id SERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value TEXT, + is_encrypted BOOLEAN DEFAULT FALSE, + description TEXT, + updated_at TIMESTAMP DEFAULT NOW(), + updated_by VARCHAR(100) +); + +-- Default config entries +INSERT INTO system_config (config_key, description) VALUES +('newbook_api_key', 'Newbook API Key'), +('newbook_username', 'Newbook Username'), +('newbook_password', 'Newbook Password'), +('newbook_region', 'Newbook Region Code'), +('resos_api_key', 'Resos API Key'), +('total_rooms', 'Total number of hotel rooms'), +('hotel_name', 'Hotel/Property Name'), +('timezone', 'Local timezone (e.g., Europe/London)'), +('accommodation_vat_rate', 'VAT rate for accommodation (e.g., 0.20 for 20%)'), +('sync_newbook_enabled', 'Enable automatic Newbook sync (true/false)'), +('sync_resos_enabled', 'Enable automatic Resos sync (true/false)'), +('sync_schedule_time', 'Time for daily sync (HH:MM format)'), +('sync_newbook_bookings_enabled', 'Enable automatic Newbook bookings data sync (true/false)'), +('sync_newbook_bookings_type', 'Newbook bookings sync type (incremental/full)'), +('sync_newbook_bookings_time', 'Newbook bookings sync time (HH:MM)'), +('sync_newbook_occupancy_enabled', 'Enable automatic Newbook occupancy report sync (true/false)'), +('sync_newbook_occupancy_time', 'Newbook occupancy report sync time (HH:MM)'), +('last_bookings_aggregation_at', 'Timestamp of last bookings stats aggregation'), +('sync_newbook_earned_revenue_enabled', 'Enable automatic Newbook earned revenue sync (true/false)'), +('sync_newbook_earned_revenue_time', 'Newbook earned revenue sync time (HH:MM)'), +('last_revenue_aggregation_at', 'Timestamp of last revenue aggregation'), +('sync_newbook_current_rates_enabled', 'Enable automatic Newbook current rates sync for pickup-v2 (true/false)'), +('sync_newbook_current_rates_time', 'Newbook current rates sync time (HH:MM)') +ON CONFLICT (config_key) DO NOTHING; + +-- Set defaults +UPDATE system_config SET config_value = '80' WHERE config_key = 'total_rooms' AND config_value IS NULL; +UPDATE system_config SET config_value = 'Europe/London' WHERE config_key = 'timezone' AND config_value IS NULL; +UPDATE system_config SET config_value = '0.20' WHERE config_key = 'accommodation_vat_rate' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_resos_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:00' WHERE config_key = 'sync_schedule_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_bookings_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = 'incremental' WHERE config_key = 'sync_newbook_bookings_type' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:00' WHERE config_key = 'sync_newbook_bookings_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_occupancy_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:00' WHERE config_key = 'sync_newbook_occupancy_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_earned_revenue_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:10' WHERE config_key = 'sync_newbook_earned_revenue_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_current_rates_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:20' WHERE config_key = 'sync_newbook_current_rates_time' AND config_value IS NULL; + +-- ============================================ +-- TAX RATES (date-based tax configuration) +-- ============================================ + +CREATE TABLE IF NOT EXISTS tax_rates ( + id SERIAL PRIMARY KEY, + tax_type VARCHAR(50) NOT NULL, -- 'accommodation_vat', 'food_vat', etc. + rate DECIMAL(5,4) NOT NULL, -- e.g., 0.20 for 20% + effective_from DATE NOT NULL, -- Date this rate becomes effective + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(tax_type, effective_from) +); + +CREATE INDEX IF NOT EXISTS idx_tax_rates_type ON tax_rates(tax_type); +CREATE INDEX IF NOT EXISTS idx_tax_rates_effective ON tax_rates(tax_type, effective_from); + +-- Default accommodation VAT rate (20% from 2022-01-01) +INSERT INTO tax_rates (tax_type, rate, effective_from) VALUES +('accommodation_vat', 0.20, '2022-01-01') +ON CONFLICT (tax_type, effective_from) DO NOTHING; + +-- ============================================ +-- NEWBOOK ROOM CATEGORIES (for occupancy settings) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_room_categories ( + id SERIAL PRIMARY KEY, + site_id VARCHAR(50) NOT NULL UNIQUE, + site_name VARCHAR(255) NOT NULL, + site_type VARCHAR(100), + room_count INTEGER DEFAULT 0, + is_included BOOLEAN DEFAULT TRUE, + display_order INTEGER DEFAULT 0, + fetched_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================ +-- NEWBOOK GL ACCOUNTS (for revenue mapping) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_gl_accounts ( + id SERIAL PRIMARY KEY, + gl_account_id VARCHAR(50) NOT NULL UNIQUE, + gl_code VARCHAR(50), + gl_name VARCHAR(255), + gl_group_id VARCHAR(50), + gl_group_name VARCHAR(255), + department VARCHAR(20), -- 'accommodation', 'dry', 'wet', or null + last_seen_date DATE, + total_amount DECIMAL(14,2) DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + fetched_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_gl_accounts_department ON newbook_gl_accounts(department); +CREATE INDEX IF NOT EXISTS idx_gl_accounts_group ON newbook_gl_accounts(gl_group_name); + +-- ============================================ +-- NEWBOOK BOOKINGS DATA (historical booking data) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_bookings_data ( + id SERIAL PRIMARY KEY, + newbook_id VARCHAR(50) NOT NULL UNIQUE, + booking_reference VARCHAR(100), + bookings_group_id VARCHAR(50), + booking_placed TIMESTAMP, -- When booking was created (for lead time calculations) + arrival_date DATE NOT NULL, + departure_date DATE NOT NULL, + nights INTEGER, + adults INTEGER DEFAULT 0, + children INTEGER DEFAULT 0, + infants INTEGER DEFAULT 0, + total_guests INTEGER, + category_id VARCHAR(50), + room_type VARCHAR(100), + site_id VARCHAR(50), + room_number VARCHAR(50), + status VARCHAR(50), + total_amount DECIMAL(12,2), + tariff_name VARCHAR(255), + tariff_total DECIMAL(12,2), + travel_agent_id VARCHAR(50), + travel_agent_name VARCHAR(255), + travel_agent_commission DECIMAL(12,2), + booking_source_id VARCHAR(50), + booking_source_name VARCHAR(255), + booking_parent_source_id VARCHAR(50), + booking_parent_source_name VARCHAR(255), + booking_method_id VARCHAR(50), + booking_method_name VARCHAR(100), + raw_json JSONB, + fetched_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bookings_arrival ON newbook_bookings_data(arrival_date); +CREATE INDEX IF NOT EXISTS idx_bookings_status ON newbook_bookings_data(status); +CREATE INDEX IF NOT EXISTS idx_bookings_placed ON newbook_bookings_data(booking_placed); + +-- ============================================ +-- NEWBOOK EARNED REVENUE DATA (historical revenue data) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_earned_revenue_data ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + gl_account_id VARCHAR(50), + gl_code VARCHAR(50), + gl_name VARCHAR(255), + amount_gross DECIMAL(12,2) DEFAULT 0, + amount_net DECIMAL(12,2) DEFAULT 0, + revenue_type VARCHAR(30), + fetched_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, gl_account_id) +); + +CREATE INDEX IF NOT EXISTS idx_earned_revenue_data_date ON newbook_earned_revenue_data(date); +CREATE INDEX IF NOT EXISTS idx_earned_revenue_data_type ON newbook_earned_revenue_data(date, revenue_type); + +-- ============================================ +-- NEWBOOK NET REVENUE DATA (aggregated by department) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_net_revenue_data ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL UNIQUE, + accommodation DECIMAL(12,2) DEFAULT 0, -- Net accommodation revenue + dry DECIMAL(12,2) DEFAULT 0, -- Net dry (food) revenue + wet DECIMAL(12,2) DEFAULT 0, -- Net wet (beverage) revenue + aggregated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_net_revenue_data_date ON newbook_net_revenue_data(date); + +-- ============================================ +-- NEWBOOK OCCUPANCY REPORT DATA (official capacity & occupancy) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_occupancy_report_data ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + category_id VARCHAR(50) NOT NULL, + category_name VARCHAR(255), + available INTEGER DEFAULT 0, -- Total configured rooms for category + occupied INTEGER DEFAULT 0, -- Official occupied per Newbook + maintenance INTEGER DEFAULT 0, -- Rooms offline (deduct from available for bookable rooms) + allotted INTEGER DEFAULT 0, -- Block allocations + revenue_gross DECIMAL(12,2) DEFAULT 0, + revenue_net DECIMAL(12,2) DEFAULT 0, + occupancy_pct DECIMAL(5,2), + fetched_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, category_id) +); + +CREATE INDEX IF NOT EXISTS idx_occupancy_report_data_date ON newbook_occupancy_report_data(date); + +-- ============================================ +-- SYNC LOGGING +-- ============================================ + +CREATE TABLE IF NOT EXISTS sync_log ( + id SERIAL PRIMARY KEY, + sync_type VARCHAR(30) NOT NULL, + source VARCHAR(20) NOT NULL, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + status VARCHAR(20) NOT NULL, + records_fetched INTEGER, + records_created INTEGER, + records_updated INTEGER, + date_from DATE, + date_to DATE, + error_message TEXT, + triggered_by VARCHAR(100) +); + +-- ============================================ +-- NEWBOOK BOOKINGS STATS (aggregated daily stats) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_bookings_stats ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL UNIQUE, + + -- Room availability (from newbook_occupancy_report_data) + rooms_count INTEGER DEFAULT 0, -- Total available rooms (included categories) + maintenance_count INTEGER DEFAULT 0, -- Rooms offline/maintenance + bookable_count INTEGER DEFAULT 0, -- rooms_count - maintenance_count + + -- Occupancy totals (from bookings) + booking_count INTEGER DEFAULT 0, -- Occupied rooms (bookings staying this night) + guests_count INTEGER DEFAULT 0, + adults_count INTEGER DEFAULT 0, + children_count INTEGER DEFAULT 0, + infants_count INTEGER DEFAULT 0, + + -- Occupancy percentages + total_occupancy_pct DECIMAL(5,2), -- booking_count / rooms_count * 100 + bookable_occupancy_pct DECIMAL(5,2), -- booking_count / bookable_count * 100 + + -- Revenue totals + guest_rate_total DECIMAL(12,2) DEFAULT 0, -- SUM of calculated_amount (gross) + net_booking_rev_total DECIMAL(12,2) DEFAULT 0, -- SUM of net accommodation + + -- Per-category breakdowns (JSONB) + occupancy_by_category JSONB DEFAULT '{}', + revenue_by_category JSONB DEFAULT '{}', + availability_by_category JSONB DEFAULT '{}', + + -- Pickup-V2: Rate statistics per category for bounds calculation + -- Structure: { "category_id": { "min_net": 120, "max_net": 200, "adr_net": 155, "rooms": 12 } } + rate_stats_by_category JSONB DEFAULT '{}', + + aggregated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bookings_stats_date ON newbook_bookings_stats(date); + +-- ============================================ +-- NEWBOOK BOOKING PACE (lead-time snapshots for forecasting) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_booking_pace ( + id SERIAL PRIMARY KEY, + arrival_date DATE NOT NULL UNIQUE, + + -- Monthly intervals: months 7-12 (6 columns) + d365 INTEGER, -- 12 months out + d330 INTEGER, -- 11 months out + d300 INTEGER, -- 10 months out + d270 INTEGER, -- 9 months out + d240 INTEGER, -- 8 months out + d210 INTEGER, -- 7 months out + + -- Weekly intervals: weeks 5-25 (21 columns) + d177 INTEGER, d170 INTEGER, d163 INTEGER, d156 INTEGER, d149 INTEGER, + d142 INTEGER, d135 INTEGER, d128 INTEGER, d121 INTEGER, d114 INTEGER, + d107 INTEGER, d100 INTEGER, d93 INTEGER, d86 INTEGER, d79 INTEGER, + d72 INTEGER, d65 INTEGER, d58 INTEGER, d51 INTEGER, d44 INTEGER, d37 INTEGER, + + -- Daily intervals: days 0-30 (31 columns) + d30 INTEGER, d29 INTEGER, d28 INTEGER, d27 INTEGER, d26 INTEGER, + d25 INTEGER, d24 INTEGER, d23 INTEGER, d22 INTEGER, d21 INTEGER, + d20 INTEGER, d19 INTEGER, d18 INTEGER, d17 INTEGER, d16 INTEGER, + d15 INTEGER, d14 INTEGER, d13 INTEGER, d12 INTEGER, d11 INTEGER, + d10 INTEGER, d9 INTEGER, d8 INTEGER, d7 INTEGER, d6 INTEGER, + d5 INTEGER, d4 INTEGER, d3 INTEGER, d2 INTEGER, d1 INTEGER, d0 INTEGER, + + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_booking_pace_arrival ON newbook_booking_pace(arrival_date); + +-- ============================================ +-- PICKUP-V2: CATEGORY BOOKING PACE (per-category room counts at lead times) +-- ============================================ + +CREATE TABLE IF NOT EXISTS category_booking_pace ( + id SERIAL PRIMARY KEY, + arrival_date DATE NOT NULL, + category_id VARCHAR(50) NOT NULL, + + -- Monthly intervals: months 7-12 (6 columns) + d365 INTEGER, d330 INTEGER, d300 INTEGER, d270 INTEGER, d240 INTEGER, d210 INTEGER, + + -- Weekly intervals: weeks 5-25 (21 columns) + d177 INTEGER, d170 INTEGER, d163 INTEGER, d156 INTEGER, d149 INTEGER, + d142 INTEGER, d135 INTEGER, d128 INTEGER, d121 INTEGER, d114 INTEGER, + d107 INTEGER, d100 INTEGER, d93 INTEGER, d86 INTEGER, d79 INTEGER, + d72 INTEGER, d65 INTEGER, d58 INTEGER, d51 INTEGER, d44 INTEGER, d37 INTEGER, + + -- Daily intervals: days 0-30 (31 columns) + d30 INTEGER, d29 INTEGER, d28 INTEGER, d27 INTEGER, d26 INTEGER, + d25 INTEGER, d24 INTEGER, d23 INTEGER, d22 INTEGER, d21 INTEGER, + d20 INTEGER, d19 INTEGER, d18 INTEGER, d17 INTEGER, d16 INTEGER, + d15 INTEGER, d14 INTEGER, d13 INTEGER, d12 INTEGER, d11 INTEGER, + d10 INTEGER, d9 INTEGER, d8 INTEGER, d7 INTEGER, d6 INTEGER, + d5 INTEGER, d4 INTEGER, d3 INTEGER, d2 INTEGER, d1 INTEGER, d0 INTEGER, + + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(arrival_date, category_id) +); + +CREATE INDEX IF NOT EXISTS idx_category_booking_pace_arrival ON category_booking_pace(arrival_date); +CREATE INDEX IF NOT EXISTS idx_category_booking_pace_category ON category_booking_pace(category_id); + +-- ============================================ +-- PICKUP-V2: REVENUE PACE (booked accommodation revenue at lead times) +-- ============================================ + +CREATE TABLE IF NOT EXISTS revenue_pace ( + id SERIAL PRIMARY KEY, + stay_date DATE NOT NULL UNIQUE, + + -- Monthly intervals: months 7-12 (6 columns) - DECIMAL for revenue + d365 DECIMAL(12,2), d330 DECIMAL(12,2), d300 DECIMAL(12,2), + d270 DECIMAL(12,2), d240 DECIMAL(12,2), d210 DECIMAL(12,2), + + -- Weekly intervals: weeks 5-25 (21 columns) + d177 DECIMAL(12,2), d170 DECIMAL(12,2), d163 DECIMAL(12,2), d156 DECIMAL(12,2), d149 DECIMAL(12,2), + d142 DECIMAL(12,2), d135 DECIMAL(12,2), d128 DECIMAL(12,2), d121 DECIMAL(12,2), d114 DECIMAL(12,2), + d107 DECIMAL(12,2), d100 DECIMAL(12,2), d93 DECIMAL(12,2), d86 DECIMAL(12,2), d79 DECIMAL(12,2), + d72 DECIMAL(12,2), d65 DECIMAL(12,2), d58 DECIMAL(12,2), d51 DECIMAL(12,2), d44 DECIMAL(12,2), d37 DECIMAL(12,2), + + -- Daily intervals: days 0-30 (31 columns) + d30 DECIMAL(12,2), d29 DECIMAL(12,2), d28 DECIMAL(12,2), d27 DECIMAL(12,2), d26 DECIMAL(12,2), + d25 DECIMAL(12,2), d24 DECIMAL(12,2), d23 DECIMAL(12,2), d22 DECIMAL(12,2), d21 DECIMAL(12,2), + d20 DECIMAL(12,2), d19 DECIMAL(12,2), d18 DECIMAL(12,2), d17 DECIMAL(12,2), d16 DECIMAL(12,2), + d15 DECIMAL(12,2), d14 DECIMAL(12,2), d13 DECIMAL(12,2), d12 DECIMAL(12,2), d11 DECIMAL(12,2), + d10 DECIMAL(12,2), d9 DECIMAL(12,2), d8 DECIMAL(12,2), d7 DECIMAL(12,2), d6 DECIMAL(12,2), + d5 DECIMAL(12,2), d4 DECIMAL(12,2), d3 DECIMAL(12,2), d2 DECIMAL(12,2), d1 DECIMAL(12,2), d0 DECIMAL(12,2), + + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_revenue_pace_stay ON revenue_pace(stay_date); + +-- ============================================ +-- PICKUP-V2: CURRENT RATES FROM NEWBOOK (for ceiling calculations) +-- Now with rate history tracking - stores snapshots when rates change +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_current_rates ( + id SERIAL PRIMARY KEY, + category_id VARCHAR(50) NOT NULL, + rate_date DATE NOT NULL, + rate_name VARCHAR(255), + rate_gross DECIMAL(12,2), + rate_net DECIMAL(12,2), + tariffs_data JSONB DEFAULT '{}', -- All available tariff options with availability status + valid_from TIMESTAMP DEFAULT NOW(), -- When this rate version started + last_verified_at TIMESTAMP DEFAULT NOW() -- Last time we confirmed rate is still current + -- No UNIQUE constraint - allows multiple versions per (category_id, rate_date) +); + +CREATE INDEX IF NOT EXISTS idx_current_rates_date ON newbook_current_rates(rate_date); +CREATE INDEX IF NOT EXISTS idx_current_rates_category ON newbook_current_rates(category_id); +CREATE INDEX IF NOT EXISTS idx_current_rates_tariffs ON newbook_current_rates USING gin(tariffs_data); +CREATE INDEX IF NOT EXISTS idx_current_rates_latest ON newbook_current_rates(category_id, rate_date, valid_from DESC); + +-- Migration: Add tariffs_data column if missing (for existing databases) +ALTER TABLE newbook_current_rates ADD COLUMN IF NOT EXISTS tariffs_data JSONB DEFAULT '{}'; + +-- Migration: Convert from old schema to new snapshot schema +-- Rename fetched_at to valid_from if it exists +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'newbook_current_rates' AND column_name = 'fetched_at') THEN + ALTER TABLE newbook_current_rates RENAME COLUMN fetched_at TO valid_from; + END IF; +END $$; + +-- Add last_verified_at column if missing +ALTER TABLE newbook_current_rates ADD COLUMN IF NOT EXISTS last_verified_at TIMESTAMP DEFAULT NOW(); + +-- Drop unique constraint if it exists (allows rate history) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'newbook_current_rates_category_id_rate_date_key') THEN + ALTER TABLE newbook_current_rates + DROP CONSTRAINT newbook_current_rates_category_id_rate_date_key; + END IF; +END $$; + +-- ============================================ +-- FORECASTING TABLES (for Prophet, XGBoost, CatBoost models) +-- ============================================ + +-- Forecast metrics configuration +CREATE TABLE IF NOT EXISTS forecast_metrics ( + id SERIAL PRIMARY KEY, + metric_code VARCHAR(50) NOT NULL UNIQUE, + metric_name VARCHAR(100) NOT NULL, + description TEXT, + unit VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + use_prophet BOOLEAN DEFAULT TRUE, + use_xgboost BOOLEAN DEFAULT TRUE, + use_pickup BOOLEAN DEFAULT FALSE, + use_catboost BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Default metrics for forecasting +INSERT INTO forecast_metrics (metric_code, metric_name, description, unit, use_prophet, use_xgboost, use_pickup, use_catboost) VALUES +('hotel_occupancy_pct', 'Hotel Occupancy %', 'Percentage of available rooms occupied', '%', TRUE, TRUE, TRUE, TRUE), +('hotel_room_nights', 'Room Nights', 'Number of rooms sold', 'rooms', TRUE, TRUE, TRUE, TRUE), +('hotel_guests', 'Guest Count', 'Total guests staying', 'guests', TRUE, TRUE, FALSE, TRUE), +('hotel_arrivals', 'Arrivals', 'Number of check-ins', 'arrivals', TRUE, TRUE, FALSE, TRUE) +ON CONFLICT (metric_code) DO NOTHING; + +-- Daily metrics (actuals storage - populated from newbook_bookings_stats) +CREATE TABLE IF NOT EXISTS daily_metrics ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + metric_code VARCHAR(50) NOT NULL, + actual_value DECIMAL(12,2), + source VARCHAR(50) DEFAULT 'newbook', + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, metric_code) +); + +CREATE INDEX IF NOT EXISTS idx_daily_metrics_date ON daily_metrics(date); +CREATE INDEX IF NOT EXISTS idx_daily_metrics_code ON daily_metrics(metric_code, date); + +-- Forecasts storage +CREATE TABLE IF NOT EXISTS forecasts ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + model_type VARCHAR(20) NOT NULL, -- 'prophet', 'xgboost', 'pickup', 'catboost' + predicted_value DECIMAL(12,2) NOT NULL, + lower_bound DECIMAL(12,2), + upper_bound DECIMAL(12,2), + generated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(forecast_date, forecast_type, model_type, generated_at) +); + +CREATE INDEX IF NOT EXISTS idx_forecasts_date ON forecasts(forecast_date); +CREATE INDEX IF NOT EXISTS idx_forecasts_type ON forecasts(forecast_type, model_type); +CREATE INDEX IF NOT EXISTS idx_forecasts_generated ON forecasts(generated_at DESC); + +-- Actual vs forecast comparison +CREATE TABLE IF NOT EXISTS actual_vs_forecast ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + metric_type VARCHAR(50) NOT NULL, + actual_value DECIMAL(12,2), + budget_value DECIMAL(12,2), + -- Prophet + prophet_forecast DECIMAL(12,2), + prophet_lower DECIMAL(12,2), + prophet_upper DECIMAL(12,2), + prophet_error DECIMAL(12,4), + prophet_pct_error DECIMAL(8,4), + -- XGBoost + xgboost_forecast DECIMAL(12,2), + xgboost_error DECIMAL(12,4), + xgboost_pct_error DECIMAL(8,4), + -- Pickup + pickup_forecast DECIMAL(12,2), + pickup_error DECIMAL(12,4), + pickup_pct_error DECIMAL(8,4), + -- CatBoost + catboost_forecast DECIMAL(12,2), + catboost_error DECIMAL(12,4), + catboost_pct_error DECIMAL(8,4), + -- Analysis + best_model VARCHAR(20), + calculated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, metric_type) +); + +CREATE INDEX IF NOT EXISTS idx_actual_vs_forecast_date ON actual_vs_forecast(date); +CREATE INDEX IF NOT EXISTS idx_actual_vs_forecast_type ON actual_vs_forecast(metric_type); + +-- Prophet decomposition storage +CREATE TABLE IF NOT EXISTS prophet_decomposition ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + trend DECIMAL(12,2), + yearly_seasonality DECIMAL(12,2), + weekly_seasonality DECIMAL(12,2), + daily_seasonality DECIMAL(12,2), + holiday_effects JSONB, + regressor_effects JSONB, + generated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_prophet_decomposition_date ON prophet_decomposition(forecast_date, forecast_type); + +-- XGBoost SHAP explanations +CREATE TABLE IF NOT EXISTS xgboost_explanations ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + base_value DECIMAL(12,2), + feature_values JSONB, + shap_values JSONB, + top_positive JSONB, + top_negative JSONB, + generated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_xgboost_explanations_date ON xgboost_explanations(forecast_date, forecast_type); + +-- Pickup model explanations +CREATE TABLE IF NOT EXISTS pickup_explanations ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + current_otb DECIMAL(12,2), + days_out INTEGER, + comparison_date DATE, + comparison_otb DECIMAL(12,2), + comparison_final DECIMAL(12,2), + pickup_curve_pct DECIMAL(8,4), + pickup_curve_stddev DECIMAL(8,4), + pace_vs_prior_pct DECIMAL(8,4), + projection_method VARCHAR(50), + projected_value DECIMAL(12,2), + confidence_note TEXT, + generated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_pickup_explanations_date ON pickup_explanations(forecast_date, forecast_type); + +-- Monthly budgets from FD +CREATE TABLE IF NOT EXISTS monthly_budgets ( + id SERIAL PRIMARY KEY, + year INTEGER NOT NULL, + month INTEGER NOT NULL CHECK (month >= 1 AND month <= 12), + budget_type VARCHAR(50) NOT NULL, + budget_value DECIMAL(12,2) NOT NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(year, month, budget_type) +); + +CREATE INDEX IF NOT EXISTS idx_monthly_budgets_year ON monthly_budgets(year, budget_type); + +-- Daily budgets (distributed from monthly) +CREATE TABLE IF NOT EXISTS daily_budgets ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + budget_type VARCHAR(50) NOT NULL, + budget_value DECIMAL(12,2), + distribution_method VARCHAR(50), + prior_year_pct DECIMAL(10,6), + monthly_budget_id INTEGER REFERENCES monthly_budgets(id), + calculated_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP, + UNIQUE(date, budget_type) +); + +CREATE INDEX IF NOT EXISTS idx_daily_budgets_date ON daily_budgets(date, budget_type); + +-- Forecast snapshots (for tracking how forecasts evolve over time) +CREATE TABLE IF NOT EXISTS forecast_snapshots ( + id SERIAL PRIMARY KEY, + snapshot_date DATE NOT NULL, + target_date DATE NOT NULL, + metric_code VARCHAR(50) NOT NULL, + days_out INTEGER NOT NULL, + prophet_value DECIMAL(12,2), + xgboost_value DECIMAL(12,2), + pickup_value DECIMAL(12,2), + catboost_value DECIMAL(12,2), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(snapshot_date, target_date, metric_code) +); + +CREATE INDEX IF NOT EXISTS idx_forecast_snapshots_target ON forecast_snapshots(target_date, metric_code); + +-- ============================================ +-- USER ROLES (migration for existing users table) +-- ============================================ + +ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'admin'; + +-- ============================================ +-- RECONCILIATION: CASH UP SESSIONS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_cash_ups ( + id SERIAL PRIMARY KEY, + session_date DATE NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'final')), + total_float_counted DECIMAL(10,2) DEFAULT 0.00, + total_cash_counted DECIMAL(10,2) DEFAULT 0.00, + notes TEXT, + submitted_at TIMESTAMP, + submitted_by INTEGER REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_recon_cash_ups_date ON recon_cash_ups(session_date); +CREATE INDEX IF NOT EXISTS idx_recon_cash_ups_status ON recon_cash_ups(status); + +-- ============================================ +-- RECONCILIATION: DENOMINATION COUNTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_denominations ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + count_type VARCHAR(20) NOT NULL DEFAULT 'takings' CHECK (count_type IN ('float', 'takings')), + denomination_type VARCHAR(20) NOT NULL CHECK (denomination_type IN ('note', 'coin')), + denomination_value DECIMAL(10,2) NOT NULL, + quantity INTEGER DEFAULT NULL, + value_entered DECIMAL(10,2) DEFAULT NULL, + total_amount DECIMAL(10,2) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_recon_denoms_cashup ON recon_denominations(cash_up_id); + +-- ============================================ +-- RECONCILIATION: CARD MACHINE DATA +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_card_machines ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + machine_name VARCHAR(100) NOT NULL, + total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + amex_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + visa_mc_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_cards_cashup ON recon_card_machines(cash_up_id); + +-- ============================================ +-- RECONCILIATION: NEWBOOK PAYMENT RECORDS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_payment_records ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER REFERENCES recon_cash_ups(id) ON DELETE SET NULL, + newbook_payment_id VARCHAR(100), + booking_id VARCHAR(100), + guest_name VARCHAR(255), + payment_date TIMESTAMP NOT NULL, + payment_type VARCHAR(100), + payment_method VARCHAR(50), + transaction_method VARCHAR(50), + card_type VARCHAR(50), + amount DECIMAL(10,2) NOT NULL, + tendered DECIMAL(10,2), + processed_by VARCHAR(255), + item_type VARCHAR(50), + synced_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_recon_payments_cashup ON recon_payment_records(cash_up_id); +CREATE INDEX IF NOT EXISTS idx_recon_payments_date ON recon_payment_records(payment_date); + +-- ============================================ +-- RECONCILIATION: RECONCILIATION RESULTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_reconciliation ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + category VARCHAR(50) NOT NULL, + banked_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + reported_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + variance DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_recon_cashup ON recon_reconciliation(cash_up_id); + +-- ============================================ +-- RECONCILIATION: DAILY STATISTICS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_daily_stats ( + id SERIAL PRIMARY KEY, + business_date DATE NOT NULL UNIQUE, + gross_sales DECIMAL(10,2) DEFAULT 0.00, + debtors_creditors_balance DECIMAL(10,2) DEFAULT 0.00, + rooms_sold INTEGER DEFAULT 0, + total_people INTEGER DEFAULT 0, + source VARCHAR(50) DEFAULT 'manual', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_recon_daily_date ON recon_daily_stats(business_date); + +-- ============================================ +-- RECONCILIATION: SALES BREAKDOWN +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_sales_breakdown ( + id SERIAL PRIMARY KEY, + business_date DATE NOT NULL, + category VARCHAR(100) NOT NULL, + net_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_sales_date ON recon_sales_breakdown(business_date); + +-- ============================================ +-- RECONCILIATION: FLOAT COUNTS (Petty Cash, Change Tin, Safe Cash) +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_float_counts ( + id SERIAL PRIMARY KEY, + count_type VARCHAR(20) NOT NULL CHECK (count_type IN ('petty_cash', 'change_tin', 'safe_cash')), + count_date TIMESTAMP NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW(), + total_counted DECIMAL(10,2) DEFAULT 0.00, + total_receipts DECIMAL(10,2) DEFAULT 0.00, + target_amount DECIMAL(10,2) DEFAULT 0.00, + variance DECIMAL(10,2) DEFAULT 0.00, + notes TEXT +); + +CREATE INDEX IF NOT EXISTS idx_recon_floats_type ON recon_float_counts(count_type); +CREATE INDEX IF NOT EXISTS idx_recon_floats_date ON recon_float_counts(count_date); + +-- ============================================ +-- RECONCILIATION: FLOAT DENOMINATION COUNTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_float_denominations ( + id SERIAL PRIMARY KEY, + float_count_id INTEGER NOT NULL REFERENCES recon_float_counts(id) ON DELETE CASCADE, + denomination_value DECIMAL(10,2) NOT NULL, + quantity INTEGER DEFAULT 0, + total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_float_denoms_fc ON recon_float_denominations(float_count_id); + +-- ============================================ +-- RECONCILIATION: FLOAT RECEIPTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_float_receipts ( + id SERIAL PRIMARY KEY, + float_count_id INTEGER NOT NULL REFERENCES recon_float_counts(id) ON DELETE CASCADE, + receipt_value DECIMAL(10,2) NOT NULL, + receipt_description VARCHAR(255) +); + +CREATE INDEX IF NOT EXISTS idx_recon_float_receipts_fc ON recon_float_receipts(float_count_id); + +-- ============================================ +-- RECONCILIATION: CASH COUNT ATTACHMENTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_attachments ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_type VARCHAR(50) NOT NULL, + file_size BIGINT NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + uploaded_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_recon_attachments_cashup ON recon_attachments(cash_up_id); + +-- ============================================ +-- RECONCILIATION: SYSTEM CONFIG ENTRIES +-- ============================================ + +INSERT INTO system_config (config_key, config_value, description) VALUES +('recon_expected_till_float', '300.00', 'Expected till float amount (GBP)'), +('recon_variance_threshold', '10.00', 'Variance threshold for highlighting (GBP)'), +('recon_default_report_days', '7', 'Default number of days for multi-day reports'), +('recon_petty_cash_target', '200.00', 'Target amount for petty cash float'), +('recon_change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20.00,"1.00":20.00,"0.50":10.00,"0.20":10.00,"0.10":5.00,"0.05":5.00}', 'Change tin denomination breakdown targets (JSON)'), +('recon_denominations', '{"notes":[50.00,20.00,10.00,5.00],"coins":[2.00,1.00,0.50,0.20,0.10,0.05,0.02,0.01]}', 'GBP denominations configuration (JSON)'), +('recon_sales_breakdown_columns', '[]', 'Sales breakdown column configuration (JSON)'), +('recon_safe_cash_target', '0.00', 'Target amount for safe cash float') +ON CONFLICT (config_key) DO NOTHING; diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..8e5b66b --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ +# Services diff --git a/backend/services/backup_service.py b/backend/services/backup_service.py new file mode 100644 index 0000000..04ee78d --- /dev/null +++ b/backend/services/backup_service.py @@ -0,0 +1,539 @@ +""" +Backup and Restore Service + +Handles creating full backups of the database and files, and restoring from backups. +""" +import os +import json +import logging +import shutil +import subprocess +import tempfile +import zipfile +from datetime import datetime +from pathlib import Path +from typing import Optional, Tuple, Dict, Any +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +# Backup storage directory +BACKUP_DIR = Path("/app/data/backups") +DATA_DIR = Path("/app/data") + + +class BackupService: + """Service for managing backups and restores""" + + def __init__(self, db: AsyncSession): + self.db = db + self.ensure_backup_dir() + + def ensure_backup_dir(self): + """Ensure backup directory exists""" + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + + async def ensure_backup_table_exists(self): + """Create backup_history table if it doesn't exist""" + await self.db.execute(text(""" + CREATE TABLE IF NOT EXISTS backup_history ( + id SERIAL PRIMARY KEY, + backup_type VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + filename VARCHAR(255), + file_path TEXT, + file_size_bytes BIGINT, + snapshot_count INTEGER, + file_count INTEGER, + started_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + error_message TEXT, + created_by VARCHAR(100) + ) + """)) + await self.db.commit() + + async def get_backup_settings(self) -> Dict[str, Any]: + """Get backup configuration from system_config""" + settings = { + 'backup_frequency': 'manual', + 'backup_retention_count': 7, + 'backup_destination': 'local', + 'backup_time': None, + 'backup_last_run_at': None, + 'backup_last_status': None + } + + result = await self.db.execute(text(""" + SELECT config_key, config_value + FROM system_config + WHERE config_key LIKE 'backup_%' + """)) + rows = result.fetchall() + + for row in rows: + settings[row.config_key] = row.config_value + + return settings + + async def update_backup_settings(self, updates: Dict[str, Any]) -> bool: + """Update backup configuration""" + try: + for key, value in updates.items(): + if not key.startswith('backup_'): + key = f'backup_{key}' + + await self.db.execute(text(""" + INSERT INTO system_config (config_key, config_value, updated_at) + VALUES (:key, :value, NOW()) + ON CONFLICT (config_key) DO UPDATE + SET config_value = :value, updated_at = NOW() + """), {'key': key, 'value': str(value)}) + + await self.db.commit() + return True + except Exception as e: + logger.error(f"Failed to update backup settings: {e}") + await self.db.rollback() + return False + + async def create_backup( + self, + backup_type: str = 'manual', + created_by: Optional[str] = None + ) -> Tuple[bool, str, Optional[int]]: + """ + Create a full backup of database and files + + Returns: (success, message, backup_id) + """ + backup_id = None + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + filename = f"backup_{timestamp}.zip" + filepath = BACKUP_DIR / filename + + try: + # Create backup record + result = await self.db.execute(text(""" + INSERT INTO backup_history ( + backup_type, status, filename, started_at, created_by + ) VALUES ( + :backup_type, 'running', :filename, NOW(), :created_by + ) RETURNING id + """), { + 'backup_type': backup_type, + 'filename': filename, + 'created_by': created_by + }) + await self.db.commit() + row = result.fetchone() + backup_id = row.id if row else None + + # Create temporary directory for backup contents + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # 1. Create PostgreSQL dump + db_url = os.getenv('DATABASE_URL', 'postgresql://forecast:forecast_secret@localhost:5432/forecast') + db_parts = db_url.replace('postgresql://', '').split('@') + user_pass = db_parts[0].split(':') + host_db = db_parts[1].split('/') + + db_dump_path = temp_path / 'database.sql' + env = os.environ.copy() + env['PGPASSWORD'] = user_pass[1] if len(user_pass) > 1 else '' + + pg_dump_cmd = [ + 'pg_dump', + '-h', host_db[0].split(':')[0], + '-U', user_pass[0], + '-d', host_db[1] if len(host_db) > 1 else 'forecast', + '-f', str(db_dump_path), + '--no-owner', + '--no-acl' + ] + + subprocess.run(pg_dump_cmd, env=env, check=True, capture_output=True) + + # 2. Create JSON export + db_json_path = temp_path / 'database.json' + snapshot_count = await self._export_database_json(db_json_path) + + # 3. Copy files + files_dir = temp_path / 'files' + file_count = self._copy_data_files(files_dir) + + # 4. Create metadata + metadata = { + 'version': '1.0', + 'timestamp': datetime.now().isoformat(), + 'snapshot_count': snapshot_count, + 'file_count': file_count, + 'database_url': db_url.split('@')[1] # Only host/db, not credentials + } + metadata_path = temp_path / 'metadata.json' + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + # 5. Create ZIP file + with zipfile.ZipFile(filepath, 'w', zipfile.ZIP_DEFLATED) as zf: + for file in temp_path.rglob('*'): + if file.is_file(): + arcname = file.relative_to(temp_path) + zf.write(file, arcname) + + # Update backup record with success + file_size = filepath.stat().st_size + await self.db.execute(text(""" + UPDATE backup_history + SET status = 'success', + file_path = :file_path, + file_size_bytes = :file_size, + snapshot_count = :snapshot_count, + file_count = :file_count, + completed_at = NOW() + WHERE id = :backup_id + """), { + 'backup_id': backup_id, + 'file_path': str(filepath), + 'file_size': file_size, + 'snapshot_count': snapshot_count, + 'file_count': file_count + }) + await self.db.commit() + + # Update last backup settings + await self.update_backup_settings({ + 'backup_last_run_at': datetime.now().isoformat(), + 'backup_last_status': 'success' + }) + + # Enforce retention policy + await self._enforce_retention() + + return True, f"Backup created successfully: {filename}", backup_id + + except Exception as e: + logger.error(f"Backup creation failed: {e}") + + # Update backup record with failure + if backup_id: + await self.db.execute(text(""" + UPDATE backup_history + SET status = 'failed', + error_message = :error, + completed_at = NOW() + WHERE id = :backup_id + """), { + 'backup_id': backup_id, + 'error': str(e) + }) + await self.db.commit() + + # Update last backup status + await self.update_backup_settings({ + 'backup_last_run_at': datetime.now().isoformat(), + 'backup_last_status': 'failed' + }) + + return False, f"Backup failed: {str(e)}", backup_id + + async def _export_database_json(self, output_path: Path) -> int: + """Export database tables to JSON format""" + export_data = { + 'exported_at': datetime.now().isoformat(), + 'tables': {} + } + + # Tables to export + tables = [ + 'forecast_snapshots', + 'special_dates', + 'newbook_bookings_data', + 'newbook_bookings_stats', + 'newbook_booking_pace', + 'newbook_occupancy_report_data', + 'newbook_room_categories', + 'monthly_budgets', + 'system_config', + 'users' + ] + + snapshot_count = 0 + for table in tables: + try: + result = await self.db.execute(text(f"SELECT * FROM {table}")) + rows = result.fetchall() + columns = result.keys() + + export_data['tables'][table] = [ + {col: self._serialize_value(getattr(row, col)) for col in columns} + for row in rows + ] + + if table == 'forecast_snapshots': + snapshot_count = len(rows) + + except Exception as e: + logger.warning(f"Could not export table {table}: {e}") + export_data['tables'][table] = [] + + with open(output_path, 'w') as f: + json.dump(export_data, f, indent=2, default=str) + + return snapshot_count + + def _serialize_value(self, value): + """Convert value to JSON-serializable format""" + if isinstance(value, (datetime,)): + return value.isoformat() + return value + + def _copy_data_files(self, dest_dir: Path) -> int: + """Copy all data files to backup directory""" + dest_dir.mkdir(parents=True, exist_ok=True) + file_count = 0 + + # Skip backup directory itself + for root, dirs, files in os.walk(DATA_DIR): + # Remove backup directory from traversal + dirs[:] = [d for d in dirs if d != 'backups'] + + for file in files: + src_file = Path(root) / file + rel_path = src_file.relative_to(DATA_DIR) + dest_file = dest_dir / rel_path + + dest_file.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dest_file) + file_count += 1 + + return file_count + + async def list_backups(self, limit: int = 50) -> list: + """List all backups, newest first""" + result = await self.db.execute(text(""" + SELECT + id, backup_type, status, filename, file_path, + file_size_bytes, snapshot_count, file_count, + started_at, completed_at, error_message, created_by + FROM backup_history + ORDER BY started_at DESC + LIMIT :limit + """), {'limit': limit}) + + rows = result.fetchall() + return [ + { + 'id': row.id, + 'backup_type': row.backup_type, + 'status': row.status, + 'filename': row.filename, + 'file_path': row.file_path, + 'file_size_bytes': row.file_size_bytes, + 'snapshot_count': row.snapshot_count, + 'file_count': row.file_count, + 'started_at': row.started_at.isoformat() if row.started_at else None, + 'completed_at': row.completed_at.isoformat() if row.completed_at else None, + 'error_message': row.error_message, + 'created_by': row.created_by + } + for row in rows + ] + + async def get_backup(self, backup_id: int) -> Optional[Dict]: + """Get a specific backup by ID""" + result = await self.db.execute(text(""" + SELECT + id, backup_type, status, filename, file_path, + file_size_bytes, snapshot_count, file_count, + started_at, completed_at, error_message, created_by + FROM backup_history + WHERE id = :backup_id + """), {'backup_id': backup_id}) + + row = result.fetchone() + if not row: + return None + + return { + 'id': row.id, + 'backup_type': row.backup_type, + 'status': row.status, + 'filename': row.filename, + 'file_path': row.file_path, + 'file_size_bytes': row.file_size_bytes, + 'snapshot_count': row.snapshot_count, + 'file_count': row.file_count, + 'started_at': row.started_at.isoformat() if row.started_at else None, + 'completed_at': row.completed_at.isoformat() if row.completed_at else None, + 'error_message': row.error_message, + 'created_by': row.created_by + } + + async def delete_backup(self, backup_id: int) -> Tuple[bool, str]: + """Delete a backup file and record""" + try: + # Get backup info + backup = await self.get_backup(backup_id) + if not backup: + return False, "Backup not found" + + # Delete file if it exists + if backup['file_path']: + file_path = Path(backup['file_path']) + if file_path.exists(): + file_path.unlink() + + # Delete database record + await self.db.execute(text(""" + DELETE FROM backup_history WHERE id = :backup_id + """), {'backup_id': backup_id}) + await self.db.commit() + + return True, "Backup deleted successfully" + + except Exception as e: + logger.error(f"Failed to delete backup: {e}") + await self.db.rollback() + return False, f"Failed to delete backup: {str(e)}" + + async def restore_from_backup(self, backup_id: int) -> Tuple[bool, str]: + """Restore database and files from a backup""" + try: + # Get backup info + backup = await self.get_backup(backup_id) + if not backup: + return False, "Backup not found" + + if backup['status'] != 'success': + return False, "Cannot restore from failed backup" + + backup_path = Path(backup['file_path']) + if not backup_path.exists(): + return False, "Backup file not found" + + return await self._restore_from_file(backup_path) + + except Exception as e: + logger.error(f"Restore failed: {e}") + return False, f"Restore failed: {str(e)}" + + async def restore_from_upload(self, file_content: bytes, filename: str) -> Tuple[bool, str]: + """Restore from an uploaded backup file""" + try: + # Save uploaded file temporarily + with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file: + temp_file.write(file_content) + temp_path = Path(temp_file.name) + + try: + # Validate ZIP file + if not zipfile.is_zipfile(temp_path): + return False, "Invalid backup file (not a ZIP file)" + + return await self._restore_from_file(temp_path) + finally: + # Clean up temp file + if temp_path.exists(): + temp_path.unlink() + + except Exception as e: + logger.error(f"Upload restore failed: {e}") + return False, f"Restore failed: {str(e)}" + + async def _restore_from_file(self, backup_path: Path) -> Tuple[bool, str]: + """Internal method to restore from a backup ZIP file""" + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Extract ZIP + with zipfile.ZipFile(backup_path, 'r') as zf: + zf.extractall(temp_path) + + # Validate required files + db_sql_path = temp_path / 'database.sql' + if not db_sql_path.exists(): + return False, "Invalid backup: missing database.sql" + + # Restore database + db_url = os.getenv('DATABASE_URL', 'postgresql://forecast:forecast_secret@localhost:5432/forecast') + db_parts = db_url.replace('postgresql://', '').split('@') + user_pass = db_parts[0].split(':') + host_db = db_parts[1].split('/') + + env = os.environ.copy() + env['PGPASSWORD'] = user_pass[1] if len(user_pass) > 1 else '' + + psql_cmd = [ + 'psql', + '-h', host_db[0].split(':')[0], + '-U', user_pass[0], + '-d', host_db[1] if len(host_db) > 1 else 'forecast', + '-f', str(db_sql_path) + ] + + result = subprocess.run(psql_cmd, env=env, capture_output=True, text=True) + if result.returncode != 0: + logger.error(f"Database restore failed: {result.stderr}") + return False, f"Database restore failed: {result.stderr}" + + # Restore files + files_dir = temp_path / 'files' + if files_dir.exists(): + file_count = 0 + for root, dirs, files in os.walk(files_dir): + for file in files: + src_file = Path(root) / file + rel_path = src_file.relative_to(files_dir) + dest_file = DATA_DIR / rel_path + + dest_file.parent.mkdir(parents=True, exist_ok=True) + # Don't overwrite existing files + if not dest_file.exists(): + shutil.copy2(src_file, dest_file) + file_count += 1 + + logger.info(f"Restored {file_count} files") + + return True, "Backup restored successfully" + + except Exception as e: + logger.error(f"Restore from file failed: {e}") + return False, f"Restore failed: {str(e)}" + + async def _enforce_retention(self): + """Delete old backups beyond retention count""" + try: + settings = await self.get_backup_settings() + retention_count = int(settings.get('backup_retention_count', 7)) + + # Get backups to delete (beyond retention count) + result = await self.db.execute(text(""" + SELECT id, file_path + FROM backup_history + WHERE status = 'success' + ORDER BY started_at DESC + OFFSET :retention_count + """), {'retention_count': retention_count}) + + rows = result.fetchall() + for row in rows: + # Delete file + if row.file_path: + file_path = Path(row.file_path) + if file_path.exists(): + file_path.unlink() + + # Delete record + await self.db.execute(text(""" + DELETE FROM backup_history WHERE id = :backup_id + """), {'backup_id': row.id}) + + await self.db.commit() + logger.info(f"Retention policy: deleted {len(rows)} old backups") + + except Exception as e: + logger.error(f"Failed to enforce retention policy: {e}") diff --git a/backend/services/booking_scraper.py b/backend/services/booking_scraper.py new file mode 100644 index 0000000..a3bd9ce --- /dev/null +++ b/backend/services/booking_scraper.py @@ -0,0 +1,829 @@ +""" +Booking.com Rate Scraper Service + +Main service for scraping competitor rates from booking.com. +Uses pluggable backends (Playwright local, proxy, Apify) via factory pattern. + +Features: +- Location-based search (1 query = 40+ hotels) +- Hotel discovery and tier management +- Rate extraction with availability status +- Anti-scrape detection and pause/resume +""" + +import logging +import uuid +from datetime import date, datetime, timedelta +from decimal import Decimal +from typing import List, Optional, Dict, Any + +from sqlalchemy import text +from sqlalchemy.orm import Session + +from .scraper_backends import ScraperBackend, PlaywrightLocalBackend, HotelData, RateData, AvailabilityStatus + +logger = logging.getLogger(__name__) + + +def get_scraper_backend(db: Session) -> ScraperBackend: + """ + Factory to get configured scraper backend. + + Reads backend type from system_config and returns appropriate instance. + """ + # Get backend configuration + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_backend'") + ).fetchone() + + backend_type = result.config_value if result and result.config_value else 'playwright_local' + + if backend_type == 'playwright_local': + return PlaywrightLocalBackend() + + elif backend_type == 'playwright_proxy': + # Get proxy config + proxy_result = db.execute( + text(""" + SELECT config_key, config_value FROM system_config + WHERE config_key IN ('booking_scraper_proxy_url', 'booking_scraper_proxy_username', 'booking_scraper_proxy_password') + """) + ) + proxy_config = {row.config_key: row.config_value for row in proxy_result.fetchall()} + return PlaywrightLocalBackend(proxy_config=proxy_config) + + elif backend_type == 'apify': + # Future: Apify backend + raise NotImplementedError("Apify backend not yet implemented") + + else: + logger.warning(f"Unknown backend type '{backend_type}', falling back to playwright_local") + return PlaywrightLocalBackend() + + +def get_scrape_config(db: Session) -> Optional[Dict[str, Any]]: + """Get the active scrape location configuration.""" + result = db.execute( + text(""" + SELECT id, location_name, location_search_url, pages_to_scrape, adults + FROM booking_scrape_config + WHERE is_active = TRUE + ORDER BY id + LIMIT 1 + """) + ).fetchone() + + if not result: + return None + + return { + 'id': result.id, + 'location_name': result.location_name, + 'location_search_url': result.location_search_url, + 'pages_to_scrape': result.pages_to_scrape or 2, + 'adults': result.adults or 2, + } + + +async def is_scraper_paused(db: Session) -> bool: + """Check if scraper is currently paused due to blocking.""" + result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'") + ).fetchone() + + if not result or result.config_value != 'true': + return False + + # Check if pause period has expired + pause_until_result = db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_pause_until'") + ).fetchone() + + if pause_until_result and pause_until_result.config_value: + try: + pause_until = datetime.fromisoformat(pause_until_result.config_value) + if datetime.now() >= pause_until: + # Pause expired, reset + db.execute( + text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'") + ) + db.commit() + return False + except ValueError: + pass + + return True + + +async def set_scraper_paused(db: Session, paused: bool, hours: int = 2): + """Set scraper pause status.""" + db.execute( + text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_paused'"), + {'val': 'true' if paused else 'false'} + ) + if paused: + pause_until = (datetime.now() + timedelta(hours=hours)).isoformat() + db.execute( + text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_pause_until'"), + {'val': pause_until} + ) + db.commit() + + +def save_hotel(db: Session, hotel: HotelData) -> int: + """ + Save or update a hotel in the database. + + Returns the hotel's database ID. + """ + # Check if hotel exists + existing = db.execute( + text("SELECT id FROM booking_com_hotels WHERE booking_com_id = :bid"), + {'bid': hotel.booking_com_id} + ).fetchone() + + if existing: + # Update last_seen_at and any changed fields + db.execute( + text(""" + UPDATE booking_com_hotels SET + name = COALESCE(:name, name), + booking_com_url = COALESCE(:url, booking_com_url), + star_rating = COALESCE(:stars, star_rating), + review_score = COALESCE(:score, review_score), + review_count = COALESCE(:count, review_count), + last_seen_at = NOW() + WHERE booking_com_id = :bid + """), + { + 'bid': hotel.booking_com_id, + 'name': hotel.name, + 'url': hotel.booking_com_url, + 'stars': float(hotel.star_rating) if hotel.star_rating else None, + 'score': float(hotel.review_score) if hotel.review_score else None, + 'count': hotel.review_count, + } + ) + return existing.id + else: + # Insert new hotel (default tier is 'market') + result = db.execute( + text(""" + INSERT INTO booking_com_hotels + (booking_com_id, name, booking_com_url, star_rating, review_score, review_count, tier) + VALUES (:bid, :name, :url, :stars, :score, :count, 'market') + RETURNING id + """), + { + 'bid': hotel.booking_com_id, + 'name': hotel.name, + 'url': hotel.booking_com_url, + 'stars': float(hotel.star_rating) if hotel.star_rating else None, + 'score': float(hotel.review_score) if hotel.review_score else None, + 'count': hotel.review_count, + } + ) + return result.fetchone().id + + +def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID): + """Save a rate to the database.""" + db.execute( + text(""" + INSERT INTO booking_com_rates + (hotel_id, rate_date, availability_status, rate_gross, currency, room_type, + breakfast_included, free_cancellation, no_prepayment, rooms_left, scrape_batch_id) + VALUES (:hotel_id, :rate_date, :status, :rate, :currency, :room_type, + :breakfast, :cancel, :prepay, :rooms_left, :batch_id) + """), + { + 'hotel_id': hotel_id, + 'rate_date': rate.rate_date, + 'status': rate.availability_status.value, + 'rate': float(rate.rate_gross) if rate.rate_gross else None, + 'currency': rate.currency, + 'room_type': rate.room_type, + 'breakfast': rate.breakfast_included, + 'cancel': rate.free_cancellation, + 'prepay': rate.no_prepayment, + 'rooms_left': rate.rooms_left, + 'batch_id': str(batch_id), + } + ) + + +def create_scrape_batch(db: Session, scrape_type: str) -> uuid.UUID: + """Create a new scrape batch log entry.""" + batch_id = uuid.uuid4() + db.execute( + text(""" + INSERT INTO booking_scrape_log + (batch_id, scrape_type, started_at, status) + VALUES (:batch_id, :scrape_type, NOW(), 'running') + """), + {'batch_id': str(batch_id), 'scrape_type': scrape_type} + ) + db.commit() + return batch_id + + +def update_scrape_batch( + db: Session, + batch_id: uuid.UUID, + status: str, + hotels_found: int = 0, + rates_scraped: int = 0, + error_message: str = None, + blocked: bool = False +): + """Update scrape batch log with results.""" + db.execute( + text(""" + UPDATE booking_scrape_log SET + completed_at = CASE WHEN :status IN ('completed', 'failed', 'blocked') THEN NOW() ELSE NULL END, + status = :status, + hotels_found = :hotels, + rates_scraped = :rates, + error_message = :error, + blocked_at = CASE WHEN :blocked THEN NOW() ELSE NULL END, + resume_after = CASE WHEN :blocked THEN NOW() + INTERVAL '2 hours' ELSE NULL END + WHERE batch_id = :batch_id + """), + { + 'batch_id': str(batch_id), + 'status': status, + 'hotels': hotels_found, + 'rates': rates_scraped, + 'error': error_message, + 'blocked': blocked, + } + ) + db.commit() + + +def cleanup_stale_batches(db: Session, max_age_minutes: int = 60): + """ + Mark any 'running' scrape batches as 'failed' if they've been running + longer than max_age_minutes. This handles orphaned batches from + container restarts or crashes. + """ + result = db.execute( + text(""" + UPDATE booking_scrape_log SET + status = 'failed', + completed_at = NOW(), + error_message = 'Interrupted (container restart or timeout)' + WHERE status = 'running' + AND started_at < NOW() - INTERVAL ':mins minutes' + RETURNING batch_id + """.replace(':mins', str(int(max_age_minutes)))) + ) + cleaned = result.fetchall() + db.commit() + if cleaned: + logger.info(f"Cleaned up {len(cleaned)} stale running scrape batch(es)") + return len(cleaned) + + +async def scrape_date( + db: Session, + rate_date: date, + backend: ScraperBackend, + config: Dict[str, Any], + batch_id: uuid.UUID +) -> Dict[str, Any]: + """ + Scrape rates for a single date. + + Args: + db: Database session + rate_date: Date to scrape rates for + backend: Scraper backend instance + config: Scrape configuration + batch_id: Current batch ID + + Returns: + Dict with 'success', 'blocked', 'hotels_count', 'rates_count' + """ + check_in = rate_date + check_out = rate_date + timedelta(days=1) # Single night + + result = await backend.scrape_location_search( + location=config['location_name'], + check_in=check_in, + check_out=check_out, + adults=config['adults'], + pages=config['pages_to_scrape'] + ) + + if result.blocked: + return { + 'success': False, + 'blocked': True, + 'block_reason': result.block_reason, + 'hotels_count': 0, + 'rates_count': 0, + } + + if not result.success: + return { + 'success': False, + 'blocked': False, + 'error': result.error_message, + 'hotels_count': 0, + 'rates_count': 0, + } + + # Save hotels and rates + hotels_saved = 0 + rates_saved = 0 + + for hotel, rate in zip(result.hotels, result.rates): + if not hotel.booking_com_id: + continue + + try: + hotel_id = save_hotel(db, hotel) + save_rate(db, rate, hotel_id, batch_id) + hotels_saved += 1 + rates_saved += 1 + except Exception as e: + logger.warning(f"Error saving hotel/rate: {e}") + continue + + db.commit() + + return { + 'success': True, + 'blocked': False, + 'hotels_count': hotels_saved, + 'rates_count': rates_saved, + } + + +async def run_manual_scrape( + db: Session, + from_date: date, + to_date: date = None +) -> Dict[str, Any]: + """ + Run a manual scrape for testing/on-demand use. + + Args: + db: Database session + from_date: Start date + to_date: End date (defaults to from_date for single day) + + Returns: + Dict with scrape results summary + """ + if to_date is None: + to_date = from_date + + # Check if paused + if await is_scraper_paused(db): + return { + 'success': False, + 'error': 'Scraper is currently paused due to blocking. Try again later.', + } + + # Get config + config = get_scrape_config(db) + if not config: + return { + 'success': False, + 'error': 'No scrape location configured. Add a location in settings.', + } + + # Create batch + batch_id = create_scrape_batch(db, 'manual') + + # Get backend + backend = get_scraper_backend(db) + + total_hotels = 0 + total_rates = 0 + dates_completed = 0 + dates_failed = 0 + + try: + current_date = from_date + while current_date <= to_date: + logger.info(f"Scraping date: {current_date}") + + result = await scrape_date(db, current_date, backend, config, batch_id) + + if result['blocked']: + # Blocking detected - pause and exit + await set_scraper_paused(db, True, hours=2) + update_scrape_batch( + db, batch_id, + status='blocked', + hotels_found=total_hotels, + rates_scraped=total_rates, + error_message=f"Blocked: {result.get('block_reason', 'unknown')}", + blocked=True + ) + return { + 'success': False, + 'blocked': True, + 'block_reason': result.get('block_reason'), + 'dates_completed': dates_completed, + 'dates_failed': dates_failed, + 'hotels_found': total_hotels, + 'rates_scraped': total_rates, + } + + if result['success']: + total_hotels += result['hotels_count'] + total_rates += result['rates_count'] + dates_completed += 1 + else: + dates_failed += 1 + logger.warning(f"Failed to scrape {current_date}: {result.get('error')}") + + current_date += timedelta(days=1) + + # Update batch as completed + update_scrape_batch( + db, batch_id, + status='completed', + hotels_found=total_hotels, + rates_scraped=total_rates + ) + + return { + 'success': True, + 'blocked': False, + 'dates_completed': dates_completed, + 'dates_failed': dates_failed, + 'hotels_found': total_hotels, + 'rates_scraped': total_rates, + } + + except Exception as e: + logger.error(f"Scrape error: {e}") + update_scrape_batch( + db, batch_id, + status='failed', + hotels_found=total_hotels, + rates_scraped=total_rates, + error_message=str(e) + ) + return { + 'success': False, + 'error': str(e), + 'dates_completed': dates_completed, + 'dates_failed': dates_failed, + 'hotels_found': total_hotels, + 'rates_scraped': total_rates, + } + finally: + await backend.close() + + +# ============================================ +# QUEUE MANAGEMENT +# ============================================ + +def populate_queue(db: Session, dates: List[date], priorities: Dict[date, int] = None): + """ + Add dates to the scrape queue, skipping any already pending/processing. + + Args: + db: Database session + dates: Dates to add to the queue + priorities: Optional priority map (higher = scraped first). Default: 0 + """ + if not dates: + return 0 + + added = 0 + for rate_date in dates: + priority = (priorities or {}).get(rate_date, 0) + try: + db.execute( + text(""" + INSERT INTO booking_scrape_queue (rate_date, status, priority) + VALUES (:rate_date, 'pending', :priority) + ON CONFLICT (rate_date, status) DO UPDATE SET + priority = GREATEST(booking_scrape_queue.priority, :priority) + """), + {'rate_date': rate_date, 'priority': priority} + ) + added += 1 + except Exception: + # Ignore duplicates or constraint issues + pass + + db.commit() + logger.info(f"Queue: added/updated {added} dates") + return added + + +def get_pending_queue_items(db: Session, limit: int = 50) -> List[Dict[str, Any]]: + """Get pending queue items ordered by priority (highest first), then date.""" + result = db.execute( + text(""" + SELECT id, rate_date, priority, attempts, max_attempts + FROM booking_scrape_queue + WHERE status = 'pending' AND attempts < max_attempts + ORDER BY priority DESC, rate_date ASC + LIMIT :limit + """), + {'limit': limit} + ) + return [dict(row._mapping) for row in result.fetchall()] + + +def mark_queue_item(db: Session, queue_id: int, status: str, error_message: str = None): + """Update a queue item's status.""" + if status == 'completed': + db.execute( + text(""" + UPDATE booking_scrape_queue SET + status = 'completed', + completed_at = NOW(), + last_attempt_at = NOW(), + attempts = attempts + 1 + WHERE id = :id + """), + {'id': queue_id} + ) + elif status == 'failed': + db.execute( + text(""" + UPDATE booking_scrape_queue SET + status = CASE + WHEN attempts + 1 >= max_attempts THEN 'failed' + ELSE 'pending' + END, + last_attempt_at = NOW(), + attempts = attempts + 1, + error_message = :error + WHERE id = :id + """), + {'id': queue_id, 'error': error_message} + ) + db.commit() + + +def clear_old_queue_items(db: Session, days: int = 7): + """Remove completed/failed queue items older than N days.""" + db.execute( + text(""" + DELETE FROM booking_scrape_queue + WHERE status IN ('completed', 'failed') + AND created_at < NOW() - INTERVAL ':days days' + """.replace(':days', str(int(days)))) + ) + db.commit() + + +async def process_queue(db: Session) -> Dict[str, Any]: + """ + Process pending items from the scrape queue. + + Picks up pending items in priority order, scrapes each date, + and handles blocking/retries. + + Returns: + Dict with processing results + """ + # Check if paused + if await is_scraper_paused(db): + return { + 'success': False, + 'error': 'Scraper is currently paused due to blocking.', + } + + # Get config + config = get_scrape_config(db) + if not config: + return { + 'success': False, + 'error': 'No scrape location configured.', + } + + # Get pending items + items = get_pending_queue_items(db, limit=200) + if not items: + return {'success': True, 'dates_completed': 0, 'message': 'Queue empty'} + + # Create batch + batch_id = create_scrape_batch(db, 'scheduled') + + # Update batch with queue count + db.execute( + text("UPDATE booking_scrape_log SET dates_queued = :count WHERE batch_id = :bid"), + {'count': len(items), 'bid': str(batch_id)} + ) + db.commit() + + # Get backend + backend = get_scraper_backend(db) + + total_hotels = 0 + total_rates = 0 + dates_completed = 0 + dates_failed = 0 + + try: + for item in items: + rate_date = item['rate_date'] + queue_id = item['id'] + + logger.info(f"Queue processing: {rate_date} (priority={item['priority']}, attempt={item['attempts']+1})") + + result = await scrape_date(db, rate_date, backend, config, batch_id) + + if result['blocked']: + # Mark this item as failed, pause, and stop + mark_queue_item(db, queue_id, 'failed', f"Blocked: {result.get('block_reason')}") + await set_scraper_paused(db, True, hours=2) + update_scrape_batch( + db, batch_id, + status='blocked', + hotels_found=total_hotels, + rates_scraped=total_rates, + error_message=f"Blocked: {result.get('block_reason', 'unknown')}", + blocked=True + ) + # Update dates counters + db.execute( + text(""" + UPDATE booking_scrape_log SET + dates_completed = :completed, + dates_failed = :failed + WHERE batch_id = :bid + """), + {'completed': dates_completed, 'failed': dates_failed + 1, 'bid': str(batch_id)} + ) + db.commit() + return { + 'success': False, + 'blocked': True, + 'block_reason': result.get('block_reason'), + 'dates_completed': dates_completed, + 'dates_failed': dates_failed + 1, + 'hotels_found': total_hotels, + 'rates_scraped': total_rates, + } + + if result['success']: + mark_queue_item(db, queue_id, 'completed') + total_hotels += result['hotels_count'] + total_rates += result['rates_count'] + dates_completed += 1 + else: + mark_queue_item(db, queue_id, 'failed', result.get('error')) + dates_failed += 1 + logger.warning(f"Queue: failed to scrape {rate_date}: {result.get('error')}") + + # Update batch as completed + update_scrape_batch( + db, batch_id, + status='completed', + hotels_found=total_hotels, + rates_scraped=total_rates + ) + db.execute( + text(""" + UPDATE booking_scrape_log SET + dates_completed = :completed, + dates_failed = :failed + WHERE batch_id = :bid + """), + {'completed': dates_completed, 'failed': dates_failed, 'bid': str(batch_id)} + ) + db.commit() + + return { + 'success': True, + 'blocked': False, + 'dates_completed': dates_completed, + 'dates_failed': dates_failed, + 'hotels_found': total_hotels, + 'rates_scraped': total_rates, + } + + except Exception as e: + logger.error(f"Queue processing error: {e}") + update_scrape_batch( + db, batch_id, + status='failed', + hotels_found=total_hotels, + rates_scraped=total_rates, + error_message=str(e) + ) + return { + 'success': False, + 'error': str(e), + 'dates_completed': dates_completed, + 'dates_failed': dates_failed, + } + finally: + await backend.close() + + +def get_competitor_matrix( + db: Session, + from_date: date, + to_date: date, + include_market: bool = False +) -> List[Dict[str, Any]]: + """ + Get rate comparison matrix for competitors. + + Args: + db: Database session + from_date: Start date + to_date: End date + include_market: Include market tier hotels + + Returns: + List of rate records for matrix display + """ + tier_filter = "h.tier IN ('own', 'competitor')" + if include_market: + tier_filter = "h.tier IN ('own', 'competitor', 'market')" + + result = db.execute( + text(f""" + SELECT + r.rate_date, + h.id AS hotel_id, + h.name AS hotel_name, + h.tier, + h.display_order, + h.star_rating, + h.review_score, + 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_latest_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 BETWEEN :from_date AND :to_date + ORDER BY r.rate_date, h.display_order, h.name + """), + {'from_date': from_date, 'to_date': to_date} + ) + + return [dict(row._mapping) for row in result.fetchall()] + + +def get_hotels_list(db: Session, tier: str = None) -> List[Dict[str, Any]]: + """ + Get list of discovered hotels. + + Args: + db: Database session + tier: Filter by tier ('own', 'competitor', 'market') or None for all + + Returns: + List of hotel records + """ + where_clause = "WHERE is_active = TRUE" + if tier: + where_clause += f" AND tier = '{tier}'" + + result = db.execute( + text(f""" + 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_clause} + ORDER BY display_order, name + """) + ) + + return [dict(row._mapping) for row in result.fetchall()] + + +def update_hotel_tier(db: Session, hotel_id: int, tier: str, display_order: int = None): + """Update a hotel's tier and display order.""" + if tier not in ('own', 'competitor', 'market'): + raise ValueError(f"Invalid tier: {tier}") + + params = {'hotel_id': hotel_id, 'tier': tier} + set_clause = "tier = :tier" + + if display_order is not None: + set_clause += ", display_order = :order" + params['order'] = display_order + + db.execute( + text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id"), + params + ) + db.commit() diff --git a/backend/services/forecasting/__init__.py b/backend/services/forecasting/__init__.py new file mode 100644 index 0000000..ee13f9b --- /dev/null +++ b/backend/services/forecasting/__init__.py @@ -0,0 +1 @@ +# Forecasting models diff --git a/backend/services/forecasting/backtest.py b/backend/services/forecasting/backtest.py new file mode 100644 index 0000000..ca1510f --- /dev/null +++ b/backend/services/forecasting/backtest.py @@ -0,0 +1,401 @@ +""" +Backtesting service for forecast model evaluation. + +Simulates historical forecasts using only data that would have been +available at the time, then compares to actual outcomes. + +This allows model accuracy evaluation without waiting for real-time +data to accumulate. +""" +import logging +from datetime import date, timedelta +from typing import List, Optional, Dict +from sqlalchemy import text + +from utils.time_alignment import get_prior_year_daily + +logger = logging.getLogger(__name__) + + +async def run_backtest( + db, + metric_code: str, + backtest_from: date, + backtest_to: date, + lead_times: List[int] = None +) -> Dict: + """ + Run backtesting for a metric over a date range. + + For each date in the range, simulates what the forecast would have been + at various lead times, using only data available at that time. + + Args: + db: Database session + metric_code: Metric to backtest (e.g., 'hotel_room_nights') + backtest_from: Start of backtest period + backtest_to: End of backtest period + lead_times: List of lead times to test (days out). Default: [7, 14, 21, 28] + + Returns: + Dict with backtest results and accuracy metrics + """ + if lead_times is None: + lead_times = [7, 14, 21, 28] + + logger.info(f"Running backtest for {metric_code} from {backtest_from} to {backtest_to}") + + results = [] + total_rooms = 25 # Default capacity + + # Get room capacity (SUM across all room categories for a single date) + if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + rooms_result = db.execute( + text(""" + SELECT COALESCE(SUM(available), 25) as total_rooms + FROM newbook_occupancy_report + WHERE date = ( + SELECT MAX(date) FROM newbook_occupancy_report + WHERE date <= :from_date + ) + """), + {"from_date": backtest_from} + ) + rooms_row = rooms_result.fetchone() + if rooms_row and rooms_row.total_rooms: + total_rooms = int(rooms_row.total_rooms) + + # For each date in backtest range + current_date = backtest_from + while current_date <= backtest_to: + # Get actual value for this date + actual_result = db.execute( + text(""" + SELECT actual_value + FROM daily_metrics + WHERE date = :target_date AND metric_code = :metric + """), + {"target_date": current_date, "metric": metric_code} + ).fetchone() + + actual_value = float(actual_result.actual_value) if actual_result and actual_result.actual_value else None + + if actual_value is None: + current_date += timedelta(days=1) + continue + + # For each lead time, simulate the forecast + for lead_time in lead_times: + # The "simulated today" is lead_time days before the target date + simulated_today = current_date - timedelta(days=lead_time) + + # Get OTB snapshot that would have been available + # Look for snapshot closest to simulated_today + otb_result = db.execute( + text(""" + SELECT otb_value, snapshot_date, days_out + FROM pickup_snapshots + WHERE stay_date = :target_date + AND metric_type = :metric + AND snapshot_date <= :simulated_today + ORDER BY snapshot_date DESC + LIMIT 1 + """), + { + "target_date": current_date, + "metric": metric_code, + "simulated_today": simulated_today + } + ).fetchone() + + if not otb_result: + continue + + # Use 'is not None' - 0 is valid OTB data + current_otb = float(otb_result.otb_value) if otb_result.otb_value is not None else 0 + actual_lead_time = otb_result.days_out or lead_time + + # Get prior year comparison data (same day of week) + prior_year_date = get_prior_year_daily(current_date) + prior_year_simulated_today = get_prior_year_daily(simulated_today) + + # Get prior year OTB at same lead time + prior_otb_result = db.execute( + text(""" + SELECT otb_value + FROM pickup_snapshots + WHERE stay_date = :prior_date + AND metric_type = :metric + AND snapshot_date <= :prior_simulated_today + ORDER BY snapshot_date DESC + LIMIT 1 + """), + { + "prior_date": prior_year_date, + "metric": metric_code, + "prior_simulated_today": prior_year_simulated_today + } + ).fetchone() + + # Use 'is not None' - 0 is valid OTB data + prior_otb = float(prior_otb_result.otb_value) if prior_otb_result and prior_otb_result.otb_value is not None else None + + # Get prior year final actual + prior_final_result = db.execute( + text(""" + SELECT actual_value + FROM daily_metrics + WHERE date = :prior_date AND metric_code = :metric + """), + {"prior_date": prior_year_date, "metric": metric_code} + ).fetchone() + + prior_final = float(prior_final_result.actual_value) if prior_final_result and prior_final_result.actual_value else None + + # Calculate forecast using ADDITIVE method + projected_value = current_otb + projection_method = 'current_otb' + + if prior_otb is not None and prior_final is not None: + # Additive method: current + expected pickup + prior_pickup = prior_final - prior_otb + projected_value = current_otb + prior_pickup + + # Floor at current OTB + if projected_value < current_otb: + projected_value = current_otb + projection_method = 'additive_floor' + else: + projection_method = 'additive' + + # Apply physical caps + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + if metric_code == 'hotel_room_nights' and projected_value > total_rooms: + projected_value = total_rooms + + elif prior_final is not None and prior_final > 0: + # Implied additive method + if lead_time >= 28: + estimated_pct = 0.35 + elif lead_time >= 14: + estimated_pct = 0.55 + elif lead_time >= 7: + estimated_pct = 0.75 + else: + estimated_pct = 0.90 + + implied_prior_otb = prior_final * estimated_pct + implied_pickup = prior_final - implied_prior_otb + projected_value = current_otb + implied_pickup + projected_value = max(projected_value, current_otb) + projection_method = 'implied_additive' + + # Apply caps + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + if metric_code == 'hotel_room_nights' and projected_value > total_rooms: + projected_value = total_rooms + + # Calculate error metrics + error = projected_value - actual_value + abs_error = abs(error) + pct_error = (error / actual_value * 100) if actual_value != 0 else None + abs_pct_error = abs(pct_error) if pct_error is not None else None + + result_record = { + "target_date": current_date, + "lead_time": lead_time, + "actual_lead_time": actual_lead_time, + "simulated_today": simulated_today, + "current_otb": current_otb, + "prior_otb": prior_otb, + "prior_final": prior_final, + "projected_value": round(projected_value, 2), + "actual_value": actual_value, + "error": round(error, 2), + "abs_error": round(abs_error, 2), + "pct_error": round(pct_error, 2) if pct_error is not None else None, + "abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None, + "projection_method": projection_method + } + results.append(result_record) + + # Store in backtest_results table + try: + db.execute( + text(""" + INSERT INTO backtest_results ( + target_date, metric_code, lead_time, simulated_today, + current_otb, prior_otb, prior_final, + projected_value, actual_value, + error, abs_error, pct_error, abs_pct_error, + projection_method, created_at + ) VALUES ( + :target_date, :metric, :lead_time, :simulated_today, + :current_otb, :prior_otb, :prior_final, + :projected_value, :actual_value, + :error, :abs_error, :pct_error, :abs_pct_error, + :projection_method, NOW() + ) + ON CONFLICT (target_date, metric_code, lead_time) DO UPDATE SET + projected_value = :projected_value, + actual_value = :actual_value, + error = :error, + abs_error = :abs_error, + pct_error = :pct_error, + abs_pct_error = :abs_pct_error, + projection_method = :projection_method, + created_at = NOW() + """), + { + "target_date": current_date, + "metric": metric_code, + "lead_time": lead_time, + "simulated_today": simulated_today, + "current_otb": current_otb, + "prior_otb": prior_otb, + "prior_final": prior_final, + "projected_value": round(projected_value, 2), + "actual_value": actual_value, + "error": round(error, 2), + "abs_error": round(abs_error, 2), + "pct_error": round(pct_error, 2) if pct_error is not None else None, + "abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None, + "projection_method": projection_method + } + ) + except Exception as e: + logger.warning(f"Could not store backtest result: {e}") + + current_date += timedelta(days=1) + + db.commit() + + # Calculate summary statistics + summary = calculate_backtest_summary(results, lead_times) + + logger.info(f"Backtest complete: {len(results)} forecasts evaluated") + + return { + "metric_code": metric_code, + "backtest_from": str(backtest_from), + "backtest_to": str(backtest_to), + "lead_times": lead_times, + "total_forecasts": len(results), + "results": results, + "summary": summary + } + + +def calculate_backtest_summary(results: List[dict], lead_times: List[int]) -> Dict: + """ + Calculate summary accuracy metrics from backtest results. + """ + if not results: + return {} + + summary = { + "overall": {}, + "by_lead_time": {} + } + + # Overall metrics + all_errors = [r['abs_error'] for r in results if r['abs_error'] is not None] + all_pct_errors = [r['abs_pct_error'] for r in results if r['abs_pct_error'] is not None] + + if all_errors: + summary["overall"] = { + "mae": round(sum(all_errors) / len(all_errors), 2), # Mean Absolute Error + "mape": round(sum(all_pct_errors) / len(all_pct_errors), 2) if all_pct_errors else None, # Mean Absolute Percentage Error + "count": len(all_errors) + } + + # By lead time + for lt in lead_times: + lt_results = [r for r in results if r['lead_time'] == lt] + lt_errors = [r['abs_error'] for r in lt_results if r['abs_error'] is not None] + lt_pct_errors = [r['abs_pct_error'] for r in lt_results if r['abs_pct_error'] is not None] + + if lt_errors: + summary["by_lead_time"][lt] = { + "mae": round(sum(lt_errors) / len(lt_errors), 2), + "mape": round(sum(lt_pct_errors) / len(lt_pct_errors), 2) if lt_pct_errors else None, + "count": len(lt_errors) + } + + # By projection method + methods = set(r['projection_method'] for r in results) + summary["by_method"] = {} + for method in methods: + method_results = [r for r in results if r['projection_method'] == method] + method_errors = [r['abs_error'] for r in method_results if r['abs_error'] is not None] + method_pct_errors = [r['abs_pct_error'] for r in method_results if r['abs_pct_error'] is not None] + + if method_errors: + summary["by_method"][method] = { + "mae": round(sum(method_errors) / len(method_errors), 2), + "mape": round(sum(method_pct_errors) / len(method_pct_errors), 2) if method_pct_errors else None, + "count": len(method_errors) + } + + return summary + + +async def get_backtest_results( + db, + metric_code: str, + from_date: Optional[date] = None, + to_date: Optional[date] = None, + lead_time: Optional[int] = None +) -> List[dict]: + """ + Retrieve stored backtest results. + """ + query = """ + SELECT + target_date, metric_code, lead_time, simulated_today, + current_otb, prior_otb, prior_final, + projected_value, actual_value, + error, abs_error, pct_error, abs_pct_error, + projection_method, created_at + FROM backtest_results + WHERE metric_code = :metric + """ + params = {"metric": metric_code} + + if from_date: + query += " AND target_date >= :from_date" + params["from_date"] = from_date + + if to_date: + query += " AND target_date <= :to_date" + params["to_date"] = to_date + + if lead_time: + query += " AND lead_time = :lead_time" + params["lead_time"] = lead_time + + query += " ORDER BY target_date, lead_time" + + result = db.execute(text(query), params) + + return [ + { + "target_date": str(row.target_date), + "metric_code": row.metric_code, + "lead_time": row.lead_time, + "simulated_today": str(row.simulated_today) if row.simulated_today else None, + "current_otb": float(row.current_otb) if row.current_otb is not None else None, + "prior_otb": float(row.prior_otb) if row.prior_otb is not None else None, + "prior_final": float(row.prior_final) if row.prior_final is not None else None, + "projected_value": float(row.projected_value) if row.projected_value is not None else None, + "actual_value": float(row.actual_value) if row.actual_value is not None else None, + "error": float(row.error) if row.error is not None else None, + "abs_error": float(row.abs_error) if row.abs_error is not None else None, + "pct_error": float(row.pct_error) if row.pct_error is not None else None, + "abs_pct_error": float(row.abs_pct_error) if row.abs_pct_error is not None else None, + "projection_method": row.projection_method + } + for row in result.fetchall() + ] diff --git a/backend/services/forecasting/blended_model.py b/backend/services/forecasting/blended_model.py new file mode 100644 index 0000000..db4195b --- /dev/null +++ b/backend/services/forecasting/blended_model.py @@ -0,0 +1,159 @@ +""" +Centralized Blended Forecasting Model Service + +This is the single source of truth for blended forecasts. +Used by: +- Weekly snapshots (saves to DB) +- Frontend live previews (on-the-fly) +- External apps (reads saved snapshots) +""" +import logging +from datetime import date, timedelta +from typing import List, Dict, Optional +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +async def run_blended_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + save_to_db: bool = False, + run_id: Optional[str] = None +) -> List[Dict]: + """ + Generate blended forecast by running Prophet, XGBoost, CatBoost and blending with accuracy weights. + + This is the centralized blended model used everywhere in the application. + + Args: + db: Database session + metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights') + start_date: Start date for forecast + end_date: End date for forecast + save_to_db: If True, saves forecasts to database (for snapshots) + run_id: Run ID for tracking (required if save_to_db=True) + + Returns: + List of forecast dicts with date and predicted_value + """ + logger.info(f"Running blended forecast for {metric_code}: {start_date} to {end_date}") + + # Use simple equal-weight averaging to match frontend behavior + # Frontend: (prophet + xgboost + catboost) / 3 + logger.info(f"Using simple equal-weight averaging for {metric_code}") + + # Step 1: Run individual models + forecasts_by_date = {} + + # Run Prophet + try: + from services.forecasting.prophet_model import run_prophet_forecast + prophet_forecasts = await run_prophet_forecast(db, metric_code, start_date, end_date) + for fc in prophet_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value']) + logger.info(f"Prophet generated {len(prophet_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"Prophet forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run XGBoost + try: + from services.forecasting.xgboost_model import run_xgboost_forecast + xgboost_forecasts = await run_xgboost_forecast(db, metric_code, start_date, end_date) + for fc in xgboost_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value']) + logger.info(f"XGBoost generated {len(xgboost_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"XGBoost forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run CatBoost + try: + from services.forecasting.catboost_model import run_catboost_forecast + catboost_forecasts = await run_catboost_forecast(db, metric_code, start_date, end_date) + for fc in catboost_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value']) + logger.info(f"CatBoost generated {len(catboost_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"CatBoost forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run Pickup-V2 for revenue metrics (net_accom, hotel_accommodation_rev) + if metric_code in ('net_accom', 'hotel_accommodation_rev'): + try: + from services.forecasting.pickup_v2_model import run_pickup_v2_forecast + pickup_v2_forecasts = await run_pickup_v2_forecast(db, 'net_accom', start_date, end_date) + for fc in pickup_v2_forecasts: + fc_date = str(fc['date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['pickup_v2'] = float(fc['predicted_value']) + logger.info(f"Pickup-V2 generated {len(pickup_v2_forecasts)} forecasts for {metric_code}") + db.commit() + except Exception as e: + logger.error(f"Pickup-V2 forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() + + # Step 3: Calculate simple average blended forecast + # Match frontend logic: (prophet + xgboost + catboost) / 3 + blended_forecasts = [] + for fc_date, model_forecasts in forecasts_by_date.items(): + # Need at least 2 models to blend + if len(model_forecasts) < 2: + continue + + # Simple average of all available models + blended_value = sum(model_forecasts.values()) / len(model_forecasts) + + blended_forecasts.append({ + 'date': fc_date, + 'predicted_value': round(blended_value, 2) + }) + + logger.info(f"Generated {len(blended_forecasts)} blended forecasts for {metric_code}") + + # Step 4: Optionally save to database (for snapshots) + if save_to_db and run_id: + try: + for fc in blended_forecasts: + db.execute( + text(""" + INSERT INTO forecasts + (run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at) + VALUES + (:run_id, :forecast_date, :forecast_type, 'blended', :predicted_value, NOW()) + """), + { + "run_id": run_id, + "forecast_date": fc['date'], + "forecast_type": metric_code, + "predicted_value": fc['predicted_value'] + } + ) + db.commit() + logger.info(f"Saved {len(blended_forecasts)} blended forecasts to database") + except Exception as e: + logger.error(f"Failed to save blended forecasts to database: {e}") + db.rollback() + raise + + return blended_forecasts diff --git a/backend/services/forecasting/blended_tuned.py b/backend/services/forecasting/blended_tuned.py new file mode 100644 index 0000000..4c8ff29 --- /dev/null +++ b/backend/services/forecasting/blended_tuned.py @@ -0,0 +1,164 @@ +""" +Blended Tuned Model Service + +This blends the production-tuned models using the exact same logic as the frontend. +Ensures backend snapshots match frontend preview values. + +Blending Logic: +- Pace metrics (rooms/occupancy): Prophet + XGBoost + CatBoost + Pickup (25% each) +- Other metrics: Prophet + XGBoost + CatBoost (33.3% each) + +This is the single source of truth for blended forecast snapshots. +""" +import logging +from datetime import date +from typing import List, Dict, Optional +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +async def run_blended_tuned_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + save_to_db: bool = False, + run_id: Optional[str] = None +) -> List[Dict]: + """ + Generate blended forecast using production-tuned models. + + This uses the exact same logic as the frontend Live Blended view to ensure + backend snapshots match frontend preview values. + + Args: + db: Database session + metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights') + start_date: Start date for forecast + end_date: End date for forecast + save_to_db: If True, saves forecasts to database (for snapshots) + run_id: Run ID for tracking (required if save_to_db=True) + + Returns: + List of forecast dicts with date and predicted_value + """ + logger.info(f"Running blended tuned forecast for {metric_code}: {start_date} to {end_date}") + + # Check if metric is a pace metric (uses pickup) + is_pace_metric = metric_code in ('hotel_occupancy_pct', 'hotel_room_nights') + + # Step 1: Run individual tuned models + forecasts_by_date = {} + + # Run Prophet Tuned + try: + from services.forecasting.prophet_tuned import run_prophet_tuned_forecast + prophet_forecasts = await run_prophet_tuned_forecast(db, metric_code, start_date, end_date) + for fc in prophet_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value']) + logger.info(f"Prophet tuned generated {len(prophet_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"Prophet tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run XGBoost Tuned + try: + from services.forecasting.xgboost_tuned import run_xgboost_tuned_forecast + xgboost_forecasts = await run_xgboost_tuned_forecast(db, metric_code, start_date, end_date) + for fc in xgboost_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value']) + logger.info(f"XGBoost tuned generated {len(xgboost_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"XGBoost tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run CatBoost Tuned + try: + from services.forecasting.catboost_tuned import run_catboost_tuned_forecast + catboost_forecasts = await run_catboost_tuned_forecast(db, metric_code, start_date, end_date) + for fc in catboost_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value']) + logger.info(f"CatBoost tuned generated {len(catboost_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"CatBoost tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run Pickup Tuned (only for pace metrics) + if is_pace_metric: + try: + from services.forecasting.pickup_tuned import run_pickup_tuned_forecast + pickup_forecasts = await run_pickup_tuned_forecast(db, metric_code, start_date, end_date) + for fc in pickup_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['pickup'] = float(fc['predicted_value']) + logger.info(f"Pickup tuned generated {len(pickup_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"Pickup tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Step 2: Calculate simple average blended forecast + # Match frontend logic: + # - Pace metrics: (prophet + xgboost + catboost + pickup) / 4 + # - Other metrics: (prophet + xgboost + catboost) / 3 + blended_forecasts = [] + for fc_date, model_forecasts in forecasts_by_date.items(): + # Need at least 2 models to blend + if len(model_forecasts) < 2: + continue + + # Simple average of all available models + blended_value = sum(model_forecasts.values()) / len(model_forecasts) + + blended_forecasts.append({ + 'date': fc_date, + 'predicted_value': round(blended_value, 2) + }) + + logger.info(f"Generated {len(blended_forecasts)} blended tuned forecasts for {metric_code}") + + # Step 3: Optionally save to database (for snapshots) + if save_to_db and run_id: + try: + for fc in blended_forecasts: + db.execute( + text(""" + INSERT INTO forecasts + (run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at) + VALUES + (:run_id, :forecast_date, :forecast_type, 'blended_tuned', :predicted_value, NOW()) + """), + { + "run_id": run_id, + "forecast_date": fc['date'], + "forecast_type": metric_code, + "predicted_value": fc['predicted_value'] + } + ) + db.commit() + logger.info(f"Saved {len(blended_forecasts)} blended tuned forecasts to database") + except Exception as e: + logger.error(f"Failed to save blended tuned forecasts to database: {e}") + db.rollback() + raise + + return blended_forecasts diff --git a/backend/services/forecasting/blended_tuned_weighted.py b/backend/services/forecasting/blended_tuned_weighted.py new file mode 100644 index 0000000..deb01bb --- /dev/null +++ b/backend/services/forecasting/blended_tuned_weighted.py @@ -0,0 +1,336 @@ +""" +Blended Tuned Model Service (Accuracy-Weighted + 60/40 Prior Year/Budget) + +Two-stage blending: +1. MAPE-weighted model blend using backtest accuracy data +2. 60/40 blend with prior year actual or budget + +Stage 1 - Model Weighting (MAPE-based): +- Query forecast_snapshots table for backtest MAPE scores +- Calculate inverse-MAPE weights (lower MAPE = higher weight) +- Pace metrics: weighted blend of Prophet + XGBoost + CatBoost + Pickup +- Other metrics: weighted blend of Prophet + XGBoost + CatBoost + +Stage 2 - 60/40 Blend: +- Revenue metrics: 60% weighted model blend + 40% budget +- Non-revenue metrics: 60% weighted model blend + 40% prior year actual + +Falls back to 100% model blend if prior year/budget data unavailable. +""" +import logging +from datetime import date, timedelta +from typing import List, Dict, Optional +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +async def get_model_weights(db, metric_code: str, is_pace_metric: bool) -> Dict[str, float]: + """ + Calculate accuracy-based weights for each model using MAPE scores from backtest data. + + Args: + db: Database session + metric_code: Metric to forecast + is_pace_metric: Whether this is a pace metric (uses pickup) + + Returns: + Dict of model names to weights (normalized to sum to 1.0) + """ + # 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', + } + + snapshot_metric = metric_map.get(metric_code, 'rooms') + + try: + # Query MAPE from forecast_snapshots where we have actuals + # Calculate MAPE for each model separately + models_to_query = ['prophet', 'xgboost', 'catboost'] + if is_pace_metric: + models_to_query.append('pickup') + + mape_scores = {} + for model in models_to_query: + query = text(""" + SELECT AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape + 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) + else: + mape_scores[model] = 100 # Default high MAPE if no data + + # Check if we have valid MAPE data + if all(score == 100 for score in mape_scores.values()): + logger.warning(f"No MAPE data found for {snapshot_metric}, using equal weights") + # Fall back to equal weights + if is_pace_metric: + return {'prophet': 0.25, 'xgboost': 0.25, 'catboost': 0.25, 'pickup': 0.25} + else: + return {'prophet': 0.333, 'xgboost': 0.333, 'catboost': 0.334} + + logger.info(f"MAPE scores for {snapshot_metric}: " + + ", ".join([f"{k}={v:.2f}%" for k, v in mape_scores.items()])) + + # Calculate inverse-MAPE weights (lower MAPE = higher weight) + weights = {model: 1.0 / max(mape, 0.1) for model, mape in mape_scores.items()} + + # Normalize weights to sum to 1.0 + weight_sum = sum(weights.values()) + normalized_weights = {k: v / weight_sum for k, v in weights.items()} + + logger.info(f"Normalized weights for {snapshot_metric}: " + + ", ".join([f"{k}={v:.4f}" for k, v in normalized_weights.items()])) + return normalized_weights + + except Exception as e: + logger.error(f"Failed to calculate model weights: {e}") + import traceback + traceback.print_exc() + # Fall back to equal weights + if is_pace_metric: + return {'prophet': 0.25, 'xgboost': 0.25, 'catboost': 0.25, 'pickup': 0.25} + else: + return {'prophet': 0.333, 'xgboost': 0.333, 'catboost': 0.334} + + +async def run_blended_tuned_weighted_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + save_to_db: bool = False, + run_id: Optional[str] = None, + perception_date: Optional[date] = None, + apply_60_40_blend: bool = True +) -> List[Dict]: + """ + Generate blended forecast using production-tuned models with accuracy-based weighting. + + This uses MAPE scores from the last 90 days to weight models by accuracy. + Lower MAPE (more accurate) models receive higher weights. + + Args: + db: Database session + metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights') + start_date: Start date for forecast + end_date: End date for forecast + save_to_db: If True, saves forecasts to database (for snapshots) + run_id: Run ID for tracking (required if save_to_db=True) + perception_date: Optional date to generate forecast as-of (for backtesting) + apply_60_40_blend: If True, applies 60/40 blend with budget/prior year (default True) + + Returns: + List of forecast dicts with date and predicted_value + """ + logger.info(f"Running blended tuned WEIGHTED forecast for {metric_code}: {start_date} to {end_date}") + + # Check if metric is a pace metric (uses pickup) + is_pace_metric = metric_code in ('hotel_occupancy_pct', 'hotel_room_nights') + + # Get accuracy-based weights + weights = await get_model_weights(db, metric_code, is_pace_metric) + + # Step 1: Run individual tuned models + forecasts_by_date = {} + + # Run Prophet Tuned + try: + from services.forecasting.prophet_tuned import run_prophet_tuned_forecast + prophet_forecasts = await run_prophet_tuned_forecast(db, metric_code, start_date, end_date, perception_date) + for fc in prophet_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value']) + logger.info(f"Prophet tuned generated {len(prophet_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"Prophet tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run XGBoost Tuned + try: + from services.forecasting.xgboost_tuned import run_xgboost_tuned_forecast + xgboost_forecasts = await run_xgboost_tuned_forecast(db, metric_code, start_date, end_date, perception_date) + for fc in xgboost_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value']) + logger.info(f"XGBoost tuned generated {len(xgboost_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"XGBoost tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run CatBoost Tuned + try: + from services.forecasting.catboost_tuned import run_catboost_tuned_forecast + catboost_forecasts = await run_catboost_tuned_forecast(db, metric_code, start_date, end_date, perception_date) + for fc in catboost_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value']) + logger.info(f"CatBoost tuned generated {len(catboost_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"CatBoost tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Run Pickup Tuned (only for pace metrics) + if is_pace_metric: + try: + from services.forecasting.pickup_tuned import run_pickup_tuned_forecast + pickup_forecasts = await run_pickup_tuned_forecast(db, metric_code, start_date, end_date, perception_date) + for fc in pickup_forecasts: + fc_date = str(fc['forecast_date']) + if fc_date not in forecasts_by_date: + forecasts_by_date[fc_date] = {} + forecasts_by_date[fc_date]['pickup'] = float(fc['predicted_value']) + logger.info(f"Pickup tuned generated {len(pickup_forecasts)} forecasts for {metric_code}") + db.commit() # Commit after successful model run + except Exception as e: + logger.error(f"Pickup tuned forecast failed for {metric_code}: {e}") + db.rollback() + db.commit() # Start fresh transaction + + # Step 2: Calculate MAPE-weighted model blend, then apply 60/40 with prior year/budget + # Determine if this is a revenue metric (uses budget instead of prior year) + revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev', 'hotel_arr'] + is_revenue_metric = metric_code in revenue_metrics + + blended_forecasts = [] + for fc_date, model_forecasts in forecasts_by_date.items(): + # Need at least 2 models to blend + if len(model_forecasts) < 2: + continue + + # Apply accuracy-based weights to get weighted model blend + weighted_sum = 0.0 + weight_total = 0.0 + + for model_name, forecast_value in model_forecasts.items(): + if model_name in weights: + weighted_sum += forecast_value * weights[model_name] + weight_total += weights[model_name] + + # Calculate weighted model average + if weight_total > 0: + weighted_model_blend = weighted_sum / weight_total + else: + # Fall back to simple average if weights are missing + weighted_model_blend = sum(model_forecasts.values()) / len(model_forecasts) + + # Apply 60/40 blend with prior year or budget (if enabled) + final_value = weighted_model_blend # Default: use model blend only + + if apply_60_40_blend: + try: + forecast_date_obj = date.fromisoformat(fc_date) + + if is_revenue_metric: + # Revenue metrics: 60% model + 40% budget + budget_query = text(""" + SELECT budget_value + FROM daily_budgets + WHERE date = :fc_date AND budget_type = :metric_code + """) + budget_result = await db.execute(budget_query, { + "fc_date": forecast_date_obj, + "metric_code": metric_code + }) + budget_row = budget_result.fetchone() + if budget_row and budget_row.budget_value is not None: + budget_value = float(budget_row.budget_value) + final_value = 0.6 * weighted_model_blend + 0.4 * budget_value + logger.debug(f"{fc_date}: Model={weighted_model_blend:.2f}, Budget={budget_value:.2f}, Final={final_value:.2f}") + else: + # Non-revenue metrics: 60% model + 40% prior year + # Map metric codes for daily_metrics table + daily_metric_map = { + 'hotel_occupancy_pct': 'hotel_occupancy_pct', + 'hotel_room_nights': 'hotel_room_nights', + 'hotel_guests': 'hotel_guests', + 'ave_guest_rate': 'ave_guest_rate', + } + daily_metric_code = daily_metric_map.get(metric_code, metric_code) + + # Get prior year date (same day of week, ~52 weeks back) + from utils.time_alignment import get_prior_year_daily + prior_year_date = get_prior_year_daily(forecast_date_obj) + + prior_query = text(""" + SELECT actual_value + FROM daily_metrics + WHERE date = :prior_date AND metric_code = :metric_code + """) + prior_result = await db.execute(prior_query, { + "prior_date": prior_year_date, + "metric_code": daily_metric_code + }) + prior_row = prior_result.fetchone() + if prior_row and prior_row.actual_value is not None: + prior_value = float(prior_row.actual_value) + final_value = 0.6 * weighted_model_blend + 0.4 * prior_value + logger.debug(f"{fc_date}: Model={weighted_model_blend:.2f}, PriorYear={prior_value:.2f}, Final={final_value:.2f}") + + except Exception as e: + logger.warning(f"Could not apply 60/40 blend for {fc_date}: {e}, using model blend only") + final_value = weighted_model_blend + + blended_forecasts.append({ + 'date': fc_date, + 'predicted_value': round(final_value, 2) + }) + + logger.info(f"Generated {len(blended_forecasts)} blended tuned WEIGHTED forecasts for {metric_code}") + + # Step 3: Optionally save to database (for snapshots) + if save_to_db and run_id: + try: + for fc in blended_forecasts: + db.execute( + text(""" + INSERT INTO forecasts + (run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at) + VALUES + (:run_id, :forecast_date, :forecast_type, 'blended_tuned_weighted', :predicted_value, NOW()) + """), + { + "run_id": run_id, + "forecast_date": fc['date'], + "forecast_type": metric_code, + "predicted_value": fc['predicted_value'] + } + ) + db.commit() + logger.info(f"Saved {len(blended_forecasts)} blended tuned weighted forecasts to database") + except Exception as e: + logger.error(f"Failed to save blended tuned weighted forecasts to database: {e}") + db.rollback() + raise + + return blended_forecasts diff --git a/backend/services/forecasting/budget_service.py b/backend/services/forecasting/budget_service.py new file mode 100644 index 0000000..80412ba --- /dev/null +++ b/backend/services/forecasting/budget_service.py @@ -0,0 +1,254 @@ +""" +Budget distribution service +Distributes monthly budgets to daily values using prior year patterns +""" +import logging +from datetime import date, timedelta +from calendar import monthrange +from typing import Optional, Dict, List +from sqlalchemy import text + +logger = logging.getLogger(__name__) + +# Map budget_type to column name in newbook_net_revenue_data +BUDGET_TYPE_TO_COLUMN = { + 'net_accom': 'accommodation', + 'net_dry': 'dry', + 'net_wet': 'wet', +} + + +async def distribute_budget( + db, + year: int, + month: int, + budget_type: Optional[str] = None +) -> dict: + """ + Distribute monthly budget to daily values using DOW-aligned prior year patterns. + + Uses 364-day offset (52 weeks) so that days of week align between years. + This ensures weekday/weekend patterns are preserved in the distribution. + + Logic: + 1. Get monthly budget from FD-provided values + 2. For each day in target month, find DOW-aligned date 364 days prior + 3. Get prior year actual values for those DOW-aligned dates + 4. Calculate percentages and distribute budget accordingly + + Example: + - Feb 1, 2026 (Sunday) - 364 days = Feb 2, 2025 (Sunday) + - If Feb 2, 2025 was 4% of the DOW-aligned period total + - Feb 1, 2026 daily budget = monthly_budget × 4% + + Args: + db: Database session + year: Year to distribute for + month: Month to distribute (1-12) + budget_type: Optional specific budget type, or all if None + + Returns: + Dict with distribution results + """ + logger.info(f"Distributing budget for {year}-{month:02d} using DOW-aligned prior year patterns") + + # Get monthly budgets + query = """ + SELECT id, budget_type, budget_value + FROM monthly_budgets + WHERE year = :year AND month = :month + """ + params = {"year": year, "month": month} + + if budget_type: + query += " AND budget_type = :budget_type" + params["budget_type"] = budget_type + + result = await db.execute(text(query), params) + monthly_budgets = result.fetchall() + + if not monthly_budgets: + logger.warning(f"No monthly budgets found for {year}-{month:02d}") + return {"days_distributed": 0, "status": "no_budgets_found"} + + # Get days in target month + _, days_in_month = monthrange(year, month) + + days_distributed = 0 + + for budget in monthly_budgets: + budget_id = budget.id + btype = budget.budget_type + monthly_value = float(budget.budget_value) + + # Get column name for this budget type + column_name = BUDGET_TYPE_TO_COLUMN.get(btype) + if not column_name: + logger.warning(f"Unknown budget type: {btype}, skipping") + continue + + logger.info(f"Distributing {btype}: £{monthly_value:,.2f}") + + # Build list of target dates and their DOW-aligned prior year dates + target_dates = [] + prior_dates = [] + for day in range(1, days_in_month + 1): + target_date = date(year, month, day) + prior_date = target_date - timedelta(days=364) # 52 weeks back, DOW aligned + target_dates.append(target_date) + prior_dates.append(prior_date) + + # Get prior year actual values for the DOW-aligned dates from newbook_net_revenue_data + prior_result = await db.execute( + text(f""" + SELECT date, {column_name} as value + FROM newbook_net_revenue_data + WHERE date = ANY(:dates) + AND {column_name} IS NOT NULL + ORDER BY date + """), + {"dates": prior_dates} + ) + prior_rows = prior_result.fetchall() + prior_values = {row.date: float(row.value) for row in prior_rows} + + # Calculate total of prior year values for percentage calculation + total_prior = sum(prior_values.get(d, 0) for d in prior_dates) + + logger.info(f"Prior year DOW-aligned total for {btype}: £{total_prior:,.2f} ({len(prior_values)} days with data)") + + if total_prior > 0: + # Distribute using DOW-aligned prior year percentages + for target_date, prior_date in zip(target_dates, prior_dates): + prior_value = prior_values.get(prior_date, 0) + pct_of_total = prior_value / total_prior if total_prior > 0 else (1 / days_in_month) + daily_budget = monthly_value * pct_of_total + + await db.execute( + text(""" + INSERT INTO daily_budgets ( + date, budget_type, budget_value, distribution_method, + prior_year_pct, monthly_budget_id, calculated_at + ) VALUES ( + :date, :btype, :value, 'dow_aligned', + :pct, :budget_id, NOW() + ) + ON CONFLICT (date, budget_type) DO UPDATE SET + budget_value = :value, + distribution_method = 'dow_aligned', + prior_year_pct = :pct, + calculated_at = NOW() + """), + { + "date": target_date, + "btype": btype, + "value": round(daily_budget, 2), + "pct": round(pct_of_total, 6), + "budget_id": budget_id + } + ) + days_distributed += 1 + + logger.info(f"Distributed {btype} using DOW-aligned prior year patterns") + else: + # No prior year data - distribute evenly + logger.warning(f"No prior year DOW-aligned data for {btype}, using even distribution") + daily_budget = monthly_value / days_in_month + + for target_date in target_dates: + await db.execute( + text(""" + INSERT INTO daily_budgets ( + date, budget_type, budget_value, distribution_method, + prior_year_pct, monthly_budget_id, calculated_at + ) VALUES ( + :date, :btype, :value, 'even', + :pct, :budget_id, NOW() + ) + ON CONFLICT (date, budget_type) DO UPDATE SET + budget_value = :value, + distribution_method = 'even', + prior_year_pct = :pct, + calculated_at = NOW() + """), + { + "date": target_date, + "btype": btype, + "value": round(daily_budget, 2), + "pct": round(1 / days_in_month, 6), + "budget_id": budget_id + } + ) + days_distributed += 1 + + await db.commit() + logger.info(f"Budget distribution complete: {days_distributed} days") + return {"days_distributed": days_distributed, "status": "success"} + + +async def calculate_prior_year_percentages(db, metric_type: str): + """ + Calculate and store prior year daily percentages for budget distribution + + For each month, calculates what percentage each day was of the monthly total + """ + logger.info(f"Calculating prior year percentages for {metric_type}") + + # Get all daily values from prior year + result = await db.execute( + text(""" + WITH monthly_totals AS ( + SELECT + EXTRACT(YEAR FROM date) as year, + EXTRACT(MONTH FROM date) as month, + SUM(actual_value) as month_total + FROM daily_metrics + WHERE metric_code = :metric_type + AND date >= CURRENT_DATE - INTERVAL '2 years' + AND actual_value IS NOT NULL + GROUP BY EXTRACT(YEAR FROM date), EXTRACT(MONTH FROM date) + ) + SELECT + dm.date, + dm.actual_value, + mt.month_total, + dm.actual_value / NULLIF(mt.month_total, 0) as pct_of_month + FROM daily_metrics dm + JOIN monthly_totals mt ON + EXTRACT(YEAR FROM dm.date) = mt.year AND + EXTRACT(MONTH FROM dm.date) = mt.month + WHERE dm.metric_code = :metric_type + AND dm.actual_value IS NOT NULL + ORDER BY dm.date + """), + {"metric_type": metric_type} + ) + + count = 0 + for row in result.fetchall(): + await db.execute( + text(""" + INSERT INTO prior_year_daily ( + date, metric_type, actual_value, month_total, pct_of_month, fetched_at + ) VALUES ( + :date, :metric, :actual, :month_total, :pct, NOW() + ) + ON CONFLICT (date, metric_type) DO UPDATE SET + actual_value = :actual, + month_total = :month_total, + pct_of_month = :pct, + fetched_at = NOW() + """), + { + "date": row.date, + "metric": metric_type, + "actual": row.actual_value, + "month_total": row.month_total, + "pct": row.pct_of_month + } + ) + count += 1 + + await db.commit() + logger.info(f"Prior year percentages calculated: {count} records for {metric_type}") + return count diff --git a/backend/services/forecasting/catboost_model.py b/backend/services/forecasting/catboost_model.py new file mode 100644 index 0000000..8ff9d78 --- /dev/null +++ b/backend/services/forecasting/catboost_model.py @@ -0,0 +1,450 @@ +""" +CatBoost forecasting model +Gradient boosting with native categorical feature support and better out-of-box performance. +Similar to XGBoost but handles categorical features natively without encoding. +""" +import logging +from datetime import date, timedelta +from typing import List, Optional +import pandas as pd +import numpy as np +import json +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +def create_features(df: pd.DataFrame, special_dates: set = None) -> pd.DataFrame: + """ + Create features for CatBoost model. + + CatBoost handles categorical features natively, so we keep day_of_week as categorical + instead of using cyclical encoding. + """ + df = df.copy() + + # Date features - keep as categorical for CatBoost + df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical + df['month'] = df['ds'].dt.month.astype(str) # Categorical + df['day_of_month'] = df['ds'].dt.day + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int) + + # Special dates / holidays + if special_dates and len(special_dates) > 0: + df['is_holiday'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_dates else 0) + + # Days to nearest special date + def days_to_nearest(d): + if not special_dates: + return 30 + future = [s for s in special_dates if s >= d] + if not future: + return 30 + return min((s - d).days for s in future) + + df['days_to_holiday'] = df['ds'].dt.date.apply(days_to_nearest) + else: + df['is_holiday'] = 0 + df['days_to_holiday'] = 30 + + # Lag features + for lag in [7, 14, 21, 28]: + df[f'lag_{lag}'] = df['y'].shift(lag) + + # Rolling averages + for window in [7, 14, 28]: + df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean() + df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std().fillna(0) + + # Year-over-year feature (364 days for DOW alignment) + if len(df) > 364: + df['lag_364'] = df['y'].shift(364) + + return df + + +async def run_catboost_forecast( + db, + metric_code: str, + forecast_from: date, + forecast_to: date, + training_days: int = 2555, # ~7 years + use_special_dates: bool = True, + use_otb_data: bool = True +) -> List[dict]: + """ + Run CatBoost forecast for a metric. + + Args: + db: Database session + metric_code: Metric to forecast + forecast_from: Start date for forecasts + forecast_to: End date for forecasts + training_days: Days of historical data to use + use_special_dates: Include holiday features + use_otb_data: Include OTB pickup features + + Returns: + List of forecast records + """ + try: + from catboost import CatBoostRegressor + + # Get historical data + training_from = forecast_from - timedelta(days=training_days + 400) # Extra for lag features + + # Revenue metrics use earned_revenue_data joined with gl_accounts + revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev'] + if metric_code in revenue_metrics: + revenue_departments = { + 'net_accom': 'accommodation', + 'net_dry': 'dry', + 'net_wet': 'wet', + 'total_rev': None, # All departments + } + department = revenue_departments.get(metric_code) + if department is None and metric_code != 'total_rev': + logger.warning(f"Unknown revenue metric for CatBoost: {metric_code}") + return [] + + if metric_code == 'total_rev': + # Total revenue across all departments + result = db.execute( + text(""" + SELECT date, SUM(amount_net) as actual_value + FROM newbook_earned_revenue_data + WHERE date BETWEEN :from_date AND :to_date + GROUP BY date + HAVING SUM(amount_net) IS NOT NULL + ORDER BY date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1)} + ) + else: + # Revenue by department + result = db.execute( + text(""" + SELECT r.date, SUM(r.amount_net) as actual_value + FROM newbook_earned_revenue_data r + JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id + WHERE r.date BETWEEN :from_date AND :to_date + AND g.department = :department + GROUP BY r.date + HAVING SUM(r.amount_net) IS NOT NULL + ORDER BY r.date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department} + ) + else: + # Hotel metrics use newbook_bookings_stats table + metric_column_map = { + 'hotel_occupancy_pct': 'total_occupancy_pct', + 'hotel_room_nights': 'booking_count', + 'hotel_guests': 'guests_count', + } + + column_name = metric_column_map.get(metric_code) + if not column_name: + logger.warning(f"Unknown metric_code for CatBoost: {metric_code}") + return [] + + result = db.execute( + text(f""" + SELECT date, {column_name} as actual_value + FROM newbook_bookings_stats + WHERE date BETWEEN :from_date AND :to_date + AND {column_name} IS NOT NULL + ORDER BY date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1)} + ) + + rows = result.fetchall() + + if len(rows) < 60: + logger.warning(f"Insufficient data for CatBoost: {metric_code} has {len(rows)} records") + return [] + + # Prepare DataFrame + df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows]) + df = df.sort_values('ds').reset_index(drop=True) + + # Load special dates if enabled + special_dates = None + if use_special_dates: + special_dates = await _load_special_dates(db, forecast_from, forecast_to) + + # Create features + df = create_features(df, special_dates) + + # Add OTB features if enabled + if use_otb_data: + otb_df = _load_otb_data(db, training_from, forecast_from) + if otb_df is not None and len(otb_df) > 0: + df = _add_otb_features(df, otb_df) + logger.info("Added OTB features to CatBoost training data") + + # Remove rows with NaN from lag features + df = df.dropna(subset=['lag_7', 'lag_14', 'lag_21', 'lag_28']) + + # Define feature columns + categorical_features = ['day_of_week', 'month'] + + numerical_features = [ + 'day_of_month', 'week_of_year', 'is_weekend', + 'is_holiday', 'days_to_holiday', + 'lag_7', 'lag_14', 'lag_21', 'lag_28', + 'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28', + 'rolling_std_7', 'rolling_std_14', 'rolling_std_28' + ] + + # Add lag_364 if available + if 'lag_364' in df.columns and df['lag_364'].notna().sum() > 30: + numerical_features.append('lag_364') + + # Add OTB features if present + otb_cols = ['otb_at_30d', 'otb_at_14d', 'otb_at_7d', + 'pickup_30d_to_14d', 'pickup_14d_to_7d', + 'otb_pct_at_30d', 'otb_pct_at_14d', 'otb_pct_at_7d'] + for col in otb_cols: + if col in df.columns: + numerical_features.append(col) + + feature_cols = categorical_features + numerical_features + + X = df[feature_cols].copy() + y = df['y'] + + # Train CatBoost model + model = CatBoostRegressor( + iterations=200, + depth=6, + learning_rate=0.1, + loss_function='RMSE', + cat_features=categorical_features, + verbose=False, + random_seed=42 + ) + model.fit(X, y) + + # Generate forecasts + forecasts = [] + current_df = df.copy() + + for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'): + # Create row for forecast date + new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}]) + current_df = pd.concat([current_df, new_row], ignore_index=True) + current_df = create_features(current_df, special_dates) + + # Add OTB features for future dates if available + if use_otb_data: + current_df = _add_otb_features(current_df, otb_df) + + # Get features for prediction + X_pred = current_df[feature_cols].iloc[-1:].copy() + + # Forward fill any NaN values + for col in numerical_features: + if col in X_pred.columns: + X_pred[col] = X_pred[col].ffill() + if X_pred[col].isna().any(): + X_pred[col] = X_pred[col].fillna(0) + + # Make prediction + prediction = float(model.predict(X_pred)[0]) + + # Ensure non-negative + prediction = max(0, prediction) + + # Update y value for lag features + current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction + + forecast_record = { + "forecast_date": forecast_date.date(), + "forecast_type": metric_code, + "model_type": "catboost", + "predicted_value": round(float(prediction), 2) + } + forecasts.append(forecast_record) + + # Store in database + db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, predicted_value, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, :predicted_value, NOW() + ) + """), + forecast_record + ) + + db.commit() + + # Calculate feature importance for explainability + try: + feature_importance = dict(zip(feature_cols, model.feature_importances_.tolist())) + top_features = sorted( + [{"feature": k, "importance": v} for k, v in feature_importance.items()], + key=lambda x: x["importance"], reverse=True + )[:10] + + logger.info(f"CatBoost top features: {[f['feature'] for f in top_features[:5]]}") + except Exception as e: + logger.warning(f"Feature importance calculation failed: {e}") + + logger.info(f"CatBoost forecast generated for {metric_code}: {len(forecasts)} records") + return forecasts + + except ImportError as e: + logger.error(f"CatBoost not installed: {e}") + return [] + except Exception as e: + logger.error(f"CatBoost forecast failed for {metric_code}: {e}") + import traceback + logger.error(traceback.format_exc()) + return [] + + +async def _load_special_dates(db, from_date: date, to_date: date) -> set: + """Load special dates from system_config.""" + try: + result = db.execute(text(""" + SELECT config_value FROM system_config + WHERE config_key = 'special_dates' + """)) + row = result.fetchone() + + if not row or not row.config_value: + return set() + + dates_json = json.loads(row.config_value) + special_dates = set() + + for item in dates_json: + if isinstance(item, dict) and 'date' in item: + try: + d = pd.to_datetime(item['date']).date() + special_dates.add(d) + except: + pass + + logger.info(f"Loaded {len(special_dates)} special dates for CatBoost") + return special_dates + + except Exception as e: + logger.warning(f"Failed to load special dates: {e}") + return set() + + +def _load_otb_data(db, from_date: date, to_date: date) -> Optional[pd.DataFrame]: + """Load OTB (On-The-Books) data.""" + try: + result = db.execute(text(""" + SELECT + arrival_date, + d93 as otb_at_90d, + d65 as otb_at_60d, + d30 as otb_at_30d, + d14 as otb_at_14d, + d7 as otb_at_7d, + d0 as final_bookings + FROM newbook_booking_pace + WHERE arrival_date BETWEEN :from_date AND :to_date + ORDER BY arrival_date + """), {"from_date": from_date, "to_date": to_date}) + rows = result.fetchall() + + if not rows: + return None + + df = pd.DataFrame([{ + "arrival_date": row.arrival_date, + "otb_at_90d": float(row.otb_at_90d) if row.otb_at_90d else 0, + "otb_at_60d": float(row.otb_at_60d) if row.otb_at_60d else 0, + "otb_at_30d": float(row.otb_at_30d) if row.otb_at_30d else 0, + "otb_at_14d": float(row.otb_at_14d) if row.otb_at_14d else 0, + "otb_at_7d": float(row.otb_at_7d) if row.otb_at_7d else 0, + "final_bookings": float(row.final_bookings) if row.final_bookings else 0 + } for row in rows]) + + return df + + except Exception as e: + logger.warning(f"Failed to load OTB data: {e}") + return None + + +def _add_otb_features(df: pd.DataFrame, otb_df: Optional[pd.DataFrame]) -> pd.DataFrame: + """Add OTB features to DataFrame.""" + df = df.copy() + + if otb_df is None or len(otb_df) == 0: + # No OTB data - set defaults + df['otb_at_30d'] = 0 + df['otb_at_14d'] = 0 + df['otb_at_7d'] = 0 + df['pickup_30d_to_14d'] = 0 + df['pickup_14d_to_7d'] = 0 + df['otb_pct_at_30d'] = 0 + df['otb_pct_at_14d'] = 0 + df['otb_pct_at_7d'] = 0 + return df + + # Create date column for merging + df['date_only'] = df['ds'].dt.date + + # Merge OTB data + otb_df = otb_df.copy() + otb_df['date_only'] = pd.to_datetime(otb_df['arrival_date']).dt.date + + # Check if columns already exist (avoid duplicates) + merge_cols = ['date_only'] + for col in ['otb_at_90d', 'otb_at_60d', 'otb_at_30d', 'otb_at_14d', 'otb_at_7d', 'final_bookings']: + if col not in df.columns: + merge_cols.append(col) + + if len(merge_cols) > 1: + df = df.merge( + otb_df[merge_cols], + on='date_only', + how='left' + ) + + # Fill NaN with 0 + for col in ['otb_at_90d', 'otb_at_60d', 'otb_at_30d', 'otb_at_14d', 'otb_at_7d', 'final_bookings']: + if col in df.columns: + df[col] = df[col].fillna(0) + + # Calculate pickup between windows + if 'pickup_30d_to_14d' not in df.columns: + df['pickup_30d_to_14d'] = df['otb_at_14d'] - df['otb_at_30d'] + if 'pickup_14d_to_7d' not in df.columns: + df['pickup_14d_to_7d'] = df['otb_at_7d'] - df['otb_at_14d'] + + # Calculate OTB as percentage of final (capped at 100%) + if 'otb_pct_at_30d' not in df.columns: + df['otb_pct_at_30d'] = np.where( + df['final_bookings'] > 0, + np.minimum(df['otb_at_30d'] / df['final_bookings'] * 100, 100), + 0 + ) + if 'otb_pct_at_14d' not in df.columns: + df['otb_pct_at_14d'] = np.where( + df['final_bookings'] > 0, + np.minimum(df['otb_at_14d'] / df['final_bookings'] * 100, 100), + 0 + ) + if 'otb_pct_at_7d' not in df.columns: + df['otb_pct_at_7d'] = np.where( + df['final_bookings'] > 0, + np.minimum(df['otb_at_7d'] / df['final_bookings'] * 100, 100), + 0 + ) + + # Drop temporary column + df = df.drop(columns=['date_only'], errors='ignore') + + return df diff --git a/backend/services/forecasting/catboost_tuned.py b/backend/services/forecasting/catboost_tuned.py new file mode 100644 index 0000000..dab3e6e --- /dev/null +++ b/backend/services/forecasting/catboost_tuned.py @@ -0,0 +1,449 @@ +""" +CatBoost Tuned Model Service + +This is the production-tuned CatBoost model extracted from the catboost-preview endpoint. +Uses the exact same logic as the frontend preview to ensure value consistency. + +Features: +- 2 years of training data +- Native categorical feature support (day_of_week, month) +- Pace features (OTB at different lead times) for room-based metrics +- Time-based features +- Lag features from prior year +- OTB floor capping +- Per-date bookable cap adjustments +""" +import logging +from datetime import date, timedelta +from typing import List, Dict, Optional +import pandas as pd +import numpy as np +from catboost import CatBoostRegressor +import warnings +from sqlalchemy import text + +from utils.capacity import get_bookable_cap +from api.special_dates import resolve_special_date + +logger = logging.getLogger(__name__) +warnings.filterwarnings('ignore') + + +# Metric configuration mapping +METRIC_COLUMN_MAP = { + 'occupancy': ('s.occupancy_pct', False, True), + 'rooms': ('s.booking_count', False, False), + 'guests': ('s.guest_count', False, False), + 'ave_guest_rate': ('s.arr_net', False, False), + 'arr': ('s.arr_net', False, False), + 'net_accom': ('r.accommodation', True, False), + 'net_dry': ('r.dry', True, False), + 'net_wet': ('r.wet', True, False), + 'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False), +} + + +def get_metric_query_parts(metric: str) -> tuple: + """Get SQL query parts for a metric. Returns: (column_expr, from_clause, is_percentage)""" + if metric not in METRIC_COLUMN_MAP: + metric = 'rooms' + col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric] + if needs_revenue: + from_clause = """ + FROM newbook_bookings_stats s + LEFT JOIN newbook_net_revenue_data r ON s.date = r.date + """ + else: + from_clause = "FROM newbook_bookings_stats s" + return col_expr, from_clause, is_pct + + +def get_lead_time_column(lead_days: int) -> str: + """Map lead days to the appropriate column in newbook_booking_pace.""" + if lead_days <= 0: + return "d0" + elif lead_days <= 30: + return f"d{lead_days}" + elif lead_days <= 177: + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + for col in weekly_cols: + if lead_days <= col: + return f"d{col}" + return "d177" + else: + monthly_cols = [210, 240, 270, 300, 330, 365] + for col in monthly_cols: + if lead_days <= col: + return f"d{col}" + return "d365" + + +def round_towards_reference(value: float, reference: Optional[float]) -> int: + """Round a forecast value towards a reference value (prior year actual).""" + if reference is None: + return round(value) + if value < reference: + return int(np.ceil(value)) + else: + return int(np.floor(value)) + + +async def run_catboost_tuned_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + perception_date: Optional[date] = None +) -> List[Dict]: + """ + Generate CatBoost forecast using production-tuned model. + + This uses the exact same logic as the catboost-preview endpoint to ensure + backend snapshots match frontend preview values. + + Args: + db: Database session + metric_code: Metric to forecast + start_date: Start date for forecast + end_date: End date for forecast + perception_date: Optional date to generate forecast as-of (for backtesting) + + Returns: + List of forecast dicts with forecast_date and predicted_value + """ + logger.info(f"Running CatBoost tuned forecast for {metric_code}: {start_date} to {end_date}") + + # Map metric codes to preview endpoint metric names + 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', + } + + metric = metric_map.get(metric_code, 'rooms') + + # Use perception_date if provided, otherwise use actual today + today = perception_date if perception_date else date.today() + + # Get default bookable cap + default_bookable_cap = await get_bookable_cap(db) + + # Get metric column and query parts + col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric) + is_room_based = metric in ('occupancy', 'rooms') + + # Get historical data (2+ years for YoY features) + history_start = today - timedelta(days=730) + + # Lead times to train on (only used for room-based metrics) + train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30] + + # Get final values (and pace data for room-based metrics) + if is_room_based: + history_result = await db.execute(text(""" + SELECT s.date as ds, s.booking_count as final, + p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30 + FROM newbook_bookings_stats s + LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + else: + # Non-room metrics: get values without pace join + history_query = f""" + SELECT s.date as ds, {col_expr} as final + {from_clause} + WHERE s.date >= :history_start + AND s.date < :today + AND {col_expr} IS NOT NULL + ORDER BY s.date + """ + history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today}) + + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + logger.warning(f"Insufficient historical data for CatBoost model: {len(history_rows)} rows") + return [] + + # Load special dates for feature + special_date_set = set() + try: + special_dates_result = await db.execute(text( + "SELECT * FROM special_dates WHERE is_active = TRUE" + )) + special_dates_rows = special_dates_result.fetchall() + years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1} + for row in special_dates_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 + } + for year in years_needed: + resolved_dates = resolve_special_date(sd, year) + for d in resolved_dates: + special_date_set.add(d) + except Exception as e: + logger.warning(f"Could not load special dates: {e}") + + # Build lookup dicts + final_by_date = {} + pace_by_date = {} + for row in history_rows: + final_by_date[row.ds] = row.final + if is_room_based and hasattr(row, 'd0'): + pace_by_date[row.ds] = { + 0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7, + 14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30 + } + + # Build training examples + training_rows = [] + + if is_room_based: + # Room-based metrics: use pace features (one per date,lead_time combo) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + continue + + for lead_time in train_lead_times: + current_otb = pace_by_date.get(ds, {}).get(lead_time) + if current_otb is None: + continue + + prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time) + if prior_otb is None: + prior_otb = 0 + + otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0 + + training_rows.append({ + 'ds': ds, + 'y': final, + 'days_out': lead_time, + 'current_otb': float(current_otb), + 'prior_otb_same_lead': float(prior_otb), + 'lag_364': float(prior_final), + 'otb_pct_of_prior_final': otb_pct_of_prior_final + }) + else: + # Non-room metrics: use time features only (one per date) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + prior_final = 0 # Allow training even without prior year for revenue metrics + + training_rows.append({ + 'ds': ds, + 'y': final, + 'lag_364': float(prior_final) if prior_final else 0 + }) + + if len(training_rows) < 30: + logger.warning(f"Insufficient data for CatBoost training: {len(training_rows)} rows") + return [] + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + # Convert to occupancy if needed + if metric == "occupancy" and default_bookable_cap > 0: + df["y"] = (df["y"] / default_bookable_cap) * 100 + if "current_otb" in df.columns: + df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100 + if "prior_otb_same_lead" in df.columns: + df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100 + df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100 + + # Create features - CatBoost handles categoricals natively + df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical + df['month'] = df['ds'].dt.month.astype(str) # Categorical + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int) + df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0) + + df_train = df.dropna() + + if len(df_train) < 30: + logger.warning(f"Insufficient data after creating features: {len(df_train)} rows") + return [] + + # Define features based on metric type - categoricals handled natively by CatBoost + categorical_features = ['day_of_week', 'month'] + if is_room_based: + numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', + 'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final'] + else: + numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', 'lag_364'] + feature_cols = categorical_features + numerical_features + + X_train = df_train[feature_cols] + y_train = df_train['y'] + + # Train CatBoost model + model = CatBoostRegressor( + iterations=150, + depth=6, + learning_rate=0.1, + loss_function='RMSE', + cat_features=categorical_features, + verbose=False, + random_seed=42 + ) + model.fit(X_train, y_train) + + # Create future dataframe for forecast period + future_dates = [] + current_date = start_date + while current_date <= end_date: + if (current_date - today).days >= 0: + future_dates.append(current_date) + current_date += timedelta(days=1) + + if not future_dates: + logger.warning("No future dates to forecast") + return [] + + # Generate forecasts for each date + forecasts = [] + + for forecast_date in future_dates: + lead_days = (forecast_date - today).days + lead_col = get_lead_time_column(lead_days) + prior_year_date = forecast_date - timedelta(days=364) + + # Get OTB data only for room-based metrics + current_otb = None + + if is_room_based: + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + + # Get prior year final using metric mapping + prior_query = f""" + SELECT {col_expr} as prior_final + {from_clause} + WHERE s.date = :prior_date + """ + prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date}) + prior_row = prior_result.fetchone() + prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0 + + # Get per-date bookable cap + date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap) + + forecast_dt = pd.Timestamp(forecast_date) + lag_364_val = prior_final if prior_final else 0 + + # Convert to occupancy if needed + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0 + + # Build features based on metric type + if is_room_based: + # Get prior OTB at same lead time + prior_year_for_otb = forecast_date - timedelta(days=364) + prior_otb_query = text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb}) + prior_otb_row = prior_otb_result.fetchone() + prior_otb_same_lead = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else 0 + + if metric == "occupancy" and date_bookable_cap > 0: + prior_otb_same_lead = (prior_otb_same_lead / date_bookable_cap) * 100 if prior_otb_same_lead else 0 + + current_otb_val = current_otb if current_otb is not None else 0 + otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0 + + features = pd.DataFrame([{ + 'day_of_week': str(forecast_dt.dayofweek), # Categorical + 'month': str(forecast_dt.month), # Categorical + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'days_out': lead_days, + 'current_otb': current_otb_val, + 'prior_otb_same_lead': prior_otb_same_lead, + 'lag_364': lag_364_val, + 'otb_pct_of_prior_final': otb_pct_of_prior_final, + }]) + else: + features = pd.DataFrame([{ + 'day_of_week': str(forecast_dt.dayofweek), # Categorical + 'month': str(forecast_dt.month), # Categorical + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'lag_364': lag_364_val, + }]) + + # Predict + yhat = float(model.predict(features)[0]) + + # Cap at max capacity based on metric type (uses per-date bookable cap) + if is_pct_metric: + yhat = min(max(yhat, 0), 100.0) + elif metric == 'rooms': + yhat = round(min(max(yhat, 0), float(date_bookable_cap))) + elif metric == 'guests': + yhat = round(max(yhat, 0)) + else: + # Revenue/rate metrics: just ensure non-negative + yhat = max(yhat, 0) + + # Floor forecast to current OTB (room-based only) + if is_room_based and current_otb is not None and yhat < current_otb: + yhat = current_otb + + # Round based on metric type + if metric == "occupancy": + yhat = round(yhat, 1) + else: + yhat = round_towards_reference(yhat, prior_final) + + forecasts.append({ + 'forecast_date': forecast_date, + 'predicted_value': yhat + }) + + logger.info(f"CatBoost tuned generated {len(forecasts)} forecasts for {metric_code}") + return forecasts diff --git a/backend/services/forecasting/covers_model.py b/backend/services/forecasting/covers_model.py new file mode 100644 index 0000000..9f26646 --- /dev/null +++ b/backend/services/forecasting/covers_model.py @@ -0,0 +1,866 @@ +""" +Restaurant Covers Forecast Model + +Forecasts restaurant covers based on: +- Breakfast: Previous night's hotel occupancy (guests expected at breakfast) +- Lunch: OTB bookings + non-resident pickup based on lead time +- Dinner: OTB bookings split by hotel guest/non-resident + pickup for each segment + +Key segments: +- Resident (hotel guest): Based on hotel occupancy, booking patterns, DBB packages +- Non-resident: Based on historical pickup patterns at lead time +""" +import logging +import math +from datetime import date, timedelta +from decimal import Decimal +from typing import Dict, List, Optional, Any +from collections import defaultdict + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from services.forecasting.pickup_v2_model import forecast_rooms_for_date, get_prior_year_date as get_py_date + +logger = logging.getLogger(__name__) + +# Valid booking statuses for counting +VALID_STATUSES = ('approved', 'arrived', 'seated', 'left') + + +async def get_hotel_bookings_with_dinner_reservation( + db: AsyncSession, + target_date: date +) -> Dict[str, int]: + """ + Get actual count of hotel bookings that have dinner reservations for a date. + + Queries resos_bookings_data to find distinct hotel_booking_numbers + that have dinner reservations, then compares to total hotel bookings. + + Returns: + { + "rooms_with_dinner": int, # Hotel bookings with dinner reservation + "total_hotel_rooms": int, # Total hotel bookings for this date + "rooms_without_dinner": int # Difference + } + """ + # Count distinct hotel bookings with dinner reservations + result = await db.execute( + text(""" + SELECT COUNT(DISTINCT hotel_booking_number) as rooms_with_dinner + FROM resos_bookings_data + WHERE booking_date = :target_date + AND is_hotel_guest = true + AND period_type = 'dinner' + AND hotel_booking_number IS NOT NULL + AND hotel_booking_number != '' + AND status IN ('approved', 'arrived', 'seated', 'left') + """), + {"target_date": target_date} + ) + row = result.fetchone() + rooms_with_dinner = row.rooms_with_dinner if row else 0 + + # Get total hotel bookings from stats + result = await db.execute( + text(""" + SELECT COALESCE(booking_count, 0) as total_rooms + FROM newbook_bookings_stats + WHERE date = :target_date + """), + {"target_date": target_date} + ) + row = result.fetchone() + total_hotel_rooms = row.total_rooms if row else 0 + + rooms_without_dinner = max(0, total_hotel_rooms - rooms_with_dinner) + + return { + "rooms_with_dinner": rooms_with_dinner, + "total_hotel_rooms": total_hotel_rooms, + "rooms_without_dinner": rooms_without_dinner, + } + + +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) + + +async def get_hotel_occupancy_for_date(db: AsyncSession, stay_date: date) -> Dict[str, Any]: + """ + Get hotel room occupancy for a specific date from aggregated stats. + Returns occupied rooms, total capacity, and occupancy percentage. + Uses newbook_bookings_stats which is pre-aggregated with is_included filtering. + """ + # Query from aggregated stats table - more reliable and already filtered + result = await db.execute( + text(""" + SELECT + COALESCE(booking_count, 0) as room_count, + COALESCE(guests_count, 0) as guest_count, + COALESCE(bookable_count, 0) as total_rooms, + COALESCE(bookable_occupancy_pct, 0) as occupancy_pct + FROM newbook_bookings_stats + WHERE date = :stay_date + """), + {"stay_date": stay_date} + ) + row = result.fetchone() + + if row: + return { + "occupied_rooms": row.room_count, + "total_rooms": row.total_rooms, + "occupancy_pct": round(float(row.occupancy_pct), 1) if row.occupancy_pct else 0, + "guests": row.guest_count + } + + # No stats for this date - return empty + return {"occupied_rooms": 0, "total_rooms": 0, "occupancy_pct": 0, "guests": 0} + + +async def get_resos_covers_for_date( + db: AsyncSession, + target_date: date, + period_type: Optional[str] = None +) -> Dict[str, Any]: + """ + Get restaurant booking covers for a specific date from aggregated stats table. + Returns covers by period (breakfast, lunch, dinner, etc.) + """ + # Query from aggregated stats table - more efficient and reliable + result = await db.execute( + text(""" + SELECT + COALESCE(breakfast_covers, 0) as breakfast_covers, + COALESCE(lunch_covers, 0) as lunch_covers, + COALESCE(afternoon_covers, 0) as afternoon_covers, + COALESCE(dinner_covers, 0) as dinner_covers, + COALESCE(other_covers, 0) as other_covers, + COALESCE(total_covers, 0) as total_covers, + COALESCE(hotel_guest_covers, 0) as hotel_guest_covers, + COALESCE(non_hotel_guest_covers, 0) as non_hotel_guest_covers, + COALESCE(dbb_covers, 0) as dbb_covers, + COALESCE(total_bookings, 0) as total_bookings + FROM resos_bookings_stats + WHERE date = :target_date + """), + {"target_date": target_date} + ) + row = result.fetchone() + + if not row: + # No data for this date - return empty structure + return { + "breakfast": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0}, + "lunch": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0}, + "dinner": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0}, + } + + # Calculate resident/non-resident split proportionally for each period + # (stats table has overall split but not per-period, so we estimate based on ratio) + total = row.total_covers or 1 # Avoid division by zero + hotel_ratio = row.hotel_guest_covers / total if total > 0 else 0 + non_hotel_ratio = row.non_hotel_guest_covers / total if total > 0 else 0 + + covers_by_period = { + "breakfast": { + "total_covers": row.breakfast_covers, + "booking_count": 0, # Not tracked per period in stats + "resident_covers": int(row.breakfast_covers * hotel_ratio), + "non_resident_covers": int(row.breakfast_covers * non_hotel_ratio), + "dbb_covers": 0 + }, + "lunch": { + "total_covers": row.lunch_covers, + "booking_count": 0, + "resident_covers": int(row.lunch_covers * hotel_ratio), + "non_resident_covers": int(row.lunch_covers * non_hotel_ratio), + "dbb_covers": 0 + }, + "dinner": { + "total_covers": row.dinner_covers, + "booking_count": 0, + "resident_covers": int(row.dinner_covers * hotel_ratio), + "non_resident_covers": int(row.dinner_covers * non_hotel_ratio), + "dbb_covers": row.dbb_covers + }, + } + + return covers_by_period + + +async def get_historical_breakfast_rate(db: AsyncSession, lookback_days: int = 90) -> float: + """ + Calculate historical breakfast attendance rate as covers per occupied room. + Uses past data to determine typical breakfast covers per hotel room. + Uses aggregated stats tables for reliability. + """ + # Join resos stats with newbook stats to get breakfast covers and occupancy + result = await db.execute( + text(""" + SELECT + SUM(rbs.breakfast_covers) as total_breakfast, + SUM(nbs.booking_count) as total_room_nights + FROM resos_bookings_stats rbs + JOIN newbook_bookings_stats nbs ON rbs.date = nbs.date + WHERE rbs.date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER) + AND rbs.date < CURRENT_DATE + AND rbs.breakfast_covers > 0 + AND nbs.booking_count > 0 + """), + {"lookback_days": lookback_days} + ) + row = result.fetchone() + + if row and row.total_room_nights and row.total_room_nights > 0: + # Calculate covers per room night + rate = float(row.total_breakfast) / float(row.total_room_nights) + return rate + + # Default: assume 1.8 covers per room (average party size for breakfast) + return 1.8 + + +async def get_lunch_pickup_by_lead_time( + db: AsyncSession, + target_date: date, + lead_days: int, + lookback_weeks: int = 8 +) -> int: + """ + Get the median pickup COUNT for lunch at a given lead time for the same DOW. + + Pickup = final_covers - otb_at_lead + This tells us how many covers typically come in AFTER this lead time. + + More stable than ratio-based approach because it doesn't inflate + when current OTB is higher than historical OTB. + + Args: + db: Database session + target_date: Date we're forecasting (to get DOW) + lead_days: Days until the target date + lookback_weeks: Weeks of history to use + + Returns: + Median pickup count (integer), or 0 if no data + """ + # Get day of week - convert Python (0=Mon) to PostgreSQL (0=Sun, 1=Mon...6=Sat) + python_dow = target_date.weekday() + pg_dow = (python_dow + 1) % 7 + + # Determine which pace column to use based on lead days + if lead_days <= 0: + return 0 # No pickup for past dates + elif lead_days <= 30: + pace_col = f"d{lead_days}" + elif lead_days <= 177: + # Weekly intervals - find closest + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}" + else: + pace_col = "d177" # Cap at max tracked + + # Query pace data for same DOW to calculate pickup counts + # pace_type 'total' gives us overall covers + result = await db.execute( + text(f""" + SELECT + COALESCE({pace_col}, 0) as otb_at_lead, + COALESCE(d0, 0) as final_covers + FROM resos_booking_pace + WHERE EXTRACT(DOW FROM booking_date) = :dow + AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER) + AND booking_date < CURRENT_DATE + AND d0 > 0 + AND pace_type = 'total' + ORDER BY booking_date DESC + LIMIT :max_weeks + """), + { + "dow": pg_dow, + "lookback_days": lookback_weeks * 7, + "max_weeks": lookback_weeks + } + ) + rows = result.fetchall() + + if not rows: + # No pace data - return 0 (no pickup estimate available) + return 0 + + # Calculate pickup counts for each historical day + pickups = [] + for row in rows: + otb_at_lead = row.otb_at_lead or 0 + final = row.final_covers or 0 + # Pickup = how many came in after this lead time + pickup = max(0, final - otb_at_lead) # Floor at 0 (cancellations shouldn't give negative) + pickups.append(pickup) + + if not pickups: + return 0 + + # Calculate median pickup count + pickups_sorted = sorted(pickups) + n = len(pickups_sorted) + if n % 2 == 0: + median = (pickups_sorted[n // 2 - 1] + pickups_sorted[n // 2]) / 2 + else: + median = pickups_sorted[n // 2] + + return math.ceil(median) # Round up + + +async def get_dinner_non_resident_pickup_by_lead_time( + db: AsyncSession, + target_date: date, + lead_days: int, + lookback_weeks: int = 8 +) -> int: + """ + Get median pickup count for non-resident dinner at a given lead time. + Same logic as lunch - straight pickup count based on historical pace data. + """ + python_dow = target_date.weekday() + pg_dow = (python_dow + 1) % 7 + + if lead_days <= 0: + return 0 + elif lead_days <= 30: + pace_col = f"d{lead_days}" + elif lead_days <= 177: + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}" + else: + pace_col = "d177" + + # Query pace data for non_resident type + result = await db.execute( + text(f""" + SELECT + COALESCE({pace_col}, 0) as otb_at_lead, + COALESCE(d0, 0) as final_covers + FROM resos_booking_pace + WHERE EXTRACT(DOW FROM booking_date) = :dow + AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER) + AND booking_date < CURRENT_DATE + AND d0 > 0 + AND pace_type = 'non_resident' + ORDER BY booking_date DESC + LIMIT :max_weeks + """), + { + "dow": pg_dow, + "lookback_days": lookback_weeks * 7, + "max_weeks": lookback_weeks + } + ) + rows = result.fetchall() + + if not rows: + return 0 + + pickups = [] + for row in rows: + otb_at_lead = row.otb_at_lead or 0 + final = row.final_covers or 0 + pickup = max(0, final - otb_at_lead) + pickups.append(pickup) + + if not pickups: + return 0 + + pickups_sorted = sorted(pickups) + n = len(pickups_sorted) + if n % 2 == 0: + median = (pickups_sorted[n // 2 - 1] + pickups_sorted[n // 2]) / 2 + else: + median = pickups_sorted[n // 2] + + return math.ceil(median) + + +async def get_resident_dining_rate( + db: AsyncSession, + target_date: date, + lookback_weeks: int = 4 +) -> float: + """ + Calculate what % of hotel guests typically dine at the restaurant (resident covers). + + Simple approach: resident_covers / hotel_guests for same DOW over last N weeks. + Returns median rate to apply to forecasted hotel guests. + + Args: + db: Database session + target_date: Date we're forecasting (to get DOW) + lookback_weeks: Weeks of history to analyze + + Returns: + Median dining rate (0.0 to 1.0) + """ + python_dow = target_date.weekday() + pg_dow = (python_dow + 1) % 7 + + # Query resident covers and hotel guests for same DOW + result = await db.execute( + text(""" + SELECT + nbs.date, + COALESCE(nbs.guests_count, 0) as hotel_guests, + COALESCE(rbs.hotel_guest_covers, 0) as resident_covers + FROM newbook_bookings_stats nbs + JOIN resos_bookings_stats rbs ON nbs.date = rbs.date + WHERE EXTRACT(DOW FROM nbs.date) = :dow + AND nbs.date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER) + AND nbs.date < CURRENT_DATE + AND nbs.guests_count > 0 + ORDER BY nbs.date DESC + LIMIT :max_weeks + """), + { + "dow": pg_dow, + "lookback_days": lookback_weeks * 7, + "max_weeks": lookback_weeks + } + ) + rows = result.fetchall() + + if not rows: + return 0.4 # Default 40% if no data + + # Calculate dining rate for each week + dining_rates = [] + for row in rows: + if row.hotel_guests > 0: + rate = min(1.0, row.resident_covers / row.hotel_guests) + dining_rates.append(rate) + + if not dining_rates: + return 0.4 + + # Return median + sorted_rates = sorted(dining_rates) + n = len(sorted_rates) + if n % 2 == 0: + return (sorted_rates[n // 2 - 1] + sorted_rates[n // 2]) / 2 + return sorted_rates[n // 2] + + +async def get_historical_pickup_by_lead_time( + db: AsyncSession, + period_type: str, + is_resident: bool, + lead_days: int, + lookback_weeks: int = 12 +) -> Dict[str, float]: + """ + Calculate historical pickup patterns for a period/segment at a given lead time. + Returns average pickup and pickup rate compared to final. + """ + # Get column name for this lead time + column = f"d{lead_days}" if lead_days <= 30 else f"d{lead_days}" # Use same format for all + + # For lead times with pace data, use pace table + pace_type = 'resident' if is_resident else 'non_resident' + + if lead_days <= 365: # We have pace columns up to d365 + result = await db.execute( + text(f""" + SELECT + AVG(COALESCE({column}, 0)) as avg_at_lead, + AVG(COALESCE(d0, 0)) as avg_final + FROM resos_booking_pace + WHERE pace_type = :pace_type + AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER) + AND booking_date < CURRENT_DATE + """), + {"pace_type": pace_type, "lookback_days": lookback_weeks * 7} + ) + else: + # Use aggregated stats table for period-specific analysis + result = await db.execute( + text(""" + SELECT + AVG(CASE WHEN :is_resident THEN hotel_guest_covers ELSE non_hotel_guest_covers END) as avg_covers + FROM resos_bookings_stats + WHERE date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER) + AND date < CURRENT_DATE + """), + {"is_resident": is_resident, "lookback_days": lookback_weeks * 7} + ) + + row = result.fetchone() + + return { + "avg_at_lead": row.avg_at_lead if row and row.avg_at_lead else 0, + "avg_final": row.avg_final if row and row.avg_final else 0 + } + + +async def forecast_covers_for_date( + db: AsyncSession, + target_date: date, + include_details: bool = False +) -> Dict[str, Any]: + """ + Generate covers forecast for a specific date. + + Returns breakdown by period and segment: + - Breakfast: Based on previous night's occupancy + - Lunch: OTB + non-resident pickup + - Dinner: OTB (resident + non-resident) + pickup for each + """ + today = date.today() + lead_days = (target_date - today).days + prior_year_date = get_prior_year_date(target_date) + + # Get current OTB covers + current_covers = await get_resos_covers_for_date(db, target_date) + + # Get prior year covers + prior_covers = await get_resos_covers_for_date(db, prior_year_date) + + # Get hotel occupancy for the night before (for breakfast) + night_before = target_date - timedelta(days=1) + prior_year_night_before = get_prior_year_date(night_before) + + # Get current hotel OTB for night before + hotel_otb = await get_hotel_occupancy_for_date(db, night_before) + # Get prior year hotel occupancy for night before (tells us expected final) + hotel_prior = await get_hotel_occupancy_for_date(db, prior_year_night_before) + + # Get breakfast rate (covers per room) + breakfast_rate = await get_historical_breakfast_rate(db) + + # Calculate forecasts by period + result = { + "date": target_date.isoformat(), + "day_of_week": target_date.strftime("%a"), + "lead_days": lead_days, + "prior_year_date": prior_year_date.isoformat(), + } + + # ============ BREAKFAST ============ + # Breakfast = hotel guests from night before (guests eat breakfast, not rooms) + # Past: use actual hotel guest count + # Future: OTB guests + pickup from pickupv2 hotel forecast + + hotel_guests_otb = hotel_otb["guests"] + hotel_rooms_otb = hotel_otb["occupied_rooms"] + hotel_guests_prior = hotel_prior["guests"] + hotel_rooms_prior = hotel_prior["occupied_rooms"] + + # Calculate guests per room ratio for converting room forecast to guests + # Use prior year ratio (more stable/representative of final state) with fallbacks + if hotel_rooms_prior > 0: + guests_per_room = hotel_guests_prior / hotel_rooms_prior + elif hotel_rooms_otb > 0: + guests_per_room = hotel_guests_otb / hotel_rooms_otb + else: + guests_per_room = 1.8 # Default fallback + + breakfast_calc = None + if lead_days <= 0: + # PAST: Use actual hotel guest count + breakfast_otb = hotel_guests_otb + breakfast_pickup = 0 + breakfast_forecast = breakfast_otb + else: + # FUTURE: Use pickupv2 model for room forecast + breakfast_otb = hotel_guests_otb + + # Get pickupv2 room forecast for the night before + # (night_before lead_days = lead_days for target_date since breakfast is next morning) + night_before_lead_days = lead_days - 1 # Night before has 1 less lead day + pickup_rooms = 0 + try: + pickupv2_forecast = await forecast_rooms_for_date( + db, + night_before, + night_before_lead_days, + prior_year_night_before, + 'hotel_room_nights' + ) + if pickupv2_forecast: + # Get forecasted rooms and pickup from pickupv2 + forecasted_rooms = pickupv2_forecast.get('predicted_value', hotel_rooms_otb) + pickup_rooms = pickupv2_forecast.get('pickup_rooms_total', 0) + + # Convert pickup rooms to guests using the ratio (round up) + breakfast_pickup = math.ceil(pickup_rooms * guests_per_room) + # Forecast = OTB + pickup (floor is always OTB guests) + breakfast_forecast = breakfast_otb + breakfast_pickup + + # Store calculation details + breakfast_calc = { + "night_before": night_before.isoformat(), + "hotel_rooms_otb": hotel_rooms_otb, + "hotel_guests_otb": hotel_guests_otb, + "pickup_rooms": round(pickup_rooms, 1), + "guests_per_room": round(guests_per_room, 2), + "source": "pickupv2", + } + else: + # Fallback to prior year pattern + breakfast_pickup = max(0, hotel_guests_prior - hotel_guests_otb) + breakfast_forecast = breakfast_otb + breakfast_pickup + breakfast_calc = { + "night_before": night_before.isoformat(), + "hotel_guests_prior": hotel_guests_prior, + "source": "prior_year_fallback", + } + except Exception as e: + logger.warning(f"Pickupv2 forecast failed for {night_before}: {e}") + # Fallback to prior year pattern + breakfast_pickup = max(0, hotel_guests_prior - hotel_guests_otb) + breakfast_forecast = breakfast_otb + breakfast_pickup + breakfast_calc = { + "night_before": night_before.isoformat(), + "hotel_guests_prior": hotel_guests_prior, + "source": "prior_year_fallback", + } + + # Prior year breakfast (for comparison) + prior_breakfast = hotel_guests_prior + + result["breakfast"] = { + "otb": breakfast_otb, + "pickup": breakfast_pickup, + "forecast": breakfast_forecast, + "prior_year": prior_breakfast, + "hotel_guests_otb": hotel_guests_otb, + "hotel_guests_prior": hotel_guests_prior, + "calc": breakfast_calc, + } + + # ============ LUNCH ============ + # Lunch: OTB + pickup based on median historical pickup at lead time + # Uses straight pickup count (not ratio) for stability + lunch_data = current_covers.get("lunch", {}) + lunch_otb = lunch_data.get("total_covers", 0) + prior_lunch = prior_covers.get("lunch", {}).get("total_covers", 0) + + # Get median pickup count for this lead time and DOW + lunch_pickup = await get_lunch_pickup_by_lead_time(db, target_date, lead_days) + + # Determine pace column for tooltip + if lead_days <= 30: + lunch_pace_col = f"d{lead_days}" + elif lead_days <= 177: + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + lunch_pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}" + else: + lunch_pace_col = "d177" + + lunch_calc = None + # For future dates, add pickup to OTB + if lead_days > 0: + lunch_forecast = lunch_otb + lunch_pickup + lunch_calc = { + "day_of_week": target_date.strftime("%A"), + "lead_days": lead_days, + "pace_column": lunch_pace_col, + "lookback_weeks": 8, + "median_pickup": lunch_pickup, + "source": "resos_booking_pace (total)", + } + else: + # Past date - no pickup + lunch_pickup = 0 + lunch_forecast = lunch_otb + + result["lunch"] = { + "otb": lunch_otb, + "pickup": lunch_pickup, + "forecast": lunch_forecast, + "prior_year": prior_lunch, + "calc": lunch_calc, + } + + # ============ DINNER ============ + # Dinner: More sophisticated calculation + # - Non-resident: Lead-time based median pickup (like lunch) + # - Resident: Based on hotel guests without dinner reservations + conversion rate + dinner_data = current_covers.get("dinner", {}) + dinner_otb = dinner_data.get("total_covers", 0) + dinner_resident_otb = dinner_data.get("resident_covers", 0) + dinner_non_resident_otb = dinner_data.get("non_resident_covers", 0) + dinner_dbb_otb = dinner_data.get("dbb_covers", 0) + + prior_dinner = prior_covers.get("dinner", {}).get("total_covers", 0) + prior_dinner_resident = prior_covers.get("dinner", {}).get("resident_covers", 0) + prior_dinner_non_resident = prior_covers.get("dinner", {}).get("non_resident_covers", 0) + + # Determine pace column for non-resident tooltip + if lead_days <= 30: + dinner_pace_col = f"d{lead_days}" + elif lead_days <= 177: + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + dinner_pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}" + else: + dinner_pace_col = "d177" + + non_resident_calc = None + if lead_days > 0: + # ---- NON-RESIDENT PICKUP ---- + # Use lead-time based median pickup (same logic as lunch) + non_resident_pickup = await get_dinner_non_resident_pickup_by_lead_time(db, target_date, lead_days) + non_resident_calc = { + "day_of_week": target_date.strftime("%A"), + "lead_days": lead_days, + "pace_column": dinner_pace_col, + "lookback_weeks": 8, + "median_pickup": non_resident_pickup, + "source": "resos_booking_pace (non_resident)", + } + + # ---- RESIDENT PICKUP ---- + # Simple approach: % of hotel guests who dine, applied to forecasted guests + # Get hotel occupancy for target_date (dinner is same night as stay) + hotel_tonight = await get_hotel_occupancy_for_date(db, target_date) + hotel_guests_otb = hotel_tonight["guests"] + hotel_rooms_otb = hotel_tonight["occupied_rooms"] + + # Calculate guests per room (use prior year ratio if current is 0) + prior_year_hotel = await get_hotel_occupancy_for_date(db, prior_year_date) + if hotel_rooms_otb > 0: + guests_per_room = hotel_guests_otb / hotel_rooms_otb + elif prior_year_hotel["occupied_rooms"] > 0: + guests_per_room = prior_year_hotel["guests"] / prior_year_hotel["occupied_rooms"] + else: + guests_per_room = 1.8 # Default + + # Get pickupv2 room forecast for tonight + pickup_rooms = 0 + try: + pickupv2_dinner = await forecast_rooms_for_date( + db, target_date, lead_days, prior_year_date, 'hotel_room_nights' + ) + if pickupv2_dinner: + pickup_rooms = pickupv2_dinner.get('pickup_rooms_total', 0) + except Exception as e: + logger.warning(f"Pickupv2 forecast failed for dinner {target_date}: {e}") + + # Calculate forecasted hotel guests (OTB + pickup) + pickup_guests = pickup_rooms * guests_per_room + forecasted_guests = hotel_guests_otb + pickup_guests + + # Get historical resident dining rate (% of hotel guests who dine) + dining_rate = await get_resident_dining_rate(db, target_date) + + # Calculate expected resident covers + # forecasted_resident_covers = forecasted_guests × dining_rate + forecasted_resident_covers = forecasted_guests * dining_rate + + # Resident pickup = expected total - current OTB resident covers + resident_pickup = max(0, math.ceil(forecasted_resident_covers) - dinner_resident_otb) + + dinner_forecast = dinner_otb + resident_pickup + non_resident_pickup + + # Store calculation details for tooltip + resident_calc = { + "hotel_guests_otb": hotel_guests_otb, + "pickup_rooms": round(pickup_rooms, 1), + "guests_per_room": round(guests_per_room, 2), + "pickup_guests": round(pickup_guests, 1), + "forecasted_guests": round(forecasted_guests, 1), + "dining_rate": round(dining_rate * 100, 1), # As percentage + "forecasted_resident_covers": round(forecasted_resident_covers, 1), + "resident_otb": dinner_resident_otb, + "source": "last 4 weeks same DOW", + } + else: + # Past date - no pickup + dinner_forecast = dinner_otb + resident_pickup = 0 + non_resident_pickup = 0 + resident_calc = None + non_resident_calc = None + + result["dinner"] = { + "otb": dinner_otb, + "resident_otb": dinner_resident_otb, + "non_resident_otb": dinner_non_resident_otb, + "dbb_otb": dinner_dbb_otb, + "resident_pickup": resident_pickup, + "non_resident_pickup": non_resident_pickup, + "forecast": dinner_forecast, + "prior_year": prior_dinner, + "prior_resident": prior_dinner_resident, + "prior_non_resident": prior_dinner_non_resident, + "resident_calc": resident_calc, + "non_resident_calc": non_resident_calc, + } + + # Totals + total_otb = breakfast_otb + lunch_otb + dinner_otb + total_forecast = breakfast_forecast + lunch_forecast + dinner_forecast + prior_total = prior_breakfast + prior_lunch + prior_dinner + + result["totals"] = { + "otb": total_otb, + "forecast": total_forecast, + "prior_year": prior_total, + "pace_vs_prior_pct": round((total_otb / prior_total * 100), 1) if prior_total > 0 else None + } + + # Add hotel occupancy context + result["hotel_context"] = { + "night_before_occupancy": hotel_otb["occupancy_pct"], + "night_before_rooms": hotel_otb["occupied_rooms"], + "night_before_guests": hotel_otb["guests"] + } + + return result + + +async def forecast_covers_range( + db: AsyncSession, + start_date: date, + end_date: date, + include_details: bool = False +) -> Dict[str, Any]: + """ + Generate covers forecast for a date range. + """ + forecasts = [] + current = start_date + + while current <= end_date: + try: + day_forecast = await forecast_covers_for_date(db, current, include_details) + forecasts.append(day_forecast) + except Exception as e: + logger.warning(f"Failed to forecast covers for {current}: {e}") + + current += timedelta(days=1) + + # Calculate summary + summary = { + "breakfast_otb": sum(f["breakfast"]["otb"] for f in forecasts), + "breakfast_forecast": sum(f["breakfast"]["forecast"] for f in forecasts), + "breakfast_prior": sum(f["breakfast"]["prior_year"] for f in forecasts), + "lunch_otb": sum(f["lunch"]["otb"] for f in forecasts), + "lunch_forecast": sum(f["lunch"]["forecast"] for f in forecasts), + "lunch_prior": sum(f["lunch"]["prior_year"] for f in forecasts), + "dinner_otb": sum(f["dinner"]["otb"] for f in forecasts), + "dinner_forecast": sum(f["dinner"]["forecast"] for f in forecasts), + "dinner_prior": sum(f["dinner"]["prior_year"] for f in forecasts), + "total_otb": sum(f["totals"]["otb"] for f in forecasts), + "total_forecast": sum(f["totals"]["forecast"] for f in forecasts), + "total_prior": sum(f["totals"]["prior_year"] for f in forecasts), + "days_count": len(forecasts) + } + + return { + "data": forecasts, + "summary": summary + } diff --git a/backend/services/forecasting/historical_forecast.py b/backend/services/forecasting/historical_forecast.py new file mode 100644 index 0000000..3e22d3b --- /dev/null +++ b/backend/services/forecasting/historical_forecast.py @@ -0,0 +1,659 @@ +""" +Historical Forecast Runner +Runs all models as if it were a specific historical date. + +This allows backtesting of Prophet, XGBoost, and Pickup models +by only using data that would have been available at that time. +""" +import logging +from datetime import date, timedelta +from typing import List, Optional +import pandas as pd +import numpy as np +import json +from sqlalchemy import text + +from utils.time_alignment import get_prior_year_daily + +logger = logging.getLogger(__name__) + + +async def run_historical_forecast( + db, + simulated_today: date, + metric_codes: List[str] = None, + models: List[str] = None, + forecast_days: int = 60 +) -> dict: + """ + Run forecasts as if today were a specific historical date. + + Only uses data that would have been available on simulated_today. + + Args: + db: Database session + simulated_today: The date to pretend "today" is + metric_codes: List of metrics to forecast (default: all main metrics) + models: List of models to run (default: all) + forecast_days: Number of days to forecast (default: 60) + + Returns: + Dict with results summary + """ + if metric_codes is None: + metric_codes = ['hotel_room_nights', 'hotel_occupancy_pct', 'resos_dinner_covers', 'resos_lunch_covers'] + + if models is None: + models = ['prophet', 'xgboost', 'pickup', 'catboost'] + + forecast_from = simulated_today + timedelta(days=1) + forecast_to = simulated_today + timedelta(days=forecast_days) + + results = { + "simulated_today": str(simulated_today), + "forecast_from": str(forecast_from), + "forecast_to": str(forecast_to), + "metrics": {}, + "total_forecasts": 0 + } + + for metric_code in metric_codes: + results["metrics"][metric_code] = {} + + for model in models: + try: + if model == 'prophet': + forecasts = await _run_prophet_historical( + db, metric_code, simulated_today, forecast_from, forecast_to + ) + elif model == 'xgboost': + forecasts = await _run_xgboost_historical( + db, metric_code, simulated_today, forecast_from, forecast_to + ) + elif model == 'pickup': + forecasts = await _run_pickup_historical( + db, metric_code, simulated_today, forecast_from, forecast_to + ) + elif model == 'catboost': + forecasts = await _run_catboost_historical( + db, metric_code, simulated_today, forecast_from, forecast_to + ) + else: + continue + + results["metrics"][metric_code][model] = len(forecasts) + results["total_forecasts"] += len(forecasts) + await db.commit() # Commit after each successful model run + + except Exception as e: + logger.error(f"Historical {model} forecast failed for {metric_code}: {e}") + await db.rollback() # Rollback on error to clear failed transaction + results["metrics"][metric_code][model] = f"error: {str(e)[:100]}" + logger.info(f"Historical forecasts complete for {simulated_today}: {results['total_forecasts']} total forecasts") + + return results + + +async def _run_prophet_historical( + db, + metric_code: str, + simulated_today: date, + forecast_from: date, + forecast_to: date, + training_days: int = 2555 +) -> List[dict]: + """Run Prophet using only data available before simulated_today.""" + try: + from prophet import Prophet + + # Get room capacity for capping (sum across all room categories for a single date) + total_rooms = 25 + if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + rooms_result = await db.execute( + text(""" + SELECT COALESCE(SUM(available), 25) as total_rooms + FROM newbook_occupancy_report + WHERE date = ( + SELECT MAX(date) FROM newbook_occupancy_report + WHERE date <= :simulated_today + ) + """), + {"simulated_today": simulated_today} + ) + rooms_row = rooms_result.fetchone() + if rooms_row and rooms_row.total_rooms: + total_rooms = int(rooms_row.total_rooms) + + # Training data ends at simulated_today - 1 (yesterday from simulated perspective) + training_to = simulated_today - timedelta(days=1) + training_from = training_to - timedelta(days=training_days) + + result = await db.execute( + text(""" + SELECT date, actual_value + FROM daily_metrics + WHERE metric_code = :metric_code + AND date BETWEEN :from_date AND :to_date + AND actual_value IS NOT NULL + ORDER BY date + """), + {"metric_code": metric_code, "from_date": training_from, "to_date": training_to} + ) + rows = result.fetchall() + + if len(rows) < 30: + logger.warning(f"Insufficient data for historical Prophet: {metric_code} has {len(rows)} records as of {simulated_today}") + return [] + + df = pd.DataFrame([{"ds": row.date, "y": float(row.actual_value)} for row in rows]) + + model = Prophet( + yearly_seasonality=True, + weekly_seasonality=True, + daily_seasonality=False, + interval_width=0.80 + ) + model.add_country_holidays(country_name='GB') + model.fit(df) + + future_dates = pd.date_range(start=forecast_from, end=forecast_to, freq='D') + future_df = pd.DataFrame({"ds": future_dates}) + forecast = model.predict(future_df) + + forecasts = [] + for _, row in forecast.iterrows(): + predicted_value = float(row["yhat"]) + lower_bound = float(row["yhat_lower"]) + upper_bound = float(row["yhat_upper"]) + + # Apply physical caps + if metric_code == 'hotel_occupancy_pct': + predicted_value = min(predicted_value, 100) + lower_bound = min(lower_bound, 100) + upper_bound = min(upper_bound, 100) + if metric_code == 'hotel_room_nights': + predicted_value = min(predicted_value, total_rooms) + lower_bound = min(lower_bound, total_rooms) + upper_bound = min(upper_bound, total_rooms) + + forecast_record = { + "forecast_date": row["ds"].date(), + "forecast_type": metric_code, + "model_type": "prophet", + "predicted_value": round(predicted_value, 2), + "lower_bound": round(lower_bound, 2), + "upper_bound": round(upper_bound, 2) + } + forecasts.append(forecast_record) + + # Store with generated_at = simulated_today to track when this "would have been" generated + await db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, + predicted_value, lower_bound, upper_bound, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, + :predicted_value, :lower_bound, :upper_bound, :generated_at + ) + """), + {**forecast_record, "generated_at": simulated_today} + ) + + logger.info(f"Historical Prophet forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records") + return forecasts + + except ImportError: + logger.error("Prophet not installed") + return [] + except Exception as e: + logger.error(f"Historical Prophet failed: {e}") + raise + + +async def _run_xgboost_historical( + db, + metric_code: str, + simulated_today: date, + forecast_from: date, + forecast_to: date, + training_days: int = 2555 +) -> List[dict]: + """Run XGBoost using only data available before simulated_today.""" + try: + import xgboost as xgb + + training_to = simulated_today - timedelta(days=1) + training_from = training_to - timedelta(days=training_days + 60) + + result = await db.execute( + text(""" + SELECT date, actual_value + FROM daily_metrics + WHERE metric_code = :metric_code + AND date BETWEEN :from_date AND :to_date + AND actual_value IS NOT NULL + ORDER BY date + """), + {"metric_code": metric_code, "from_date": training_from, "to_date": training_to} + ) + rows = result.fetchall() + + if len(rows) < 60: + logger.warning(f"Insufficient data for historical XGBoost: {metric_code} has {len(rows)} records") + return [] + + df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows]) + df = df.sort_values('ds').reset_index(drop=True) + df = _create_features(df) + df = df.dropna() + + feature_cols = [ + 'day_of_week', 'month', 'day_of_month', 'week_of_year', 'is_weekend', + 'dow_sin', 'dow_cos', 'month_sin', 'month_cos', + 'lag_7', 'lag_14', 'lag_21', 'lag_28', + 'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28', + 'rolling_std_7', 'rolling_std_14', 'rolling_std_28' + ] + + if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30: + feature_cols.append('lag_365') + + X = df[feature_cols] + y = df['y'] + + model = xgb.XGBRegressor( + n_estimators=100, + max_depth=5, + learning_rate=0.1, + objective='reg:squarederror', + random_state=42 + ) + model.fit(X, y) + + forecasts = [] + current_df = df.copy() + + for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'): + new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}]) + current_df = pd.concat([current_df, new_row], ignore_index=True) + current_df = _create_features(current_df) + + X_pred = current_df[feature_cols].iloc[-1:].ffill() + prediction = float(model.predict(X_pred)[0]) + current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction + + forecast_record = { + "forecast_date": forecast_date.date(), + "forecast_type": metric_code, + "model_type": "xgboost", + "predicted_value": round(float(prediction), 2) + } + forecasts.append(forecast_record) + + await db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, predicted_value, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at + ) + """), + {**forecast_record, "generated_at": simulated_today} + ) + + logger.info(f"Historical XGBoost forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records") + return forecasts + + except ImportError as e: + logger.error(f"Required package not installed: {e}") + return [] + except Exception as e: + logger.error(f"Historical XGBoost failed: {e}") + raise + + +async def _run_pickup_historical( + db, + metric_code: str, + simulated_today: date, + forecast_from: date, + forecast_to: date +) -> List[dict]: + """Run Pickup model using only data available before simulated_today.""" + + forecasts = [] + + # Get room capacity (sum of available rooms across all categories for a single date) + total_rooms = 25 + if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + rooms_result = await db.execute( + text(""" + SELECT COALESCE(SUM(available), 25) as total_rooms + FROM newbook_occupancy_report + WHERE date = ( + SELECT MAX(date) FROM newbook_occupancy_report + WHERE date <= :simulated_today + ) + """), + {"simulated_today": simulated_today} + ) + rooms_row = rooms_result.fetchone() + if rooms_row and rooms_row.total_rooms: + total_rooms = int(rooms_row.total_rooms) + + for days_out in range((forecast_to - forecast_from).days + 1): + forecast_date = forecast_from + timedelta(days=days_out) + lead_time = (forecast_date - simulated_today).days + + if lead_time < 1: + continue + + # Calculate prior year comparison date + prior_year_date = get_prior_year_daily(forecast_date) + + # Get/reconstruct current OTB as of simulated_today + current_otb = await _get_reconstructed_otb( + db, metric_code, forecast_date, simulated_today, total_rooms + ) + + if current_otb is None: + continue + + # Get prior year OTB at same lead time + prior_simulated_today = get_prior_year_daily(simulated_today) + prior_otb = await _get_reconstructed_otb( + db, metric_code, prior_year_date, prior_simulated_today, total_rooms + ) + + # Get prior year final actual + prior_final_result = await db.execute( + text(""" + SELECT actual_value + FROM daily_metrics + WHERE date = :prior_date AND metric_code = :metric + """), + {"prior_date": prior_year_date, "metric": metric_code} + ) + prior_final_row = prior_final_result.fetchone() + prior_final = float(prior_final_row.actual_value) if prior_final_row and prior_final_row.actual_value else None + + # Calculate projection using additive method + projected_value = current_otb + + if prior_otb is not None and prior_final is not None: + prior_pickup = prior_final - prior_otb + projected_value = current_otb + prior_pickup + + if projected_value < current_otb: + projected_value = current_otb + + # Apply caps + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + if metric_code == 'hotel_room_nights' and projected_value > total_rooms: + projected_value = total_rooms + + elif prior_final is not None and prior_final > 0: + # Implied additive + if lead_time >= 28: + estimated_pct = 0.35 + elif lead_time >= 14: + estimated_pct = 0.55 + elif lead_time >= 7: + estimated_pct = 0.75 + else: + estimated_pct = 0.90 + + implied_pickup = prior_final * (1 - estimated_pct) + projected_value = current_otb + implied_pickup + projected_value = max(projected_value, current_otb) + + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + if metric_code == 'hotel_room_nights' and projected_value > total_rooms: + projected_value = total_rooms + + forecast_record = { + "forecast_date": forecast_date, + "forecast_type": metric_code, + "model_type": "pickup", + "predicted_value": round(projected_value, 2) + } + forecasts.append(forecast_record) + + await db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, predicted_value, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at + ) + """), + {**forecast_record, "generated_at": simulated_today} + ) + + logger.info(f"Historical Pickup forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records") + return forecasts + + +async def _run_catboost_historical( + db, + metric_code: str, + simulated_today: date, + forecast_from: date, + forecast_to: date, + training_days: int = 2555 +) -> List[dict]: + """Run CatBoost using only data available before simulated_today.""" + try: + from catboost import CatBoostRegressor + + training_to = simulated_today - timedelta(days=1) + training_from = training_to - timedelta(days=training_days + 60) + + result = await db.execute( + text(""" + SELECT date, actual_value + FROM daily_metrics + WHERE metric_code = :metric_code + AND date BETWEEN :from_date AND :to_date + AND actual_value IS NOT NULL + ORDER BY date + """), + {"metric_code": metric_code, "from_date": training_from, "to_date": training_to} + ) + rows = result.fetchall() + + if len(rows) < 60: + logger.warning(f"Insufficient data for historical CatBoost: {metric_code} has {len(rows)} records") + return [] + + df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows]) + df = df.sort_values('ds').reset_index(drop=True) + df = _create_catboost_features(df) + df = df.dropna() + + categorical_features = ['day_of_week', 'month'] + numerical_features = [ + 'day_of_month', 'week_of_year', 'is_weekend', + 'lag_7', 'lag_14', 'lag_21', 'lag_28', + 'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28', + 'rolling_std_7', 'rolling_std_14', 'rolling_std_28' + ] + + if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30: + numerical_features.append('lag_365') + + feature_cols = categorical_features + numerical_features + + X = df[feature_cols] + y = df['y'] + + model = CatBoostRegressor( + iterations=200, + depth=6, + learning_rate=0.1, + loss_function='RMSE', + cat_features=categorical_features, + verbose=False, + random_seed=42 + ) + model.fit(X, y) + + forecasts = [] + current_df = df.copy() + + for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'): + new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}]) + current_df = pd.concat([current_df, new_row], ignore_index=True) + current_df = _create_catboost_features(current_df) + + X_pred = current_df[feature_cols].iloc[-1:].copy() + for col in numerical_features: + if col in X_pred.columns: + X_pred[col] = X_pred[col].ffill() + if X_pred[col].isna().any(): + X_pred[col] = X_pred[col].fillna(0) + + prediction = float(model.predict(X_pred)[0]) + prediction = max(0, prediction) + current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction + + forecast_record = { + "forecast_date": forecast_date.date(), + "forecast_type": metric_code, + "model_type": "catboost", + "predicted_value": round(float(prediction), 2) + } + forecasts.append(forecast_record) + + await db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, predicted_value, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at + ) + """), + {**forecast_record, "generated_at": simulated_today} + ) + + logger.info(f"Historical CatBoost forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records") + return forecasts + + except ImportError as e: + logger.error(f"CatBoost not installed: {e}") + return [] + except Exception as e: + logger.error(f"Historical CatBoost failed: {e}") + raise + + +def _create_catboost_features(df: pd.DataFrame) -> pd.DataFrame: + """Create features for CatBoost model with native categorical support.""" + df = df.copy() + + df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical for CatBoost + df['month'] = df['ds'].dt.month.astype(str) # Categorical for CatBoost + df['day_of_month'] = df['ds'].dt.day + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int) + + for lag in [7, 14, 21, 28]: + df[f'lag_{lag}'] = df['y'].shift(lag) + + for window in [7, 14, 28]: + df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean() + df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std().fillna(0) + + if len(df) > 365: + df['lag_365'] = df['y'].shift(365) + + return df + + +async def _get_reconstructed_otb( + db, + metric_code: str, + target_date: date, + as_of_date: date, + total_rooms: int = 25 +) -> Optional[float]: + """ + Get or reconstruct OTB value for a target date as of a specific date. + + First tries pickup_snapshots, then reconstructs from booking data. + """ + # Try snapshots first + snap_result = await db.execute( + text(""" + SELECT otb_value + FROM pickup_snapshots + WHERE stay_date = :target_date + AND metric_type = :metric + AND snapshot_date <= :as_of_date + ORDER BY snapshot_date DESC + LIMIT 1 + """), + {"target_date": target_date, "metric": metric_code, "as_of_date": as_of_date} + ) + snap_row = snap_result.fetchone() + + if snap_row and snap_row.otb_value is not None: + return float(snap_row.otb_value) + + # Reconstruct from booking data + # EXCLUDES overflow category (category_id=5) used for chargeable no-shows + if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + # Use CAST instead of :: to avoid asyncpg parameter parsing issues + recon_result = await db.execute( + text(""" + SELECT COUNT(DISTINCT newbook_id) as otb_count + FROM newbook_bookings + WHERE arrival_date <= :target_date + AND departure_date > :target_date + AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist') + AND CAST(raw_json->>'booking_placed' AS timestamp) <= CAST(:as_of_date AS date) + INTERVAL '1 day' + AND (category_id IS NULL OR category_id != '5') + """), + {"target_date": target_date, "as_of_date": as_of_date} + ) + recon_row = recon_result.fetchone() + + if recon_row: + otb_count = recon_row.otb_count or 0 + if metric_code == 'hotel_occupancy_pct': + return (otb_count / total_rooms) * 100 if total_rooms > 0 else 0 + else: + return otb_count + + return None + + +def _create_features(df: pd.DataFrame) -> pd.DataFrame: + """Create features for XGBoost model.""" + df = df.copy() + + df['day_of_week'] = df['ds'].dt.dayofweek + df['month'] = df['ds'].dt.month + df['day_of_month'] = df['ds'].dt.day + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['day_of_week'] >= 5).astype(int) + + df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7) + df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7) + df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12) + df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12) + + for lag in [7, 14, 21, 28]: + df[f'lag_{lag}'] = df['y'].shift(lag) + + for window in [7, 14, 28]: + df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean() + df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std() + + if len(df) > 365: + df['lag_365'] = df['y'].shift(365) + + return df diff --git a/backend/services/forecasting/pickup_model.py b/backend/services/forecasting/pickup_model.py new file mode 100644 index 0000000..863c06b --- /dev/null +++ b/backend/services/forecasting/pickup_model.py @@ -0,0 +1,332 @@ +""" +Pickup forecasting model +Hotel industry standard pace/pickup tracking +Compares current on-the-books vs historical patterns + +Uses ADDITIVE method for small properties: +- Projected = current_otb + expected_pickup_count +- Where expected_pickup_count = prior_year_final - prior_year_otb + +This avoids ratio distortion with small numbers (e.g., 2→6 = 3x ratio +applied to 5 = 15 rooms, which is unrealistic) + +Prior year comparison uses 364 days (52 weeks) for day-of-week alignment: +- Monday compares to Monday +- Saturday compares to Saturday +""" +import logging +from datetime import date, timedelta +from typing import List, Optional +from sqlalchemy import text + +from utils.time_alignment import get_prior_year_daily, get_comparison_info +from utils.capacity import get_bookable_cap_sync + +logger = logging.getLogger(__name__) + + +async def run_pickup_forecast( + db, + metric_code: str, + forecast_from: date, + forecast_to: date +) -> List[dict]: + """ + Run Pickup model forecast for a metric + + The pickup model works by: + 1. Getting current on-the-books (OTB) for each future date + 2. Comparing to prior year SAME DAY OF WEEK at same lead time + 3. Projecting final using ADDITIVE method (not ratio) for reliability + + Additive method: projected = current_otb + (prior_final - prior_otb) + This represents: "what I have now + what typically picks up from here" + + Args: + db: Database session + metric_code: Metric to forecast + forecast_from: Start date for forecasts + forecast_to: End date for forecasts + + Returns: + List of forecast records + """ + forecasts = [] + today = date.today() + + for days_out in range((forecast_to - forecast_from).days + 1): + forecast_date = forecast_from + timedelta(days=days_out) + lead_time = (forecast_date - today).days + + if lead_time < 1: + continue # Can't do pickup for past dates + + # Calculate prior year comparison date (same day of week alignment) + prior_year_date = get_prior_year_daily(forecast_date) + + # Get bookable rooms for this date (rooms - maintenance, for capping) + bookable_cap = get_bookable_cap_sync(db, forecast_date, fallback_value=25) + + # Get current OTB from snapshots + otb_result = db.execute( + text(""" + SELECT otb_value, prior_year_otb, prior_year_final + FROM pickup_snapshots + WHERE stay_date = :forecast_date + AND metric_type = :metric_code + AND snapshot_date = :today + """), + {"forecast_date": forecast_date, "metric_code": metric_code, "today": today} + ) + otb_row = otb_result.fetchone() + + if not otb_row: + # No OTB data available, skip + continue + + current_otb = float(otb_row.otb_value) if otb_row.otb_value is not None else 0 + # Use 'is not None' - 0 is valid data meaning no bookings at that lead time + prior_otb = float(otb_row.prior_year_otb) if otb_row.prior_year_otb is not None else None + prior_final = float(otb_row.prior_year_final) if otb_row.prior_year_final is not None else None + + # Get pickup curve for this day of week and season (fallback) + day_of_week = forecast_date.weekday() + day_name = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][day_of_week] + month = forecast_date.month + + # Determine season + if month in [6, 7, 8]: + season = 'peak' + elif month in [12, 1, 2]: + season = 'low' + else: + season = 'shoulder' + + curve_result = db.execute( + text(""" + SELECT avg_pct_of_final, std_dev + FROM pickup_curves + WHERE day_of_week = :dow + AND season = :season + AND metric_type = :metric_code + AND days_out = :lead_time + """), + {"dow": day_of_week, "season": season, "metric_code": metric_code, "lead_time": lead_time} + ) + curve_row = curve_result.fetchone() + + # Calculate projection + projected_value = current_otb + projection_method = 'current_otb' + pace_vs_prior = None + confidence_note = "Using current on-the-books" + + if prior_otb is not None and prior_final is not None: + # Calculate expected pickup count from prior year + prior_pickup_count = prior_final - prior_otb # How many picked up from this lead time + + # Calculate pace vs prior year + if prior_otb > 0: + pace_vs_prior = ((current_otb - prior_otb) / prior_otb) * 100 + + # ADDITIVE METHOD: current + expected pickup + # This is more reliable for small properties than ratio method + # Example: prior had 2 OTB → 6 final = 4 pickup + # current has 5 OTB → project 5 + 4 = 9 + projected_value = current_otb + prior_pickup_count + + # Ensure projection is at least current OTB (pickup can't be negative in projection) + if projected_value < current_otb: + projected_value = current_otb + projection_method = 'additive_floor' + confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: {prior_otb:.0f}→{prior_final:.0f} (negative pickup, using OTB)" + else: + projection_method = 'additive' + confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: {prior_otb:.0f}→{prior_final:.0f} (+{prior_pickup_count:.0f} pickup)" + + # Apply physical caps + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + confidence_note += " (capped at 100%)" + if metric_code == 'hotel_room_nights' and projected_value > bookable_cap: + projected_value = bookable_cap + confidence_note += f" (capped at {bookable_cap} bookable rooms)" + + elif prior_final is not None and prior_final > 0: + # No prior OTB, but have prior final - use as guidance + # Estimate typical OTB percentage at this lead time + if lead_time >= 28: + estimated_pct = 0.35 + elif lead_time >= 14: + estimated_pct = 0.55 + elif lead_time >= 7: + estimated_pct = 0.75 + else: + estimated_pct = 0.90 + + # Calculate implied pickup from typical percentages + implied_prior_otb = prior_final * estimated_pct + implied_pickup = prior_final - implied_prior_otb + + # Apply additive method with implied pickup + projected_value = current_otb + implied_pickup + projected_value = max(projected_value, current_otb) + + projection_method = 'implied_additive' + confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: final was {prior_final:.0f}, est +{implied_pickup:.0f} pickup at {lead_time}d" + + # Apply physical caps + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + confidence_note += " (capped at 100%)" + if metric_code == 'hotel_room_nights' and projected_value > bookable_cap: + projected_value = bookable_cap + confidence_note += f" (capped at {bookable_cap} bookable rooms)" + + elif curve_row and curve_row.avg_pct_of_final > 0: + # Curve method: project based on historical pickup curve + projected_value = current_otb / (curve_row.avg_pct_of_final / 100) + projection_method = 'curve' + confidence_note = f"Based on pickup curve ({curve_row.avg_pct_of_final:.1f}% typical at {lead_time} days out)" + + # Apply physical caps + if metric_code == 'hotel_occupancy_pct' and projected_value > 100: + projected_value = 100 + confidence_note += " (capped at 100%)" + if metric_code == 'hotel_room_nights' and projected_value > bookable_cap: + projected_value = bookable_cap + confidence_note += f" (capped at {bookable_cap} bookable rooms)" + + forecast_record = { + "forecast_date": forecast_date, + "forecast_type": metric_code, + "model_type": "pickup", + "predicted_value": round(projected_value, 2) + } + forecasts.append(forecast_record) + + # Store in database + db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, predicted_value, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, :predicted_value, NOW() + ) + """), + forecast_record + ) + + # Store explanation + try: + db.execute( + text(""" + INSERT INTO pickup_explanations ( + forecast_date, forecast_type, current_otb, days_out, + comparison_otb, comparison_final, + pickup_curve_pct, pace_vs_prior_pct, projection_method, + projected_value, confidence_note, generated_at + ) VALUES ( + :date, :metric, :otb, :days_out, + :prior_otb, :prior_final, + :curve_pct, :pace, :method, + :projected, :confidence, NOW() + ) + """), + { + "date": forecast_date, + "metric": metric_code, + "otb": current_otb, + "days_out": lead_time, + "prior_otb": prior_otb, + "prior_final": prior_final, + "curve_pct": curve_row.avg_pct_of_final if curve_row else None, + "pace": pace_vs_prior, + "method": projection_method, + "projected": projected_value, + "confidence": confidence_note + } + ) + except Exception: + pass # Skip if conflict, explanations are supplementary + + db.commit() + logger.info(f"Pickup forecast generated for {metric_code}: {len(forecasts)} records") + return forecasts + + +async def update_pickup_curves(db, metric_code: str, lookback_days: int = 2555): + """ + Update historical pickup curves from actuals + + Calculates average percentage of final value at each lead time + """ + logger.info(f"Updating pickup curves for {metric_code}") + + # For each day of week and season + for dow in range(7): + for season in ['peak', 'shoulder', 'low']: + # Get historical final values and snapshots + result = db.execute( + text(""" + WITH final_values AS ( + SELECT date, actual_value + FROM daily_metrics + WHERE metric_code = :metric_code + AND date > CURRENT_DATE - :lookback + AND actual_value IS NOT NULL + AND EXTRACT(DOW FROM date) = :dow + ), + snapshot_data AS ( + SELECT + ps.stay_date, + ps.days_out, + ps.otb_value, + fv.actual_value as final_value + FROM pickup_snapshots ps + JOIN final_values fv ON ps.stay_date = fv.date + WHERE ps.metric_type = :metric_code + ) + SELECT + days_out, + AVG(otb_value / NULLIF(final_value, 0) * 100) as avg_pct, + STDDEV(otb_value / NULLIF(final_value, 0) * 100) as std_pct, + COUNT(*) as sample_count + FROM snapshot_data + WHERE final_value > 0 + GROUP BY days_out + HAVING COUNT(*) >= 5 + """), + {"metric_code": metric_code, "lookback": lookback_days, "dow": dow} + ) + + for row in result.fetchall(): + db.execute( + text(""" + INSERT INTO pickup_curves ( + day_of_week, season, metric_type, days_out, + avg_pct_of_final, std_dev, sample_count, updated_at + ) VALUES ( + :dow, :season, :metric, :days_out, + :avg_pct, :std, :count, NOW() + ) + ON CONFLICT (day_of_week, season, metric_type, days_out) + DO UPDATE SET + avg_pct_of_final = :avg_pct, + std_dev = :std, + sample_count = :count, + updated_at = NOW() + """), + { + "dow": dow, + "season": season, + "metric": metric_code, + "days_out": row.days_out, + "avg_pct": row.avg_pct, + "std": row.std_pct, + "count": row.sample_count + } + ) + + db.commit() + logger.info(f"Pickup curves updated for {metric_code}") diff --git a/backend/services/forecasting/pickup_tuned.py b/backend/services/forecasting/pickup_tuned.py new file mode 100644 index 0000000..66e89a5 --- /dev/null +++ b/backend/services/forecasting/pickup_tuned.py @@ -0,0 +1,177 @@ +""" +Pickup Tuned Model Service + +This is the production-tuned Pickup model extracted from the preview endpoint. +Uses the exact same logic as the frontend preview to ensure value consistency. + +Pickup Formula: Forecast = Current OTB + (Prior Year Final - Prior Year OTB) + +This transparent model calculates expected pickup based on prior year booking patterns. +Only works for room-based metrics (occupancy, rooms). Not applicable to revenue metrics. +""" +import logging +from datetime import date, timedelta +from typing import List, Dict, Optional +from sqlalchemy import text + +from utils.capacity import get_bookable_cap + +logger = logging.getLogger(__name__) + + +def get_lead_time_column(lead_days: int) -> str: + """Map lead days to the appropriate column in newbook_booking_pace.""" + if lead_days <= 0: + return "d0" + elif lead_days <= 30: + return f"d{lead_days}" + elif lead_days <= 177: + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + for col in weekly_cols: + if lead_days <= col: + return f"d{col}" + return "d177" + else: + monthly_cols = [210, 240, 270, 300, 330, 365] + for col in monthly_cols: + if lead_days <= col: + return f"d{col}" + return "d365" + + +async def run_pickup_tuned_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + perception_date: Optional[date] = None +) -> List[Dict]: + """ + Generate Pickup forecast using production-tuned model. + + Uses transparent booking pace formula: + Forecast = Current OTB + (Prior Year Final - Prior Year OTB) + + This uses the exact same logic as the preview endpoint to ensure + backend snapshots match frontend preview values. + + Args: + db: Database session + metric_code: Metric to forecast (only room-based metrics supported) + start_date: Start date for forecast + end_date: End date for forecast + perception_date: Optional date to generate forecast as-of (for backtesting) + + Returns: + List of forecast dicts with forecast_date and predicted_value + """ + logger.info(f"Running Pickup tuned forecast for {metric_code}: {start_date} to {end_date}") + + # Map metric codes to preview endpoint metric names + metric_map = { + 'hotel_occupancy_pct': 'occupancy', + 'hotel_room_nights': 'rooms', + 'hotel_guests': 'guests', + } + + metric = metric_map.get(metric_code, 'rooms') + + # Check if metric is room-based (pickup model only works for these) + is_room_based = metric in ('occupancy', 'rooms') + if not is_room_based: + logger.warning(f"Pickup model doesn't apply to non-room metric: {metric_code}") + return [] + + # Use perception_date if provided, otherwise use actual today + today = perception_date if perception_date else date.today() + + # Get default bookable cap + default_bookable_cap = await get_bookable_cap(db) + + # Generate forecasts for each date + forecasts = [] + + current_date = start_date + while current_date <= end_date: + lead_days = (current_date - today).days + if lead_days < 0: + current_date += timedelta(days=1) + continue + + lead_col = get_lead_time_column(lead_days) + prior_year_date = current_date - timedelta(days=364) # 52 weeks for DOW alignment + + # Get current OTB + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": current_date}) + current_row = current_result.fetchone() + + # Get prior year OTB from booking_pace (for lead time comparison) + prior_year_for_otb = current_date - timedelta(days=364) + prior_otb_query = text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb}) + prior_otb_row = prior_otb_result.fetchone() + + # Get prior year FINAL from bookings_stats + prior_final_query = text(""" + SELECT booking_count as prior_final + FROM newbook_bookings_stats + WHERE date = :prior_date + """) + prior_final_result = await db.execute(prior_final_query, {"prior_date": prior_year_date}) + prior_final_row = prior_final_result.fetchone() + + # Extract values + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None + prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final is not None else 0 + + # Get per-date bookable cap + date_bookable_cap = await get_bookable_cap(db, current_date, default_bookable_cap) + + # Convert to occupancy % if metric is occupancy + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + if prior_otb is not None: + prior_otb = (prior_otb / date_bookable_cap) * 100 + if prior_final is not None: + prior_final = (prior_final / date_bookable_cap) * 100 + + # Calculate forecast using pickup formula + forecast = None + + if current_otb is not None: + if prior_final is not None and prior_otb is not None: + expected_pickup = prior_final - prior_otb + forecast = current_otb + expected_pickup + # Floor to current OTB if pickup is negative + if forecast < current_otb: + forecast = current_otb + # Cap at max capacity (uses per-date bookable cap) + if metric == "occupancy" and forecast > 100: + forecast = 100.0 + elif metric == "rooms" and forecast > date_bookable_cap: + forecast = float(date_bookable_cap) + else: + # No prior year data - use current OTB as forecast + forecast = current_otb + + if forecast is not None: + forecasts.append({ + 'forecast_date': current_date, + 'predicted_value': round(forecast, 1) + }) + + current_date += timedelta(days=1) + + logger.info(f"Pickup tuned generated {len(forecasts)} forecasts for {metric_code}") + return forecasts diff --git a/backend/services/forecasting/pickup_v2_model.py b/backend/services/forecasting/pickup_v2_model.py new file mode 100644 index 0000000..143120a --- /dev/null +++ b/backend/services/forecasting/pickup_v2_model.py @@ -0,0 +1,1367 @@ +""" +Pickup-V2 Forecasting Model - Self-contained revenue forecasting with confidence bands + +This model runs alongside the existing pickup model, adding: +1. Revenue forecasting for accommodation using per-category rate tracking +2. Confidence bands based on rate range analysis (ADR position between min/max) +3. Per-category breakdown for detailed analysis + +Uses 364-day offset for prior year comparison (52 weeks = day-of-week alignment). +""" +import logging +from datetime import date, timedelta +from decimal import Decimal +from typing import Dict, List, Optional, Any, Tuple + +from sqlalchemy import text + +logger = logging.getLogger(__name__) + +# Valid booking statuses for aggregation +VALID_STATUSES = ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + +# All tracked pace intervals (same as booking_pace table structure) +PACE_INTERVALS = [ + # Monthly (months 7-12) + 365, 330, 300, 270, 240, 210, + # Weekly (weeks 5-25) + 177, 170, 163, 156, 149, 142, 135, 128, 121, 114, + 107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37, + # Daily (days 0-30) + 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 +] + + +def get_lead_time_column(lead_days: int) -> str: + """ + Map lead days to the appropriate column in pace tables. + Uses round-up logic for days between tracked intervals. + """ + if lead_days <= 0: + return "d0" + elif lead_days <= 30: + return f"d{lead_days}" + elif lead_days <= 177: + # Weekly intervals - find next higher + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + for col in weekly_cols: + if lead_days <= col: + return f"d{col}" + return "d177" + else: + # Monthly intervals + monthly_cols = [210, 240, 270, 300, 330, 365] + for col in monthly_cols: + if lead_days <= col: + return f"d{col}" + return "d365" + + +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) + + +async def get_current_otb_revenue(db, stay_date: date) -> Decimal: + """ + Get current on-the-books revenue for a stay date. + + For current state, calculate from actual bookings directly (more accurate than pace snapshots). + Sums net accommodation revenue for all bookings spanning the stay_date. + """ + # Get VAT rate for net calculation + vat_result = await db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ) + row = vat_result.fetchone() + vat_rate = Decimal(row.config_value) if row and row.config_value else Decimal('0.20') + + # Get included categories + cat_result = await db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + return Decimal('0') + + # Query actual bookings for real-time OTB revenue + result = await db.execute( + text(""" + SELECT raw_json + FROM newbook_bookings_data + WHERE arrival_date <= :stay_date + AND departure_date > :stay_date + AND status IN ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + """), + { + "stay_date": stay_date, + "categories": included_categories + } + ) + + total_revenue = Decimal('0') + for row in result.fetchall(): + if row.raw_json: + revenue = _extract_day_rate_from_booking(row.raw_json, stay_date, vat_rate) + total_revenue += revenue + + return total_revenue + + +async def get_revenue_at_lead_time(db, stay_date: date, lead_days: int) -> Decimal: + """ + Get revenue snapshot at a specific lead time for a stay date. + """ + column = get_lead_time_column(lead_days) + + result = await db.execute( + text(f""" + SELECT {column} as revenue + FROM revenue_pace + WHERE stay_date = :stay_date + """), + {"stay_date": stay_date} + ) + row = result.fetchone() + + if row and row.revenue is not None: + return Decimal(str(row.revenue)) + return Decimal('0') + + +async def get_actual_revenue(db, stay_date: date) -> Decimal: + """ + Get actual final revenue for a stay date from newbook_net_revenue_data. + """ + result = await db.execute( + text(""" + SELECT accommodation + FROM newbook_net_revenue_data + WHERE date = :stay_date + """), + {"stay_date": stay_date} + ) + row = result.fetchone() + + if row and row.accommodation is not None: + return Decimal(str(row.accommodation)) + return Decimal('0') + + +async def get_prior_otb_revenue_from_bookings( + db, + prior_date: date, + lead_days: int, + vat_rate: Decimal +) -> Decimal: + """ + Calculate prior year OTB revenue by looking at bookings that: + 1. Span the prior year date (arrival <= date < departure) + 2. Were placed BEFORE the equivalent lead time cutoff + + This gives us what was "on the books" at the same lead time last year. + + Args: + prior_date: The prior year stay date (e.g., Feb 16, 2025) + lead_days: Current lead days (e.g., 7 days out) + vat_rate: VAT rate for net calculation + + Returns: + Net accommodation revenue that was booked at same lead time + """ + # Calculate the cutoff date - bookings must have been placed before this + # This is "today" equivalent in the prior year + cutoff_date = prior_date - timedelta(days=lead_days) + + # Get included categories + cat_result = await db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + return Decimal('0') + + # Query bookings that span the prior date and were placed before cutoff + result = await db.execute( + text(""" + SELECT raw_json + FROM newbook_bookings_data + WHERE arrival_date <= :prior_date + AND departure_date > :prior_date + AND booking_placed < :cutoff_date + AND status IN ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + """), + { + "prior_date": prior_date, + "cutoff_date": cutoff_date, + "categories": included_categories + } + ) + + total_revenue = Decimal('0') + for row in result.fetchall(): + if row.raw_json: + revenue = _extract_day_rate_from_booking(row.raw_json, prior_date, vat_rate) + total_revenue += revenue + + return total_revenue + + +def _extract_day_rate_from_booking(raw_json: dict, target_date: date, vat_rate: Decimal) -> Decimal: + """ + Extract net accommodation revenue from booking JSON for a specific stay date. + Returns net rate only (for backward compatibility). + """ + net, _ = _extract_day_rates_from_booking(raw_json, target_date, vat_rate) + return net + + +def _extract_day_rates_from_booking(raw_json: dict, target_date: date, vat_rate: Decimal) -> Tuple[Decimal, Decimal]: + """ + Extract both net and gross rates from booking JSON for a specific stay date. + + Returns: + (net_rate, gross_rate) tuple + + Net includes: + - Room tariff from tariffs_quoted (net of VAT) + - Inventory items like breakfast allocations (net of VAT) + - Commission deductions (negative amounts, no VAT) + + Gross is the actual guest-facing tariff: + - Room tariff charge_amount + - Inventory items amounts + """ + if not raw_json: + return Decimal('0'), Decimal('0') + + target_str = target_date.strftime("%Y-%m-%d") + total_net = Decimal('0') + total_gross = Decimal('0') + + # 1. Get room tariff for this stay date + tariffs = raw_json.get("tariffs_quoted", []) + for tariff in tariffs: + if tariff.get("stay_date") == target_str: + charge_amount = Decimal(str(tariff.get("charge_amount", 0) or 0)) + total_gross += charge_amount + + # Try to get net from taxes array if available + taxes = tariff.get("taxes", []) + if taxes and charge_amount > 0: + tax_amount = sum(Decimal(str(t.get("tax_amount", 0) or 0)) for t in taxes) + net_amount = charge_amount - tax_amount + else: + # Fallback: calculate net using VAT rate + net_amount = charge_amount / (1 + vat_rate) + + total_net += net_amount + break # Only one tariff per date + + # 2. Add inventory items for this date (breakfast, commissions, etc.) + inventory_items = raw_json.get("inventory_items", []) + for item in inventory_items: + if item.get("stay_date") == target_str: + amount = Decimal(str(item.get("amount", 0) or 0)) + + if amount > 0: + # Positive amounts (breakfast, extras) + total_gross += amount # Guest pays this + net_amount = amount / (1 + vat_rate) + else: + # Negative amounts (commissions) - reduce both, no VAT + total_gross += amount # Reduces guest charge if visible + net_amount = amount + + total_net += net_amount + + return total_net, total_gross + + +async def get_current_otb_rooms_by_category(db, stay_date: date) -> Dict[str, int]: + """ + Get current on-the-books room counts by category. + + For current state, query actual bookings directly (more accurate than pace snapshots). + This counts bookings that span the stay_date and have valid status. + """ + # Get included categories + cat_result = await db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + return {} + + # Query actual bookings for real-time OTB count + result = await db.execute( + text(""" + SELECT category_id, COUNT(*) as room_count + FROM newbook_bookings_data + WHERE arrival_date <= :stay_date + AND departure_date > :stay_date + AND status IN ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + GROUP BY category_id + """), + { + "stay_date": stay_date, + "categories": included_categories + } + ) + + return {str(row.category_id): row.room_count for row in result.fetchall()} + + +async def get_rooms_at_lead_time_by_category(db, stay_date: date, lead_days: int) -> Dict[str, int]: + """ + Get room count snapshot at a specific lead time by category. + """ + column = get_lead_time_column(lead_days) + + result = await db.execute( + text(f""" + SELECT category_id, {column} as rooms + FROM category_booking_pace + WHERE arrival_date = :stay_date + """), + {"stay_date": stay_date} + ) + + return {row.category_id: row.rooms or 0 for row in result.fetchall()} + + +async def get_rate_stats_by_category(db, stay_date: date) -> Dict[str, Dict[str, Any]]: + """ + Get rate statistics (min/max/adr) per category from newbook_bookings_stats. + """ + result = await db.execute( + text(""" + SELECT rate_stats_by_category + FROM newbook_bookings_stats + WHERE date = :stay_date + """), + {"stay_date": stay_date} + ) + row = result.fetchone() + + if row and row.rate_stats_by_category: + return row.rate_stats_by_category + return {} + + +async def get_current_rates_by_category(db, stay_date: date) -> Dict[str, Dict[str, Decimal]]: + """ + Get current rack rates per category from newbook_current_rates. + Returns dict with 'net' and 'gross' rates per category. + Falls back to historical ADR if no current rate available. + """ + result = await db.execute( + text(""" + SELECT category_id, rate_net, rate_gross + FROM newbook_current_rates + WHERE rate_date = :stay_date + """), + {"stay_date": stay_date} + ) + + rates = {} + for row in result.fetchall(): + rates[row.category_id] = { + 'net': Decimal(str(row.rate_net)) if row.rate_net else Decimal('0'), + 'gross': Decimal(str(row.rate_gross)) if row.rate_gross else Decimal('0') + } + + # Fallback to historical ADR for categories without current rates + if not rates: + rate_stats = await get_rate_stats_by_category(db, stay_date) + for cat_id, stats in rate_stats.items(): + if cat_id not in rates and 'adr_net' in stats: + net = Decimal(str(stats['adr_net'])) + # Estimate gross as net * 1.2 (20% VAT) as fallback + rates[cat_id] = { + 'net': net, + 'gross': net * Decimal('1.2') + } + + return rates + + +async def get_prior_year_pickup_rooms_by_category( + db, + prior_date: date, + lead_days: int +) -> Dict[str, int]: + """ + Get prior year pickup rooms by category from bookings data. + + Pickup = Final rooms - OTB rooms at same lead time. + OTB rooms = bookings placed BEFORE the cutoff date + Final rooms = all valid bookings (status in valid statuses) + + Returns per-category pickup counts. + """ + cutoff_date = prior_date - timedelta(days=lead_days) + + # Get included categories + cat_result = await db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + return {} + + # Count OTB rooms by category (bookings placed BEFORE cutoff) + otb_result = await db.execute( + text(""" + SELECT category_id, COUNT(*) as room_count + FROM newbook_bookings_data + WHERE arrival_date <= :prior_date + AND departure_date > :prior_date + AND booking_placed < :cutoff_date + AND status IN ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + GROUP BY category_id + """), + { + "prior_date": prior_date, + "cutoff_date": cutoff_date, + "categories": included_categories + } + ) + otb_by_cat = {str(row.category_id): row.room_count for row in otb_result.fetchall()} + + # Count final rooms by category (all valid bookings for that date) + final_result = await db.execute( + text(""" + SELECT category_id, COUNT(*) as room_count + FROM newbook_bookings_data + WHERE arrival_date <= :prior_date + AND departure_date > :prior_date + AND status IN ('Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + GROUP BY category_id + """), + { + "prior_date": prior_date, + "categories": included_categories + } + ) + final_by_cat = {str(row.category_id): row.room_count for row in final_result.fetchall()} + + # Calculate pickup per category + pickup_by_category = {} + all_categories = set(list(otb_by_cat.keys()) + list(final_by_cat.keys())) + + for cat_id in all_categories: + otb = otb_by_cat.get(cat_id, 0) + final = final_by_cat.get(cat_id, 0) + pickup = max(0, final - otb) # Only positive pickup (ignore cancellations) + if pickup > 0: + pickup_by_category[cat_id] = pickup + + return pickup_by_category + + +async def get_prior_year_pickup_rates_by_category( + db, + prior_date: date, + lead_days: int, + vat_rate: Decimal +) -> Dict[str, Dict[str, Decimal]]: + """ + Get prior year rates for picked-up bookings by category. + + Uses the EARLIEST pickup booking(s) to estimate the listed rate at that lead time, + rather than averaging all pickup bookings (which gets diluted by last-minute discounts). + + Returns dict per category with: + - avg_rate: Rate from earliest pickup booking(s) - represents listed rate at this lead time + - cheaper_50_avg / expensive_50_avg: For bounds calculation + + Pickup bookings = bookings placed AFTER the cutoff date (late bookers). + """ + # Calculate the cutoff date - bookings placed AFTER this are "pickup" + cutoff_date = prior_date - timedelta(days=lead_days) + + # Get included categories + cat_result = await db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + return {} + + # Query bookings ordered by booking_placed to get earliest first + # This lets us use the first booking(s) as the "listed rate at this lead time" + result = await db.execute( + text(""" + SELECT category_id, raw_json, booking_placed + FROM newbook_bookings_data + WHERE arrival_date <= :prior_date + AND departure_date > :prior_date + AND booking_placed >= :cutoff_date + AND status IN ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + ORDER BY booking_placed ASC + """), + { + "prior_date": prior_date, + "cutoff_date": cutoff_date, + "categories": included_categories + } + ) + + # Collect rates per category with booking order preserved + # Format: List of (net, gross, booking_placed) tuples + rates_by_category: Dict[str, List[Tuple[Decimal, Decimal, date]]] = {} + + for row in result.fetchall(): + cat_id = str(row.category_id) + if row.raw_json: + net_rate, gross_rate = _extract_day_rates_from_booking(row.raw_json, prior_date, vat_rate) + if net_rate > 0: + if cat_id not in rates_by_category: + rates_by_category[cat_id] = [] + booking_date = row.booking_placed.date() if hasattr(row.booking_placed, 'date') else row.booking_placed + rates_by_category[cat_id].append((net_rate, gross_rate, booking_date)) + + # Calculate TWO sets of rates: + # 1. Average of ALL pickup bookings - for realistic revenue forecasting + # 2. Earliest booking(s) rate - represents "listed rate at this lead time" for rate comparison + result_rates: Dict[str, Dict[str, Decimal]] = {} + + for cat_id, rate_tuples in rates_by_category.items(): + if rate_tuples: + all_net_rates = [(r[0], r[1]) for r in rate_tuples] + + # Average of ALL pickup bookings - for forecast revenue calculation + avg_rate = sum(r[0] for r in all_net_rates) / len(all_net_rates) + avg_rate_gross = sum(r[1] for r in all_net_rates) / len(all_net_rates) + + # Earliest 1-3 bookings = "listed rate at this lead time" for rate comparison + earliest_count = min(3, len(rate_tuples)) + earliest_bookings = rate_tuples[:earliest_count] + listed_rate = sum(r[0] for r in earliest_bookings) / len(earliest_bookings) + listed_rate_gross = sum(r[1] for r in earliest_bookings) / len(earliest_bookings) + + # For 50% splits, use all bookings sorted by rate + sorted_pairs = sorted(all_net_rates, key=lambda x: x[0]) + mid_point = len(sorted_pairs) // 2 + if mid_point == 0: + mid_point = 1 + + cheaper_half = sorted_pairs[:mid_point] + expensive_half = sorted_pairs[mid_point:] if mid_point < len(sorted_pairs) else sorted_pairs + + cheaper_50_avg = sum(r[0] for r in cheaper_half) / len(cheaper_half) + cheaper_50_avg_gross = sum(r[1] for r in cheaper_half) / len(cheaper_half) + expensive_50_avg = sum(r[0] for r in expensive_half) / len(expensive_half) if expensive_half else avg_rate + expensive_50_avg_gross = sum(r[1] for r in expensive_half) / len(expensive_half) if expensive_half else avg_rate_gross + + result_rates[cat_id] = { + 'avg_rate': avg_rate, # For forecast calculation + 'avg_rate_gross': avg_rate_gross, + 'listed_rate': listed_rate, # For rate comparison (earliest bookings) + 'listed_rate_gross': listed_rate_gross, + 'cheaper_50_avg': cheaper_50_avg, + 'cheaper_50_avg_gross': cheaper_50_avg_gross, + 'expensive_50_avg': expensive_50_avg, + 'expensive_50_avg_gross': expensive_50_avg_gross, + 'booking_count': len(rate_tuples), + 'earliest_booking_count': earliest_count + } + + return result_rates + + +async def get_prior_year_otb_rooms_by_category( + db, + prior_date: date, + lead_days: int +) -> Dict[str, int]: + """ + Get prior year OTB rooms by category (bookings placed before cutoff). + """ + cutoff_date = prior_date - timedelta(days=lead_days) + + cat_result = await db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + return {} + + result = await db.execute( + text(""" + SELECT category_id, COUNT(*) as room_count + FROM newbook_bookings_data + WHERE arrival_date <= :prior_date + AND departure_date > :prior_date + AND booking_placed < :cutoff_date + AND status IN ('Unconfirmed', 'Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + GROUP BY category_id + """), + { + "prior_date": prior_date, + "cutoff_date": cutoff_date, + "categories": included_categories + } + ) + + return {str(row.category_id): row.room_count for row in result.fetchall()} + + +async def get_prior_year_final_rooms_by_category( + db, + prior_date: date +) -> Dict[str, int]: + """ + Get prior year final rooms by category (all valid bookings). + """ + cat_result = await db.execute( + text("SELECT site_id FROM newbook_room_categories WHERE is_included = true") + ) + included_categories = [row.site_id for row in cat_result.fetchall()] + + if not included_categories: + return {} + + result = await db.execute( + text(""" + SELECT category_id, COUNT(*) as room_count + FROM newbook_bookings_data + WHERE arrival_date <= :prior_date + AND departure_date > :prior_date + AND status IN ('Confirmed', 'Arrived', 'Departed') + AND category_id = ANY(:categories) + GROUP BY category_id + """), + { + "prior_date": prior_date, + "categories": included_categories + } + ) + + return {str(row.category_id): row.room_count for row in result.fetchall()} + + +async def get_category_availability(db, stay_date: date) -> Dict[str, int]: + """ + Get available room capacity per category for a date. + Uses room inventory minus OTB. + """ + # Get included categories with their room counts + result = await db.execute( + text(""" + SELECT site_id, room_count + FROM newbook_room_categories + WHERE is_included = true + """) + ) + + capacity = {row.site_id: row.room_count or 0 for row in result.fetchall()} + return capacity + + +async def get_bookable_rooms(db, stay_date: date) -> int: + """ + Get total bookable room capacity for a date. + """ + result = await db.execute( + text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE date = :stay_date + """), + {"stay_date": stay_date} + ) + row = result.fetchone() + + if row and row.bookable_count: + return row.bookable_count + + # Fallback: sum of all included category rooms + result = await db.execute( + text(""" + SELECT COALESCE(SUM(room_count), 0) as total + FROM newbook_room_categories + WHERE is_included = true + """) + ) + row = result.fetchone() + + return row.total if row else 0 + + +async def calculate_revenue_bounds( + db, + stay_date: date, + current_otb_rev: Decimal, + current_otb_rooms: Dict[str, int], + expected_pickup_rev: Decimal +) -> Tuple[Decimal, Decimal, Decimal, float, Dict[str, Dict[str, Any]]]: + """ + Calculate forecast with confidence bounds based on rate analysis. + + Returns: + (forecast, upper_bound, lower_bound, adr_position, category_breakdown) + + Upper bound: current OTB + (pickup rooms × current rack rate per category) + Lower bound: current OTB + (pickup rooms × min historical rate per category) + ADR position: where current ADR falls between min/max (0-1 scale) + """ + capacity = await get_category_availability(db, stay_date) + rate_stats = await get_rate_stats_by_category(db, stay_date) + current_rates = await get_current_rates_by_category(db, stay_date) + + # Get prior year data for expected pickup calculation + prior_date = get_prior_year_date(stay_date) + prior_otb_rooms = await get_current_otb_rooms_by_category(db, prior_date) + + category_breakdown: Dict[str, Dict[str, Any]] = {} + total_upper_pickup = Decimal('0') + total_lower_pickup = Decimal('0') + total_expected_pickup = Decimal('0') + + weighted_adr_position = Decimal('0') + total_weight = Decimal('0') + + for cat_id, cat_capacity in capacity.items(): + otb_rooms = current_otb_rooms.get(cat_id, 0) + remaining = cat_capacity - otb_rooms + + if remaining <= 0: + category_breakdown[cat_id] = { + 'otb_rooms': otb_rooms, + 'capacity': cat_capacity, + 'remaining': 0, + 'pickup_rooms': 0, + 'upper_pickup_rev': 0, + 'lower_pickup_rev': 0, + 'expected_pickup_rev': 0 + } + continue + + # Get rate bounds for this category + cat_stats = rate_stats.get(cat_id, {}) + min_rate = Decimal(str(cat_stats.get('min_net', 100))) # Default fallback + max_rate = Decimal(str(cat_stats.get('max_net', 200))) + adr = Decimal(str(cat_stats.get('adr_net', 150))) + # Handle new dict format for current_rates + current_rate_data = current_rates.get(cat_id, {}) + if isinstance(current_rate_data, dict): + current_rate = current_rate_data.get('net', adr) + else: + current_rate = current_rate_data if current_rate_data else adr + + # Expected pickup rooms based on prior year pattern + prior_otb = prior_otb_rooms.get(cat_id, 0) + # Assume similar pickup ratio to prior year + # This is simplified - ideally would use actual prior final rooms + expected_pickup_rooms = min(remaining, max(0, prior_otb - otb_rooms) if prior_otb > otb_rooms else int(remaining * 0.3)) + + # Calculate revenue bounds + upper_pickup_rev = Decimal(remaining) * current_rate + lower_pickup_rev = Decimal(remaining) * min_rate + expected_pickup_rev_cat = Decimal(expected_pickup_rooms) * adr + + total_upper_pickup += upper_pickup_rev + total_lower_pickup += lower_pickup_rev + total_expected_pickup += expected_pickup_rev_cat + + # Calculate ADR position for this category + if max_rate > min_rate: + cat_adr_position = (adr - min_rate) / (max_rate - min_rate) + weighted_adr_position += cat_adr_position * Decimal(otb_rooms) if otb_rooms > 0 else Decimal('0') + total_weight += Decimal(otb_rooms) if otb_rooms > 0 else Decimal('0') + + category_breakdown[cat_id] = { + 'otb_rooms': otb_rooms, + 'capacity': cat_capacity, + 'remaining': remaining, + 'pickup_rooms': expected_pickup_rooms, + 'min_rate': float(min_rate), + 'max_rate': float(max_rate), + 'adr': float(adr), + 'current_rate': float(current_rate), + 'upper_pickup_rev': float(upper_pickup_rev), + 'lower_pickup_rev': float(lower_pickup_rev), + 'expected_pickup_rev': float(expected_pickup_rev_cat) + } + + # Use expected pickup from prior year pattern (even if negative - indicates cancellations) + # Only fall back to room-based calculation if we have no prior data at all + if expected_pickup_rev != 0: + forecast = current_otb_rev + expected_pickup_rev + else: + forecast = current_otb_rev + total_expected_pickup + + # Floor: can't go below current OTB (negative pickup still respects current bookings) + forecast = max(forecast, current_otb_rev) + + # Calculate bounds using rate-based variance + # Use min/max rate ratios from rate_stats if available, otherwise use default variance + total_min_rate = Decimal('0') + total_max_rate = Decimal('0') + total_adr = Decimal('0') + rate_count = 0 + + for cat_id, stats in rate_stats.items(): + if 'min_net' in stats and 'max_net' in stats and 'adr_net' in stats: + total_min_rate += Decimal(str(stats['min_net'])) + total_max_rate += Decimal(str(stats['max_net'])) + total_adr += Decimal(str(stats['adr_net'])) + rate_count += 1 + + if rate_count > 0 and total_adr > 0: + # Use actual rate ratios for bounds + avg_min = total_min_rate / rate_count + avg_max = total_max_rate / rate_count + avg_adr = total_adr / rate_count + + # Rate multipliers: how much pickup could vary based on rate changes + lower_ratio = avg_min / avg_adr if avg_adr > 0 else Decimal('0.85') + upper_ratio = avg_max / avg_adr if avg_adr > 0 else Decimal('1.15') + else: + # No rate stats - use default variance + lower_ratio = Decimal('0.85') + upper_ratio = Decimal('1.15') + + if expected_pickup_rev > 0: + # Positive pickup: apply rate variance to pickup amount + upper_bound = current_otb_rev + (expected_pickup_rev * upper_ratio) + lower_bound = max(current_otb_rev, current_otb_rev + (expected_pickup_rev * lower_ratio)) + else: + # Negative or zero pickup: forecast is already floored to OTB + # Upper: could get some late bookings (small % upside) + # Lower: OTB is the floor + upside_pct = Decimal('0.10') + upper_bound = forecast + (current_otb_rev * upside_pct) + lower_bound = current_otb_rev + + # Calculate weighted ADR position + adr_position = float(weighted_adr_position / total_weight) if total_weight > 0 else 0.5 + + return forecast, upper_bound, lower_bound, adr_position, category_breakdown + + +async def run_pickup_v2_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + include_details: bool = False +) -> List[Dict[str, Any]]: + """ + Generate pickup-v2 forecast for a date range. + + Supports metrics: + - net_accom: Accommodation revenue forecast with confidence bands + - hotel_room_nights: Room count forecast (uses same pickup logic) + - hotel_occupancy_pct: Occupancy percentage forecast + + Args: + db: Database session + metric_code: Metric to forecast + start_date: Start date for forecast + end_date: End date for forecast + include_details: If True, includes category breakdown and explanations + + Returns: + List of forecast dicts with predicted values and confidence bounds + """ + logger.info(f"Running pickup-v2 forecast for {metric_code}: {start_date} to {end_date}") + + forecasts = [] + today = date.today() + current_date = start_date + + while current_date <= end_date: + lead_days = (current_date - today).days + day_of_week = current_date.strftime("%a") + prior_date = get_prior_year_date(current_date) + + if metric_code == 'net_accom': + # Revenue forecasting with confidence bands + forecast_data = await forecast_revenue_for_date( + db, current_date, lead_days, prior_date, include_details + ) + elif metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'): + # Room-based forecasting + forecast_data = await forecast_rooms_for_date( + db, current_date, lead_days, prior_date, metric_code, include_details + ) + else: + logger.warning(f"Unsupported metric: {metric_code}") + current_date += timedelta(days=1) + continue + + if forecast_data: + forecast_data['date'] = str(current_date) + forecast_data['day_of_week'] = day_of_week + forecast_data['lead_days'] = lead_days + forecast_data['prior_year_date'] = str(prior_date) + forecasts.append(forecast_data) + + current_date += timedelta(days=1) + + logger.info(f"Generated {len(forecasts)} pickup-v2 forecasts for {metric_code}") + return forecasts + + +async def forecast_revenue_for_date( + db, + stay_date: date, + lead_days: int, + prior_date: date, + include_details: bool = False +) -> Optional[Dict[str, Any]]: + """ + Generate revenue forecast for a single date using room-based per-category pickup. + + Four scenarios calculated: + a) At prior ADR = OTB + Σ(pickup_rooms[cat] × prior_adr[cat]) - what prior year achieved + b) At current rate = OTB + Σ(pickup_rooms[cat] × current_rate[cat]) - achievable now + c) Cheaper 50% = OTB + Σ(pickup_rooms[cat] × cheaper_half_avg[cat]) + d) Expensive 50% = OTB + Σ(pickup_rooms[cat] × expensive_half_avg[cat]) + + Forecast = min(at_prior_adr, at_current_rate) - can't exceed what's achievable at current prices + Bounds: Upper = max of all 4, Lower = min of all 4 + Ceiling: OTB + remaining_rooms × expensive_50_rate (physical capacity limit) + """ + # Get VAT rate for revenue calculations + vat_result = await db.execute( + text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'") + ) + row = vat_result.fetchone() + vat_rate = Decimal(row.config_value) if row and row.config_value else Decimal('0.20') + + # Get current OTB revenue (net accommodation) + current_otb_rev = await get_current_otb_revenue(db, stay_date) + + # Get prior year data for comparison (still useful for pace metrics) + prior_otb_rev = await get_prior_otb_revenue_from_bookings(db, prior_date, lead_days, vat_rate) + prior_final_rev = await get_actual_revenue(db, prior_date) + + # NEW: Room-based per-category pickup calculation + # Get prior year pickup rooms by category + pickup_rooms_by_cat = await get_prior_year_pickup_rooms_by_category(db, prior_date, lead_days) + + # Get prior year pickup rates (avg rate and cheapest 3 avg) per category + pickup_rates_by_cat = await get_prior_year_pickup_rates_by_category(db, prior_date, lead_days, vat_rate) + + # Get current rates for upper bound calculation + current_rates = await get_current_rates_by_category(db, stay_date) + + # Get rate stats for fallback rates + rate_stats = await get_rate_stats_by_category(db, prior_date) + + # Get current OTB rooms and capacity for ceiling calculation + current_otb_rooms_by_cat = await get_current_otb_rooms_by_category(db, stay_date) + current_otb_rooms_total = sum(current_otb_rooms_by_cat.values()) + capacity_by_cat = await get_category_availability(db, stay_date) + + # Calculate 4 scenarios per category + category_breakdown: Dict[str, Dict[str, Any]] = {} + # a) Forecast: pickup × ADR of prior year pickups (net and gross) + forecast_pickup = Decimal('0') + forecast_pickup_gross = Decimal('0') + # b) At current rate: pickup × current rack rate (net and gross) + current_rate_pickup = Decimal('0') + current_rate_pickup_gross = Decimal('0') + # c) Cheaper 50%: pickup × avg of cheaper half of prior pickups + cheaper_50_pickup = Decimal('0') + # d) Expensive 50%: pickup × avg of more expensive half of prior pickups + expensive_50_pickup = Decimal('0') + # Listed rate at lead time (earliest bookings) - for rate comparison + listed_rate_pickup = Decimal('0') + listed_rate_pickup_gross = Decimal('0') + # Ceiling: max revenue if all remaining rooms sell at expensive 50% rate + max_remaining_revenue = Decimal('0') + total_pickup_rooms = 0 + + for cat_id, pickup_rooms in pickup_rooms_by_cat.items(): + total_pickup_rooms += pickup_rooms + cat_rates = pickup_rates_by_cat.get(cat_id, {}) + + # Prior year average rate for picked up bookings (for forecast) - net and gross + avg_rate = cat_rates.get('avg_rate') + avg_rate_gross = cat_rates.get('avg_rate_gross') + # Listed rate = earliest bookings (for rate comparison at this lead time) + listed_rate = cat_rates.get('listed_rate') + listed_rate_gross = cat_rates.get('listed_rate_gross') + if avg_rate is None: + # Fallback to rate_stats ADR if no pickup bookings found + cat_stats = rate_stats.get(cat_id, {}) + avg_rate = Decimal(str(cat_stats.get('adr_net', 150))) + avg_rate_gross = avg_rate * Decimal('1.2') # Estimate gross + listed_rate = avg_rate + listed_rate_gross = avg_rate_gross + + # Cheaper 50% avg rate + cheaper_50_rate = cat_rates.get('cheaper_50_avg') + if cheaper_50_rate is None: + cat_stats = rate_stats.get(cat_id, {}) + cheaper_50_rate = Decimal(str(cat_stats.get('min_net', avg_rate * Decimal('0.85')))) + + # Expensive 50% avg rate + expensive_50_rate = cat_rates.get('expensive_50_avg') + if expensive_50_rate is None: + cat_stats = rate_stats.get(cat_id, {}) + expensive_50_rate = Decimal(str(cat_stats.get('max_net', avg_rate * Decimal('1.15')))) + + # Current rack rate (net and gross from newbook_current_rates) + current_rate_data = current_rates.get(cat_id, {}) + if isinstance(current_rate_data, dict): + current_rate = current_rate_data.get('net') + current_rate_gross = current_rate_data.get('gross') + else: + # Backward compatibility if old format + current_rate = current_rate_data + current_rate_gross = current_rate * Decimal('1.2') if current_rate else None + + if current_rate is None: + cat_stats = rate_stats.get(cat_id, {}) + current_rate = Decimal(str(cat_stats.get('adr_net', avg_rate))) + current_rate_gross = current_rate * Decimal('1.2') + + # Calculate pickup revenue contributions for all 4 scenarios + cat_forecast_pickup = Decimal(pickup_rooms) * avg_rate + cat_forecast_pickup_gross = Decimal(pickup_rooms) * (avg_rate_gross or avg_rate * Decimal('1.2')) + cat_current_pickup = Decimal(pickup_rooms) * current_rate + cat_current_pickup_gross = Decimal(pickup_rooms) * (current_rate_gross or current_rate * Decimal('1.2')) + cat_cheaper_50_pickup = Decimal(pickup_rooms) * cheaper_50_rate + cat_expensive_50_pickup = Decimal(pickup_rooms) * expensive_50_rate + # Listed rate contribution (for rate comparison) + cat_listed_rate_pickup = Decimal(pickup_rooms) * (listed_rate or avg_rate) + cat_listed_rate_pickup_gross = Decimal(pickup_rooms) * (listed_rate_gross or avg_rate_gross or avg_rate * Decimal('1.2')) + + forecast_pickup += cat_forecast_pickup + forecast_pickup_gross += cat_forecast_pickup_gross + current_rate_pickup += cat_current_pickup + current_rate_pickup_gross += cat_current_pickup_gross + cheaper_50_pickup += cat_cheaper_50_pickup + expensive_50_pickup += cat_expensive_50_pickup + listed_rate_pickup += cat_listed_rate_pickup + listed_rate_pickup_gross += cat_listed_rate_pickup_gross + + # Calculate remaining capacity for ceiling + cat_otb_rooms = current_otb_rooms_by_cat.get(cat_id, 0) + cat_capacity = capacity_by_cat.get(cat_id, 0) + remaining_rooms = max(0, cat_capacity - cat_otb_rooms) + + # Cap pickup rooms at remaining capacity + capped_pickup_rooms = min(pickup_rooms, remaining_rooms) + + # Ceiling contribution: remaining rooms × expensive 50% rate + max_remaining_revenue += Decimal(remaining_rooms) * expensive_50_rate + + category_breakdown[cat_id] = { + 'pickup_rooms': pickup_rooms, + 'capped_pickup_rooms': capped_pickup_rooms, + 'remaining_capacity': remaining_rooms, + 'prior_avg_rate': float(avg_rate), + 'prior_avg_rate_gross': float(avg_rate_gross or avg_rate * Decimal('1.2')), + 'cheaper_50_rate': float(cheaper_50_rate), + 'expensive_50_rate': float(expensive_50_rate), + 'current_rate': float(current_rate), + 'current_rate_gross': float(current_rate_gross or current_rate * Decimal('1.2')), + 'forecast_pickup_rev': float(cat_forecast_pickup), + 'current_rate_pickup_rev': float(cat_current_pickup), + 'cheaper_50_pickup_rev': float(cat_cheaper_50_pickup), + 'expensive_50_pickup_rev': float(cat_expensive_50_pickup), + 'booking_count': cat_rates.get('booking_count', 0) + } + + # Ceiling: OTB + max possible from remaining rooms at expensive 50% rate + revenue_ceiling = current_otb_rev + max_remaining_revenue + + # Calculate 4 scenarios (floored at OTB, capped at ceiling) + # a) Forecast: at prior year ADR (what actually happened) + at_prior_adr = min(max(current_otb_rev + forecast_pickup, current_otb_rev), revenue_ceiling) + # b) At current rack rate + at_current_rate = min(max(current_otb_rev + current_rate_pickup, current_otb_rev), revenue_ceiling) + # c) Cheaper 50%: if bookings come in at lower end of prior distribution + at_cheaper_50 = min(max(current_otb_rev + cheaper_50_pickup, current_otb_rev), revenue_ceiling) + # d) Expensive 50%: if bookings come in at higher end of prior distribution + at_expensive_50 = min(max(current_otb_rev + expensive_50_pickup, current_otb_rev), revenue_ceiling) + + # Forecast = prior year ADR, but capped at current rate (can't exceed what's achievable) + # If current rates are lower than prior, we're limited to current rate + forecast = min(at_prior_adr, at_current_rate) + + # Bounds = min/max of all 4 scenarios (forecast will naturally fall in between) + all_scenarios = [at_prior_adr, at_current_rate, at_cheaper_50, at_expensive_50] + upper_bound = max(all_scenarios) + lower_bound = min(all_scenarios) + + # Rate gap: current rate vs prior ADR (negative = opportunity) + rate_gap = at_current_rate - at_prior_adr + + # Calculate weighted average rates (per room) for display + # Uses actual gross rates from tariffs (including breakfast, VAT, etc.) + weighted_avg_prior_rate = 0.0 + weighted_avg_current_rate = 0.0 + weighted_avg_prior_rate_gross = 0.0 + weighted_avg_current_rate_gross = 0.0 + # Listed rate = earliest bookings, for rate comparison at same lead time + weighted_avg_listed_rate = 0.0 + weighted_avg_listed_rate_gross = 0.0 + # Effective rate = rate actually used in forecast (min of prior and current) + effective_rate = 0.0 + effective_rate_gross = 0.0 + if total_pickup_rooms > 0: + weighted_avg_prior_rate = float(forecast_pickup / Decimal(total_pickup_rooms)) + weighted_avg_current_rate = float(current_rate_pickup / Decimal(total_pickup_rooms)) + # Use actual gross rates from booking tariffs, not calculated + weighted_avg_prior_rate_gross = float(forecast_pickup_gross / Decimal(total_pickup_rooms)) + weighted_avg_current_rate_gross = float(current_rate_pickup_gross / Decimal(total_pickup_rooms)) + # Listed rate (earliest bookings) for rate comparison + weighted_avg_listed_rate = float(listed_rate_pickup / Decimal(total_pickup_rooms)) + weighted_avg_listed_rate_gross = float(listed_rate_pickup_gross / Decimal(total_pickup_rooms)) + # Effective rate: the rate actually used in forecast = min(prior, current) + # If current rate is lower, forecast is capped at what's achievable at current prices + if current_rate_pickup < forecast_pickup: + effective_rate = weighted_avg_current_rate + effective_rate_gross = weighted_avg_current_rate_gross + else: + effective_rate = weighted_avg_prior_rate + effective_rate_gross = weighted_avg_prior_rate_gross + + # Lost potential: compare current rate vs LISTED rate per room + # Only show lost potential if current rate is LOWER than what was offered at this lead time + # If current rate is higher, there's no lost potential - we're doing better! + if current_rate_pickup < listed_rate_pickup: + lost_potential = listed_rate_pickup - current_rate_pickup + else: + lost_potential = Decimal('0') + has_pricing_opportunity = lost_potential > 0 + + # Rate position vs listed rate (what was being offered at this lead time) + # Positive = current rate is higher (good), Negative = current rate is lower (opportunity) + rate_vs_prior_pct = 0.0 + if listed_rate_pickup > 0: + rate_vs_prior_pct = float((current_rate_pickup - listed_rate_pickup) / listed_rate_pickup * 100) + + # Calculate pace vs prior year + pace_vs_prior_pct = 0.0 + if prior_otb_rev > 0: + pace_vs_prior_pct = float((current_otb_rev - prior_otb_rev) / prior_otb_rev * 100) + + # Legacy pickup calc for comparison + expected_pickup_rev = prior_final_rev - prior_otb_rev if prior_final_rev > 0 else Decimal('0') + + result = { + 'current_otb_rev': float(current_otb_rev), + 'current_otb': current_otb_rooms_total, # Room count for display + 'current_otb_rooms': current_otb_rooms_total, # Alias for compatibility + 'prior_year_otb_rev': float(prior_otb_rev), + 'prior_year_final_rev': float(prior_final_rev), + 'expected_pickup_rev': float(expected_pickup_rev), # Legacy revenue-based pickup + 'pickup_rooms_total': total_pickup_rooms, + 'forecast_pickup_rev': float(forecast_pickup), # Pickup revenue at prior ADR + 'forecast': float(forecast), + 'predicted_value': float(forecast), # For compatibility with blended model + # 4 scenarios + 'at_prior_adr': float(at_prior_adr), # a) Pickup at prior year ADR (forecast) + 'at_current_rate': float(at_current_rate), # b) Pickup at current rack rates + 'at_cheaper_50': float(at_cheaper_50), # c) Pickup at cheaper 50% of prior rates + 'at_expensive_50': float(at_expensive_50), # d) Pickup at expensive 50% of prior rates + # Bounds (min/max of all scenarios) + 'upper_bound': float(upper_bound), + 'lower_bound': float(lower_bound), + 'ceiling': float(revenue_ceiling), # Max if all remaining rooms sell at expensive 50% + 'rate_gap': float(rate_gap), # Negative = current rate below prior ADR + 'lost_potential': float(lost_potential), # Revenue left on table (0 if none) + 'has_pricing_opportunity': has_pricing_opportunity, # True if current < prior ADR + 'rate_vs_prior_pct': round(rate_vs_prior_pct, 1), + 'pace_vs_prior_pct': round(pace_vs_prior_pct, 1), + # Weighted average rates per room for display (net) - avg of all pickups for forecast + 'weighted_avg_prior_rate': round(weighted_avg_prior_rate, 2), + 'weighted_avg_current_rate': round(weighted_avg_current_rate, 2), + # Gross rates (inc VAT) for UI reference when adjusting rates + 'weighted_avg_prior_rate_gross': round(weighted_avg_prior_rate_gross, 2), + 'weighted_avg_current_rate_gross': round(weighted_avg_current_rate_gross, 2), + # Listed rate at lead time (earliest bookings) - for rate comparison + 'weighted_avg_listed_rate': round(weighted_avg_listed_rate, 2), + 'weighted_avg_listed_rate_gross': round(weighted_avg_listed_rate_gross, 2), + # Effective rate = rate actually used in forecast (min of prior and current) + 'effective_rate': round(effective_rate, 2), + 'effective_rate_gross': round(effective_rate_gross, 2) + } + + if include_details: + result['category_breakdown'] = category_breakdown + result['explanation'] = { + 'method': 'room_based_category_pickup', + 'formula': 'Forecast = min(at_prior_adr, at_current_rate) - capped at achievable', + 'scenarios': { + 'a_prior_adr': 'OTB + pickup × prior year ADR (what actually happened)', + 'b_current': 'OTB + pickup × current rack rate (what we can achieve now)', + 'c_cheaper_50': 'OTB + pickup × avg of cheaper 50% of prior bookings', + 'd_expensive_50': 'OTB + pickup × avg of expensive 50% of prior bookings' + }, + 'bounds': 'Upper = max of all 4 scenarios, Lower = min of all 4', + 'ceiling': 'OTB + (remaining_rooms × expensive_50_rate) - physical capacity limit', + 'rate_gap': 'at_current_rate - at_prior_adr (negative = opportunity to raise rates)' + } + + return result + + +async def forecast_rooms_for_date( + db, + stay_date: date, + lead_days: int, + prior_date: date, + metric_code: str, + include_details: bool = False +) -> Optional[Dict[str, Any]]: + """ + Generate room/occupancy forecast for a single date using category-based pickup. + + Uses same bookings-based approach as revenue forecast for consistency. + Formula: Forecast = Current OTB Rooms + Σ(pickup_rooms[cat]) + Floor: Current OTB + Ceiling: Bookable capacity + """ + # Get current OTB rooms by category + current_otb_by_cat = await get_current_otb_rooms_by_category(db, stay_date) + current_otb_rooms = sum(current_otb_by_cat.values()) + + # Get prior year pickup rooms by category (from bookings data) + pickup_rooms_by_cat = await get_prior_year_pickup_rooms_by_category(db, prior_date, lead_days) + total_pickup_rooms = sum(pickup_rooms_by_cat.values()) + + # Get prior year totals for comparison (from bookings data) + prior_otb_by_cat = await get_prior_year_otb_rooms_by_category(db, prior_date, lead_days) + prior_otb_rooms = sum(prior_otb_by_cat.values()) + prior_final_by_cat = await get_prior_year_final_rooms_by_category(db, prior_date) + prior_final_rooms = sum(prior_final_by_cat.values()) + + # Get bookable capacity + bookable = await get_bookable_rooms(db, stay_date) + capacity_by_cat = await get_category_availability(db, stay_date) + + # Build category breakdown + category_breakdown: Dict[str, Dict[str, Any]] = {} + all_categories = set( + list(current_otb_by_cat.keys()) + + list(pickup_rooms_by_cat.keys()) + + list(capacity_by_cat.keys()) + ) + + for cat_id in all_categories: + cat_otb = current_otb_by_cat.get(cat_id, 0) + cat_pickup = pickup_rooms_by_cat.get(cat_id, 0) + cat_capacity = capacity_by_cat.get(cat_id, 0) + cat_forecast = min(cat_otb + cat_pickup, cat_capacity) if cat_capacity > 0 else cat_otb + cat_pickup + + category_breakdown[cat_id] = { + 'current_otb': cat_otb, + 'pickup_rooms': cat_pickup, + 'forecast': cat_forecast, + 'capacity': cat_capacity, + 'prior_otb': prior_otb_by_cat.get(cat_id, 0), + 'prior_final': prior_final_by_cat.get(cat_id, 0) + } + + # Base forecast + forecast_rooms = current_otb_rooms + total_pickup_rooms + + # Apply floor and ceiling + forecast_rooms = max(forecast_rooms, current_otb_rooms) # Floor: current OTB + forecast_rooms = min(forecast_rooms, bookable) if bookable > 0 else forecast_rooms # Ceiling: capacity + + # Calculate pace vs prior year + pace_vs_prior_pct = 0.0 + if prior_otb_rooms > 0: + pace_vs_prior_pct = float((current_otb_rooms - prior_otb_rooms) / prior_otb_rooms * 100) + + # Convert to appropriate metric + if metric_code == 'hotel_occupancy_pct': + predicted_value = (forecast_rooms / bookable * 100) if bookable > 0 else 0 + current_otb_val = (current_otb_rooms / bookable * 100) if bookable > 0 else 0 + ceiling_val = 100.0 + else: + predicted_value = forecast_rooms + current_otb_val = current_otb_rooms + ceiling_val = bookable + + result = { + 'current_otb': current_otb_val, + 'current_otb_rooms': current_otb_rooms, + 'prior_year_otb': prior_otb_rooms, + 'prior_year_final': prior_final_rooms, + 'pickup_rooms_total': total_pickup_rooms, + 'expected_pickup': total_pickup_rooms, # For compatibility + 'forecast': predicted_value, + 'predicted_value': predicted_value, + 'ceiling': ceiling_val, + 'floor': current_otb_val, + 'pace_vs_prior_pct': round(pace_vs_prior_pct, 1) + } + + if include_details: + result['category_breakdown'] = category_breakdown + result['explanation'] = { + 'method': 'category_based_pickup', + 'formula': 'Current OTB + Σ(pickup_rooms[cat])', + 'floor': 'Current OTB rooms', + 'ceiling': 'Bookable room capacity' + } + + return result + + +async def get_pickup_v2_summary( + db, + start_date: date, + end_date: date, + metric_code: str = 'net_accom' +) -> Dict[str, Any]: + """ + Get summary statistics for a pickup-v2 forecast range. + """ + forecasts = await run_pickup_v2_forecast(db, metric_code, start_date, end_date) + + if not forecasts: + return { + 'days_count': 0, + 'message': 'No forecast data available' + } + + if metric_code == 'net_accom': + return { + 'otb_rev_total': sum(f.get('current_otb_rev', 0) for f in forecasts), + 'forecast_total': sum(f.get('forecast', 0) for f in forecasts), + 'upper_total': sum(f.get('upper_bound', 0) for f in forecasts), + 'lower_total': sum(f.get('lower_bound', 0) for f in forecasts), + 'prior_final_total': sum(f.get('prior_year_final_rev', 0) for f in forecasts), + 'avg_adr_position': sum(f.get('adr_position', 0.5) for f in forecasts) / len(forecasts), + 'avg_pace_pct': sum(f.get('pace_vs_prior_pct', 0) for f in forecasts) / len(forecasts), + 'days_count': len(forecasts) + } + else: + return { + 'otb_total': sum(f.get('current_otb', 0) for f in forecasts), + 'forecast_total': sum(f.get('forecast', 0) for f in forecasts), + 'prior_final_total': sum(f.get('prior_year_final', 0) for f in forecasts), + 'avg_pace_pct': sum(f.get('pace_vs_prior_pct', 0) for f in forecasts) / len(forecasts), + 'days_count': len(forecasts) + } diff --git a/backend/services/forecasting/prophet_model.py b/backend/services/forecasting/prophet_model.py new file mode 100644 index 0000000..addc20a --- /dev/null +++ b/backend/services/forecasting/prophet_model.py @@ -0,0 +1,195 @@ +""" +Prophet forecasting model +Time series forecasting with trend, seasonality, and holiday effects +""" +import logging +from datetime import date, timedelta +from typing import List, Optional +import pandas as pd +import numpy as np +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +async def run_prophet_forecast( + db, + metric_code: str, + forecast_from: date, + forecast_to: date, + training_days: int = 2555 # ~7 years - use all available history +) -> List[dict]: + """ + Run Prophet forecast for a metric + + Args: + db: Database session + metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct') + forecast_from: Start date for forecasts + forecast_to: End date for forecasts + training_days: Days of historical data to use for training + + Returns: + List of forecast records + """ + try: + from prophet import Prophet + + # Get historical data + training_from = forecast_from - timedelta(days=training_days) + + # Revenue metrics use earned_revenue_data joined with gl_accounts + revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev'] + if metric_code in revenue_metrics: + revenue_departments = { + 'net_accom': 'accommodation', + 'net_dry': 'dry', + 'net_wet': 'wet', + 'total_rev': None, # All departments + } + department = revenue_departments.get(metric_code) + if department is None and metric_code != 'total_rev': + logger.warning(f"Unknown revenue metric for Prophet: {metric_code}") + return [] + + if metric_code == 'total_rev': + # Total revenue across all departments + result = db.execute( + text(""" + SELECT date, SUM(amount_net) as actual_value + FROM newbook_earned_revenue_data + WHERE date BETWEEN :from_date AND :to_date + GROUP BY date + HAVING SUM(amount_net) IS NOT NULL + ORDER BY date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1)} + ) + else: + # Revenue by department + result = db.execute( + text(""" + SELECT r.date, SUM(r.amount_net) as actual_value + FROM newbook_earned_revenue_data r + JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id + WHERE r.date BETWEEN :from_date AND :to_date + AND g.department = :department + GROUP BY r.date + HAVING SUM(r.amount_net) IS NOT NULL + ORDER BY r.date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department} + ) + else: + # Hotel metrics use newbook_bookings_stats table + metric_column_map = { + 'hotel_occupancy_pct': 'total_occupancy_pct', + 'hotel_room_nights': 'booking_count', + 'hotel_guests': 'guests_count', + } + + column_name = metric_column_map.get(metric_code) + if not column_name: + logger.warning(f"Unknown metric_code for Prophet: {metric_code}") + return [] + + result = db.execute( + text(f""" + SELECT date, {column_name} as actual_value + FROM newbook_bookings_stats + WHERE date BETWEEN :from_date AND :to_date + AND {column_name} IS NOT NULL + ORDER BY date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1)} + ) + + rows = result.fetchall() + + if len(rows) < 30: + logger.warning(f"Insufficient data for Prophet forecast: {metric_code} has {len(rows)} records") + return [] + + # Prepare data for Prophet + df = pd.DataFrame([{"ds": row.date, "y": float(row.actual_value)} for row in rows]) + + # Initialize and fit Prophet model + model = Prophet( + yearly_seasonality=True, + weekly_seasonality=True, + daily_seasonality=False, + interval_width=0.80 # 80% confidence interval + ) + + # Add UK holidays + model.add_country_holidays(country_name='GB') + + model.fit(df) + + # Generate future dates + future_dates = pd.date_range(start=forecast_from, end=forecast_to, freq='D') + future_df = pd.DataFrame({"ds": future_dates}) + + # Make predictions + forecast = model.predict(future_df) + + # Store forecasts + forecasts = [] + for _, row in forecast.iterrows(): + forecast_record = { + "forecast_date": row["ds"].date(), + "forecast_type": metric_code, + "model_type": "prophet", + "predicted_value": round(float(row["yhat"]), 2), + "lower_bound": round(float(row["yhat_lower"]), 2), + "upper_bound": round(float(row["yhat_upper"]), 2) + } + forecasts.append(forecast_record) + + # Store in database - simple insert (latest value wins) + db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, + predicted_value, lower_bound, upper_bound, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, + :predicted_value, :lower_bound, :upper_bound, NOW() + ) + """), + forecast_record + ) + + # Store decomposition for explainability + # Simple insert - decomposition stored per generation + try: + db.execute( + text(""" + INSERT INTO prophet_decomposition ( + forecast_date, forecast_type, trend, + yearly_seasonality, weekly_seasonality, generated_at + ) VALUES ( + :date, :metric, :trend, :yearly, :weekly, NOW() + ) + """), + { + "date": row["ds"].date(), + "metric": metric_code, + "trend": round(float(row.get("trend", 0)), 4), + "yearly": round(float(row.get("yearly", 0)), 4), + "weekly": round(float(row.get("weekly", 0)), 4) + } + ) + except Exception: + pass # Skip if conflict, decomposition is supplementary + + db.commit() + logger.info(f"Prophet forecast generated for {metric_code}: {len(forecasts)} records") + return forecasts + + except ImportError: + logger.error("Prophet not installed. Install with: pip install prophet") + return [] + except Exception as e: + logger.error(f"Prophet forecast failed for {metric_code}: {e}") + return [] diff --git a/backend/services/forecasting/prophet_tuned.py b/backend/services/forecasting/prophet_tuned.py new file mode 100644 index 0000000..001bc8c --- /dev/null +++ b/backend/services/forecasting/prophet_tuned.py @@ -0,0 +1,281 @@ +""" +Prophet Tuned Model Service + +This is the production-tuned Prophet model extracted from the prophet-preview endpoint. +Uses the exact same logic as the frontend preview to ensure value consistency. + +Features: +- 2 years of training data +- Logistic growth with floor/cap +- UK holidays + custom special dates +- OTB floor capping +- Per-date bookable cap adjustments +- Metric-specific handling +""" +import logging +from datetime import date, timedelta +from typing import List, Dict, Optional +import pandas as pd +import warnings +from prophet import Prophet +from sqlalchemy import text + +from utils.capacity import get_bookable_cap + +logger = logging.getLogger(__name__) +warnings.filterwarnings('ignore') + + +# Metric configuration mapping +METRIC_COLUMN_MAP = { + 'occupancy': ('s.occupancy_pct', False, True), + 'rooms': ('s.booking_count', False, False), + 'guests': ('s.guest_count', False, False), + 'ave_guest_rate': ('s.arr_net', False, False), + 'arr': ('s.arr_net', False, False), + 'net_accom': ('r.accommodation', True, False), + 'net_dry': ('r.dry', True, False), + 'net_wet': ('r.wet', True, False), + 'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False), +} + + +def get_metric_query_parts(metric: str) -> tuple: + """ + Get SQL query parts for a metric. + Returns: (column_expr, from_clause, is_percentage) + """ + if metric not in METRIC_COLUMN_MAP: + # Default to rooms if unknown metric + metric = 'rooms' + + col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric] + + if needs_revenue: + from_clause = """ + FROM newbook_bookings_stats s + LEFT JOIN newbook_net_revenue_data r ON s.date = r.date + """ + else: + from_clause = "FROM newbook_bookings_stats s" + + return col_expr, from_clause, is_pct + + +def get_lead_time_column(lead_days: int) -> str: + """ + Map lead days to the appropriate column in newbook_booking_pace. + """ + if lead_days <= 0: + return "d0" + elif lead_days <= 30: + return f"d{lead_days}" + elif lead_days <= 177: + # Weekly intervals - find nearest column + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + for col in weekly_cols: + if lead_days <= col: + return f"d{col}" + return "d177" + else: + # Monthly intervals + monthly_cols = [210, 240, 270, 300, 330, 365] + for col in monthly_cols: + if lead_days <= col: + return f"d{col}" + return "d365" + + +async def run_prophet_tuned_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + perception_date: Optional[date] = None +) -> List[Dict]: + """ + Generate Prophet forecast using production-tuned model. + + This uses the exact same logic as the prophet-preview endpoint to ensure + backend snapshots match frontend preview values. + + Args: + db: Database session + metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights') + start_date: Start date for forecast + end_date: End date for forecast + perception_date: Optional date to generate forecast as-of (for backtesting) + + Returns: + List of forecast dicts with forecast_date and predicted_value + """ + logger.info(f"Running Prophet tuned forecast for {metric_code}: {start_date} to {end_date}") + + # Map metric codes to preview endpoint metric names + 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', + } + + metric = metric_map.get(metric_code, 'rooms') + + # Use perception_date if provided, otherwise use actual today + today = perception_date if perception_date else date.today() + + # Get default bookable cap + default_bookable_cap = await get_bookable_cap(db) + + # Get metric column and query parts + col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric) + + # Get historical data for Prophet training (past 2 years) + history_start = today - timedelta(days=730) + history_query = f""" + SELECT s.date as ds, {col_expr} as y + {from_clause} + WHERE s.date >= :history_start + AND s.date < :today + AND {col_expr} IS NOT NULL + ORDER BY s.date + """ + history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today}) + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + logger.warning(f"Insufficient historical data for Prophet model: {len(history_rows)} rows") + return [] + + # Build training dataframe + df = pd.DataFrame([{"ds": row.ds, "y": float(row.y) if row.y is not None else 0} for row in history_rows]) + + # Set floor/cap based on metric type + if is_pct_metric: + # Percentage metrics (occupancy) + training_cap = 100 + elif metric == 'rooms': + # Room counts - cap at bookable rooms + training_cap = default_bookable_cap + elif metric == 'guests': + # Guests can exceed rooms (multiple per room) - use historical max * 1.5 + training_cap = df["y"].max() * 1.5 if len(df) > 0 and df["y"].max() > 0 else default_bookable_cap * 3 + else: + # Revenue/rate metrics - use percentile-based cap + training_cap = df["y"].quantile(0.99) * 1.5 if len(df) > 0 and df["y"].quantile(0.99) > 0 else 10000 + + df["floor"] = 0 + df["cap"] = training_cap + + # Train Prophet model with logistic growth (respects floor/cap) + model = Prophet( + growth='logistic', + yearly_seasonality=True, + weekly_seasonality=True, + daily_seasonality=False, + interval_width=0.8, + changepoint_prior_scale=0.05 + ) + + # Add UK holidays + model.add_country_holidays(country_name='UK') + + # Add custom special dates from settings + try: + from api.special_dates import get_special_dates_for_prophet + # Get special dates for training period + forecast period + min_year = history_start.year + max_year = end_date.year + 1 + custom_holidays = await get_special_dates_for_prophet(db, min_year, max_year) + + if custom_holidays: + # Create holidays dataframe for Prophet + holidays_df = pd.DataFrame(custom_holidays) + # Group by holiday name and add lower/upper windows + for holiday_name in holidays_df['holiday'].unique(): + holiday_dates = holidays_df[holidays_df['holiday'] == holiday_name][['ds', 'holiday']] + holiday_dates = holiday_dates.copy() + holiday_dates['lower_window'] = 0 + holiday_dates['upper_window'] = 0 + model.holidays = pd.concat([model.holidays, holiday_dates]) if model.holidays is not None else holiday_dates + except Exception as e: + logger.warning(f"Could not load special dates for Prophet: {e}") + + model.fit(df) + + # Create future dataframe for forecast period + future_dates = [] + current_date = start_date + while current_date <= end_date: + if (current_date - today).days >= 0: + future_dates.append({"ds": current_date}) + current_date += timedelta(days=1) + + if not future_dates: + logger.warning("No future dates to forecast") + return [] + + future_df = pd.DataFrame(future_dates) + + # Add floor/cap for logistic growth predictions (must match training cap) + future_df["floor"] = 0 + future_df["cap"] = training_cap + + forecast = model.predict(future_df) + + # Process forecast results + forecasts = [] + is_room_based = metric in ('occupancy', 'rooms') + + for _, row in forecast.iterrows(): + forecast_date = row["ds"].date() + lead_days = (forecast_date - today).days + lead_col = get_lead_time_column(lead_days) + + # Get current OTB (only for room-based metrics) + current_otb = None + if is_room_based: + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + + # Get per-date bookable cap for room-based metrics + date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap) + + # Convert to occupancy if needed + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + + # Get Prophet forecast values + yhat = row["yhat"] + + # Cap at max capacity based on metric type (uses per-date bookable cap) + if is_pct_metric: + yhat = min(yhat, 100.0) + elif metric == 'rooms': + yhat = min(yhat, float(date_bookable_cap)) + # Guests and revenue/rate metrics don't have a hard cap + + # Floor forecast to current OTB if we have it (room-based metrics only) + # But never exceed the bookable capacity (e.g., closed/maintenance periods) + if is_room_based and current_otb is not None and yhat < current_otb: + yhat = min(current_otb, float(date_bookable_cap)) + + forecasts.append({ + 'forecast_date': forecast_date, + 'predicted_value': round(yhat, 2) + }) + + logger.info(f"Prophet tuned generated {len(forecasts)} forecasts for {metric_code}") + return forecasts diff --git a/backend/services/forecasting/xgboost_model.py b/backend/services/forecasting/xgboost_model.py new file mode 100644 index 0000000..aae852c --- /dev/null +++ b/backend/services/forecasting/xgboost_model.py @@ -0,0 +1,300 @@ +""" +XGBoost forecasting model +Gradient boosting with feature engineering and SHAP explainability +""" +import logging +from datetime import date, timedelta +from typing import List, Optional +import pandas as pd +import numpy as np +import json +from sqlalchemy import text + +logger = logging.getLogger(__name__) + + +def create_features(df: pd.DataFrame) -> pd.DataFrame: + """ + Create features for XGBoost model + + Features include: + - Day of week (0-6) + - Month (1-12) + - Day of month + - Week of year + - Is weekend + - Is holiday (would need holiday calendar) + - Lag features (7, 14, 28 days) + - Rolling averages (7, 14, 28 days) + """ + df = df.copy() + + # Date features + df['day_of_week'] = df['ds'].dt.dayofweek + df['month'] = df['ds'].dt.month + df['day_of_month'] = df['ds'].dt.day + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['day_of_week'] >= 5).astype(int) + + # Cyclical encoding for day of week + df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7) + df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7) + + # Cyclical encoding for month + df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12) + df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12) + + # Lag features + for lag in [7, 14, 21, 28]: + df[f'lag_{lag}'] = df['y'].shift(lag) + + # Rolling averages + for window in [7, 14, 28]: + df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean() + df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std() + + # Year-over-year feature: uses 365 days (same calendar date, not DOW-aligned) + # This is intentional for ML: captures date-specific patterns like holidays + # Combined with day_of_week features, the model learns both patterns + # Note: For direct comparisons (pickup model), use 364 days for DOW alignment + if len(df) > 365: + df['lag_365'] = df['y'].shift(365) + + return df + + +async def run_xgboost_forecast( + db, + metric_code: str, + forecast_from: date, + forecast_to: date, + training_days: int = 2555 # ~7 years - use all available history +) -> List[dict]: + """ + Run XGBoost forecast for a metric + + Args: + db: Database session + metric_code: Metric to forecast + forecast_from: Start date for forecasts + forecast_to: End date for forecasts + training_days: Days of historical data to use + + Returns: + List of forecast records + """ + try: + import xgboost as xgb + from sklearn.model_selection import train_test_split + + # Get historical data + training_from = forecast_from - timedelta(days=training_days + 60) # Extra for lag features + + # Revenue metrics use earned_revenue_data joined with gl_accounts + revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev'] + if metric_code in revenue_metrics: + revenue_departments = { + 'net_accom': 'accommodation', + 'net_dry': 'dry', + 'net_wet': 'wet', + 'total_rev': None, # All departments + } + department = revenue_departments.get(metric_code) + if department is None and metric_code != 'total_rev': + logger.warning(f"Unknown revenue metric for XGBoost: {metric_code}") + return [] + + if metric_code == 'total_rev': + # Total revenue across all departments + result = db.execute( + text(""" + SELECT date, SUM(amount_net) as actual_value + FROM newbook_earned_revenue_data + WHERE date BETWEEN :from_date AND :to_date + GROUP BY date + HAVING SUM(amount_net) IS NOT NULL + ORDER BY date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1)} + ) + else: + # Revenue by department + result = db.execute( + text(""" + SELECT r.date, SUM(r.amount_net) as actual_value + FROM newbook_earned_revenue_data r + JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id + WHERE r.date BETWEEN :from_date AND :to_date + AND g.department = :department + GROUP BY r.date + HAVING SUM(r.amount_net) IS NOT NULL + ORDER BY r.date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department} + ) + else: + # Hotel metrics use newbook_bookings_stats table + metric_column_map = { + 'hotel_occupancy_pct': 'total_occupancy_pct', + 'hotel_room_nights': 'booking_count', + 'hotel_guests': 'guests_count', + } + + column_name = metric_column_map.get(metric_code) + if not column_name: + logger.warning(f"Unknown metric_code for XGBoost: {metric_code}") + return [] + + result = db.execute( + text(f""" + SELECT date, {column_name} as actual_value + FROM newbook_bookings_stats + WHERE date BETWEEN :from_date AND :to_date + AND {column_name} IS NOT NULL + ORDER BY date + """), + {"from_date": training_from, "to_date": forecast_from - timedelta(days=1)} + ) + + rows = result.fetchall() + + if len(rows) < 60: + logger.warning(f"Insufficient data for XGBoost forecast: {metric_code} has {len(rows)} records") + return [] + + # Prepare data + df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows]) + df = df.sort_values('ds').reset_index(drop=True) + + # Create features + df = create_features(df) + + # Remove rows with NaN from lag features + df = df.dropna() + + # Define feature columns + feature_cols = [ + 'day_of_week', 'month', 'day_of_month', 'week_of_year', 'is_weekend', + 'dow_sin', 'dow_cos', 'month_sin', 'month_cos', + 'lag_7', 'lag_14', 'lag_21', 'lag_28', + 'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28', + 'rolling_std_7', 'rolling_std_14', 'rolling_std_28' + ] + + # Add lag_365 if available + if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30: + feature_cols.append('lag_365') + + X = df[feature_cols] + y = df['y'] + + # Train model + model = xgb.XGBRegressor( + n_estimators=100, + max_depth=5, + learning_rate=0.1, + objective='reg:squarederror', + random_state=42 + ) + model.fit(X, y) + + # Generate forecasts + forecasts = [] + current_df = df.copy() + + for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'): + # Create row for forecast date + new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}]) + current_df = pd.concat([current_df, new_row], ignore_index=True) + current_df = create_features(current_df) + + # Get features for prediction + X_pred = current_df[feature_cols].iloc[-1:].ffill() + + # Make prediction + prediction = float(model.predict(X_pred)[0]) + + # Update y value for lag features + current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction + + forecast_record = { + "forecast_date": forecast_date.date(), + "forecast_type": metric_code, + "model_type": "xgboost", + "predicted_value": round(float(prediction), 2) + } + forecasts.append(forecast_record) + + # Store in database + db.execute( + text(""" + INSERT INTO forecasts ( + forecast_date, forecast_type, model_type, predicted_value, generated_at + ) VALUES ( + :forecast_date, :forecast_type, :model_type, :predicted_value, NOW() + ) + """), + forecast_record + ) + + # Commit forecasts before SHAP calculations + db.commit() + + # Calculate SHAP values for explainability + try: + import shap + explainer = shap.TreeExplainer(model) + + # Get SHAP values for last few predictions + for i, forecast_date in enumerate(pd.date_range(start=forecast_from, end=min(forecast_from + timedelta(days=7), forecast_to), freq='D')): + idx = len(df) + i + X_explain = current_df[feature_cols].iloc[idx:idx+1].ffill() + shap_values = explainer.shap_values(X_explain) + + # Store SHAP explanation + feature_contributions = dict(zip(feature_cols, shap_values[0].tolist())) + top_positive = sorted( + [{"feature": k, "contribution": v} for k, v in feature_contributions.items() if v > 0], + key=lambda x: x["contribution"], reverse=True + )[:5] + top_negative = sorted( + [{"feature": k, "contribution": v} for k, v in feature_contributions.items() if v < 0], + key=lambda x: x["contribution"] + )[:3] + + try: + db.execute( + text(""" + INSERT INTO xgboost_explanations ( + forecast_date, forecast_type, base_value, + feature_values, shap_values, top_positive, top_negative, generated_at + ) VALUES ( + :date, :metric, :base_value, :feature_values, + :shap_values, :top_positive, :top_negative, NOW() + ) + """), + { + "date": forecast_date.date(), + "metric": metric_code, + "base_value": float(explainer.expected_value), + "feature_values": json.dumps(X_explain.iloc[0].to_dict()), + "shap_values": json.dumps(feature_contributions), + "top_positive": json.dumps(top_positive), + "top_negative": json.dumps(top_negative) + } + ) + except Exception: + pass # Skip if conflict, explanations are supplementary + except Exception as e: + logger.warning(f"SHAP calculation failed: {e}") + + db.commit() + logger.info(f"XGBoost forecast generated for {metric_code}: {len(forecasts)} records") + return forecasts + + except ImportError as e: + logger.error(f"Required package not installed: {e}") + return [] + except Exception as e: + logger.error(f"XGBoost forecast failed for {metric_code}: {e}") + return [] diff --git a/backend/services/forecasting/xgboost_tuned.py b/backend/services/forecasting/xgboost_tuned.py new file mode 100644 index 0000000..cd5cb70 --- /dev/null +++ b/backend/services/forecasting/xgboost_tuned.py @@ -0,0 +1,446 @@ +""" +XGBoost Tuned Model Service + +This is the production-tuned XGBoost model extracted from the xgboost-preview endpoint. +Uses the exact same logic as the frontend preview to ensure value consistency. + +Features: +- 2 years of training data +- Pace features (OTB at different lead times) for room-based metrics +- Time-based features (day of week, month, week, weekend, special dates) +- Lag features from prior year same DOW +- OTB floor capping +- Per-date bookable cap adjustments +""" +import logging +from datetime import date, timedelta +from typing import List, Dict, Optional +import pandas as pd +import numpy as np +from xgboost import XGBRegressor +import warnings +from sqlalchemy import text + +from utils.capacity import get_bookable_cap +from api.special_dates import resolve_special_date + +logger = logging.getLogger(__name__) +warnings.filterwarnings('ignore') + + +# Metric configuration mapping +METRIC_COLUMN_MAP = { + 'occupancy': ('s.occupancy_pct', False, True), + 'rooms': ('s.booking_count', False, False), + 'guests': ('s.guest_count', False, False), + 'ave_guest_rate': ('s.arr_net', False, False), + 'arr': ('s.arr_net', False, False), + 'net_accom': ('r.accommodation', True, False), + 'net_dry': ('r.dry', True, False), + 'net_wet': ('r.wet', True, False), + 'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False), +} + + +def get_metric_query_parts(metric: str) -> tuple: + """Get SQL query parts for a metric. Returns: (column_expr, from_clause, is_percentage)""" + if metric not in METRIC_COLUMN_MAP: + metric = 'rooms' + col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric] + if needs_revenue: + from_clause = """ + FROM newbook_bookings_stats s + LEFT JOIN newbook_net_revenue_data r ON s.date = r.date + """ + else: + from_clause = "FROM newbook_bookings_stats s" + return col_expr, from_clause, is_pct + + +def get_lead_time_column(lead_days: int) -> str: + """Map lead days to the appropriate column in newbook_booking_pace.""" + if lead_days <= 0: + return "d0" + elif lead_days <= 30: + return f"d{lead_days}" + elif lead_days <= 177: + weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177] + for col in weekly_cols: + if lead_days <= col: + return f"d{col}" + return "d177" + else: + monthly_cols = [210, 240, 270, 300, 330, 365] + for col in monthly_cols: + if lead_days <= col: + return f"d{col}" + return "d365" + + +def round_towards_reference(value: float, reference: Optional[float]) -> int: + """Round a forecast value towards a reference value (prior year actual).""" + if reference is None: + return round(value) + if value < reference: + return int(np.ceil(value)) + else: + return int(np.floor(value)) + + +async def run_xgboost_tuned_forecast( + db, + metric_code: str, + start_date: date, + end_date: date, + perception_date: Optional[date] = None +) -> List[Dict]: + """ + Generate XGBoost forecast using production-tuned model. + + This uses the exact same logic as the xgboost-preview endpoint to ensure + backend snapshots match frontend preview values. + + Args: + db: Database session + metric_code: Metric to forecast + start_date: Start date for forecast + end_date: End date for forecast + perception_date: Optional date to generate forecast as-of (for backtesting) + + Returns: + List of forecast dicts with forecast_date and predicted_value + """ + logger.info(f"Running XGBoost tuned forecast for {metric_code}: {start_date} to {end_date}") + + # Map metric codes to preview endpoint metric names + 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', + } + + metric = metric_map.get(metric_code, 'rooms') + + # Use perception_date if provided, otherwise use actual today + today = perception_date if perception_date else date.today() + + # Get default bookable cap + default_bookable_cap = await get_bookable_cap(db) + + # Get metric column and query parts + col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric) + is_room_based = metric in ('occupancy', 'rooms') + + # Get historical data for XGBoost training (past 2 years) + history_start = today - timedelta(days=730) + + # Lead times to train on (key intervals) - only used for room-based metrics + train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30] + + # Get final values (and pace data for room-based metrics) + if is_room_based: + history_result = await db.execute(text(""" + SELECT s.date as ds, s.booking_count as final, + p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30 + FROM newbook_bookings_stats s + LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date + WHERE s.date >= :history_start + AND s.date < :today + AND s.booking_count IS NOT NULL + ORDER BY s.date + """), {"history_start": history_start, "today": today}) + else: + # Non-room metrics: get values without pace join + history_query = f""" + SELECT s.date as ds, {col_expr} as final + {from_clause} + WHERE s.date >= :history_start + AND s.date < :today + AND {col_expr} IS NOT NULL + ORDER BY s.date + """ + history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today}) + + history_rows = history_result.fetchall() + + if len(history_rows) < 30: + logger.warning(f"Insufficient historical data for XGBoost model: {len(history_rows)} rows") + return [] + + # Load special dates for feature + special_date_set = set() + try: + special_dates_result = await db.execute(text( + "SELECT * FROM special_dates WHERE is_active = TRUE" + )) + special_dates_rows = special_dates_result.fetchall() + years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1} + for row in special_dates_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 + } + for year in years_needed: + resolved_dates = resolve_special_date(sd, year) + for d in resolved_dates: + special_date_set.add(d) + except Exception as e: + logger.warning(f"Could not load special dates: {e}") + + # Build lookup dicts + final_by_date = {} + pace_by_date = {} + for row in history_rows: + final_by_date[row.ds] = row.final + if is_room_based and hasattr(row, 'd0'): + pace_by_date[row.ds] = { + 0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7, + 14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30 + } + + # Build training examples + training_rows = [] + + if is_room_based: + # Room-based metrics: use pace features (one per date,lead_time combo) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + continue + + for lead_time in train_lead_times: + current_otb = pace_by_date.get(ds, {}).get(lead_time) + if current_otb is None: + continue + + prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time) + if prior_otb is None: + prior_otb = 0 + + otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0 + + training_rows.append({ + 'ds': ds, + 'y': final, + 'days_out': lead_time, + 'current_otb': float(current_otb), + 'prior_otb_same_lead': float(prior_otb), + 'lag_364': float(prior_final), + 'otb_pct_of_prior_final': otb_pct_of_prior_final + }) + else: + # Non-room metrics: use time features only (one per date) + for row in history_rows: + ds = row.ds + final = float(row.final) if row.final else 0 + prior_ds = ds - timedelta(days=364) + + prior_final = final_by_date.get(prior_ds) + if prior_final is None: + prior_final = 0 # Allow training even without prior year for revenue metrics + + training_rows.append({ + 'ds': ds, + 'y': final, + 'lag_364': float(prior_final) if prior_final else 0 + }) + + if len(training_rows) < 30: + logger.warning(f"Insufficient data for XGBoost training: {len(training_rows)} rows") + return [] + + df = pd.DataFrame(training_rows) + df['ds'] = pd.to_datetime(df['ds']) + + # Convert to occupancy if needed + if metric == "occupancy" and default_bookable_cap > 0: + df["y"] = (df["y"] / default_bookable_cap) * 100 + if "current_otb" in df.columns: + df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100 + if "prior_otb_same_lead" in df.columns: + df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100 + df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100 + + # Create time-based features + df['day_of_week'] = df['ds'].dt.dayofweek + df['month'] = df['ds'].dt.month + df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int) + df['is_weekend'] = (df['day_of_week'] >= 5).astype(int) + df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0) + + df_train = df.dropna() + + if len(df_train) < 30: + logger.warning(f"Insufficient data after creating features: {len(df_train)} rows") + return [] + + # Define features based on metric type + if is_room_based: + feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', + 'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final'] + else: + feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', 'lag_364'] + + X_train = df_train[feature_cols] + y_train = df_train['y'] + + # Train XGBoost model + model = XGBRegressor( + n_estimators=100, + max_depth=6, + learning_rate=0.1, + objective='reg:squarederror', + random_state=42, + n_jobs=-1 + ) + model.fit(X_train, y_train) + + # Create future dataframe for forecast period + future_dates = [] + current_date = start_date + while current_date <= end_date: + if (current_date - today).days >= 0: + future_dates.append(current_date) + current_date += timedelta(days=1) + + if not future_dates: + logger.warning("No future dates to forecast") + return [] + + # Generate forecasts for each date + forecasts = [] + + for forecast_date in future_dates: + lead_days = (forecast_date - today).days + lead_col = get_lead_time_column(lead_days) + prior_year_date = forecast_date - timedelta(days=364) + + # Get OTB data only for room-based metrics + current_otb = None + + if is_room_based: + current_query = text(""" + SELECT booking_count as current_otb + FROM newbook_bookings_stats + WHERE date = :arrival_date + """) + current_result = await db.execute(current_query, {"arrival_date": forecast_date}) + current_row = current_result.fetchone() + current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0 + + # Get prior year final using metric mapping + prior_query = f""" + SELECT {col_expr} as prior_final + {from_clause} + WHERE s.date = :prior_date + """ + prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date}) + prior_row = prior_result.fetchone() + prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0 + + # Get per-date bookable cap for this forecast date + date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap) + + # Build features for this date + forecast_dt = pd.Timestamp(forecast_date) + lag_364_val = prior_final if prior_final else 0 + + # Convert to occupancy if needed + if metric == "occupancy" and date_bookable_cap > 0: + if current_otb is not None: + current_otb = (current_otb / date_bookable_cap) * 100 + lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0 + + # Build features based on metric type + if is_room_based: + # Get prior OTB at same lead time + prior_year_for_otb = forecast_date - timedelta(days=364) + prior_otb_query = text(f""" + SELECT {lead_col} as prior_otb + FROM newbook_booking_pace + WHERE arrival_date = :prior_date + """) + prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb}) + prior_otb_row = prior_otb_result.fetchone() + prior_otb_same_lead = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else 0 + + if metric == "occupancy" and date_bookable_cap > 0: + prior_otb_same_lead = (prior_otb_same_lead / date_bookable_cap) * 100 if prior_otb_same_lead else 0 + + current_otb_val = current_otb if current_otb is not None else 0 + otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0 + + features = pd.DataFrame([{ + 'day_of_week': forecast_dt.dayofweek, + 'month': forecast_dt.month, + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'days_out': lead_days, + 'current_otb': current_otb_val, + 'prior_otb_same_lead': prior_otb_same_lead, + 'lag_364': lag_364_val, + 'otb_pct_of_prior_final': otb_pct_of_prior_final, + }]) + else: + features = pd.DataFrame([{ + 'day_of_week': forecast_dt.dayofweek, + 'month': forecast_dt.month, + 'week_of_year': forecast_dt.isocalendar().week, + 'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0, + 'is_special_date': 1 if forecast_date in special_date_set else 0, + 'lag_364': lag_364_val, + }]) + + # Predict + yhat = float(model.predict(features)[0]) + + # Cap at max capacity based on metric type (uses per-date bookable cap) + if is_pct_metric: + yhat = min(max(yhat, 0), 100.0) + elif metric == 'rooms': + yhat = round(min(max(yhat, 0), float(date_bookable_cap))) + elif metric == 'guests': + yhat = round(max(yhat, 0)) + else: + # Revenue/rate metrics: just ensure non-negative + yhat = max(yhat, 0) + + # Floor forecast to current OTB (room-based only) + if is_room_based and current_otb is not None and yhat < current_otb: + yhat = current_otb + + # Round based on metric type + if metric == "occupancy": + yhat = round(yhat, 1) + else: + yhat = round_towards_reference(yhat, prior_final) + + forecasts.append({ + 'forecast_date': forecast_date, + 'predicted_value': yhat + }) + + logger.info(f"XGBoost tuned generated {len(forecasts)} forecasts for {metric_code}") + return forecasts diff --git a/backend/services/newbook_client.py b/backend/services/newbook_client.py new file mode 100644 index 0000000..85f2fe6 --- /dev/null +++ b/backend/services/newbook_client.py @@ -0,0 +1,443 @@ +""" +Newbook API Client + +CRITICAL: This client is READ-ONLY. +Newbook API uses POST for all requests - the "action" parameter determines the operation. +This client ONLY uses read actions (bookings_list, site_list, report_*). +NO write actions (booking_create, booking_update, booking_cancel, etc.) are used. +Data flows ONE WAY: Newbook → Local Database +""" +import os +import httpx +import asyncio +import logging +from datetime import date, timedelta +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +class NewbookAPIError(Exception): + """Custom exception for Newbook API errors""" + pass + + +class NewbookClient: + """ + Async client for Newbook REST API + + Rate limiting: ~100 requests/min, using 0.75s delay between requests + Pagination: Uses data_offset/data_limit, max 1000 per request + """ + + BASE_URL = "https://api.newbook.cloud/rest" + + def __init__(self, api_key: str = None, username: str = None, password: str = None, region: str = None): + # Use provided credentials or fall back to environment variables + self.api_key = api_key or os.getenv("NEWBOOK_API_KEY") + self.username = username or os.getenv("NEWBOOK_USERNAME") + self.password = password or os.getenv("NEWBOOK_PASSWORD") + self.region = region or os.getenv("NEWBOOK_REGION") + + if not all([self.api_key, self.username, self.password, self.region]): + logger.warning("Newbook credentials not fully configured") + + def _get_url(self, endpoint: str) -> str: + """Get full URL for an endpoint""" + return f"{self.BASE_URL}/{endpoint}" + + @classmethod + async def from_db(cls, db): + """Create client with credentials from database""" + from api.config import _get_config_value + + api_key = await _get_config_value(db, "newbook_api_key") + username = await _get_config_value(db, "newbook_username") + password = await _get_config_value(db, "newbook_password") + region = await _get_config_value(db, "newbook_region") + + return cls(api_key=api_key, username=username, password=password, region=region) + + async def __aenter__(self): + self.client = httpx.AsyncClient(timeout=300.0) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.client.aclose() + + def _get_auth_payload(self) -> dict: + """Get base authentication payload (api_key and region only - username/password go in Basic Auth)""" + return { + "api_key": self.api_key, + "region": self.region + } + + async def test_connection(self) -> bool: + """Test API connection""" + try: + payload = self._get_auth_payload() + + response = await self.client.post( + self._get_url("site_list"), + json=payload, + auth=(self.username, self.password) + ) + return response.status_code == 200 + except Exception as e: + logger.error(f"Newbook connection test failed: {e}") + return False + + async def get_bookings( + self, + modified_since: Optional[str] = None, + modified_until: Optional[str] = None, + batch_size: int = 1000 + ) -> List[dict]: + """ + Fetch all bookings with pagination and rate limiting. + + Uses list_type="all" which returns all bookings (including cancelled). + period_from/period_to filter by created/modified timestamp, not stay dates. + + Args: + modified_since: ISO timestamp - only bookings created/modified after this + modified_until: ISO timestamp - only bookings created/modified before this + batch_size: Records per request (max 1000) + + Returns: + List of booking objects (all statuses including cancelled) + """ + all_bookings = [] + offset = 0 + + while True: + logger.info(f"Fetching Newbook bookings (all): modified_since={modified_since} (offset: {offset})") + + payload = self._get_auth_payload() + payload.update({ + "list_type": "all", + "data_offset": offset, + "data_limit": batch_size + }) + + # Add optional timestamp filters + if modified_since: + payload["period_from"] = modified_since + if modified_until: + payload["period_to"] = modified_until + + response = await self.client.post( + self._get_url("bookings_list"), + json=payload, + auth=(self.username, self.password) + ) + + if response.status_code != 200: + logger.error(f"Newbook API error {response.status_code}: {response.text}") + raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}") + + data = response.json() + + if not data.get("success"): + raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}") + + bookings = data.get("data", []) + + if not bookings: + break + + all_bookings.extend(bookings) + logger.info(f"Fetched {len(bookings)} bookings (offset {offset})") + + # Check if we've got all records + total = data.get("data_total", 0) + if offset + len(bookings) >= total: + break + + offset += batch_size + + # Rate limiting: 0.75s delay + await asyncio.sleep(0.75) + + logger.info(f"Total bookings fetched: {len(all_bookings)}") + return all_bookings + + async def get_bookings_by_stay_dates( + self, + from_date: date, + to_date: date, + list_type: str = "staying", + batch_size: int = 1000 + ) -> List[dict]: + """ + Fetch bookings by stay dates (arrival/departure/staying period). + + Args: + from_date: Start date for stay period + to_date: End date for stay period + list_type: Type of booking list: + "staying" - bookings staying during dates (excludes cancelled) + "arrived" - arrived during dates (add mode="projected" for expected) + "arriving" - expected to arrive before period_to + "departed" - departed during dates + "departing" - expected to depart during dates + "cancelled" - cancelled during dates + "placed" - created during dates + "no_show" - no shows for dates + batch_size: Records per request (max 1000) + + Returns: + List of booking objects + """ + all_bookings = [] + offset = 0 + + while True: + logger.info(f"Fetching Newbook bookings ({list_type}): {from_date} to {to_date} (offset: {offset})") + + payload = self._get_auth_payload() + payload.update({ + "list_type": list_type, + "period_from": from_date.isoformat(), + "period_to": to_date.isoformat(), + "data_offset": offset, + "data_limit": batch_size + }) + + response = await self.client.post( + self._get_url("bookings_list"), + json=payload, + auth=(self.username, self.password) + ) + + if response.status_code != 200: + logger.error(f"Newbook API error {response.status_code}: {response.text}") + raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}") + + data = response.json() + + if not data.get("success"): + raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}") + + bookings = data.get("data", []) + + if not bookings: + break + + all_bookings.extend(bookings) + logger.info(f"Fetched {len(bookings)} bookings (offset {offset})") + + # Check if we've got all records + total = data.get("data_total", 0) + if offset + len(bookings) >= total: + break + + offset += batch_size + + # Rate limiting: 0.75s delay + await asyncio.sleep(0.75) + + logger.info(f"Total bookings fetched: {len(all_bookings)}") + return all_bookings + + async def get_occupancy_report( + self, + from_date: date, + to_date: date + ) -> List[dict]: + """ + Fetch occupancy report for date range. + + Uses reports_occupancy endpoint which returns data by room category. + Returns all categories with nested occupancy data for each date in range. + No pagination needed - API returns full dataset in single response. + + Response format: + [ + { + "category_id": "1", + "category_name": "Single Room", + "occupancy": { + "2024-08-01": { + "date": "2024-08-01", + "available": 5, + "occupied": 3, + "maintenance": 1, + "allotted": 0, + "revenue_gross": 450.00, + "revenue_net": 375.00 + }, + ... + } + }, + ... + ] + + Returns list of category objects with nested occupancy by date + """ + logger.info(f"Fetching occupancy report: {from_date} to {to_date}") + + payload = self._get_auth_payload() + payload.update({ + "period_from": f"{from_date.isoformat()} 00:00:00", + "period_to": f"{to_date.isoformat()} 23:59:59" + }) + + response = await self.client.post( + self._get_url("reports_occupancy"), + json=payload, + auth=(self.username, self.password) + ) + + if response.status_code != 200: + raise NewbookAPIError(f"Failed to fetch occupancy: {response.status_code}") + + data = response.json() + + if not data.get("success"): + raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}") + + records = data.get("data", []) + logger.info(f"Fetched occupancy report: {len(records)} categories") + return records + + async def get_site_list(self) -> List[dict]: + """Fetch list of rooms/sites with categories""" + payload = self._get_auth_payload() + + response = await self.client.post( + self._get_url("site_list"), + json=payload, + auth=(self.username, self.password) + ) + + if response.status_code != 200: + raise NewbookAPIError(f"Failed to fetch site list: {response.status_code}") + + data = response.json() + return data.get("data", []) + + async def get_earned_revenue( + self, + from_date: date, + to_date: date + ) -> dict: + """ + Fetch earned revenue report day by day + + Returns dict keyed by date with revenue breakdown by GL code + """ + all_revenue = {} + current_date = from_date + + while current_date <= to_date: + logger.info(f"Fetching earned revenue for {current_date}") + + payload = self._get_auth_payload() + payload.update({ + "period_from": current_date.isoformat(), + "period_to": current_date.isoformat() + }) + + response = await self.client.post( + self._get_url("reports_earned_revenue"), + json=payload, + auth=(self.username, self.password) + ) + + if response.status_code == 200: + data = response.json() + if data.get("success"): + day_data = data.get("data", {}) + # Debug: log first day's response structure + if current_date == from_date: + import json + logger.info(f"Sample earned revenue response: {json.dumps(day_data)[:500]}") + all_revenue[current_date.isoformat()] = day_data + + current_date += timedelta(days=1) + + # Rate limiting + await asyncio.sleep(0.75) + + return all_revenue + + async def get_transaction_flow( + self, + from_date: date, + to_date: date, + batch_size: int = 5000 + ) -> List[dict]: + """ + Fetch transaction flow report for date range. + Used by reconciliation module for payment categorization. + + Returns raw transaction records (payments, refunds, voided items). + Excludes balance_transfer items. + Handles pagination via data_offset/data_limit. + """ + logger.info(f"Fetching transaction flow: {from_date} to {to_date}") + + all_transactions = [] + offset = 0 + + while True: + payload = self._get_auth_payload() + payload.update({ + "period_from": f"{from_date.isoformat()} 00:00:00", + "period_to": f"{to_date.isoformat()} 23:59:59", + "data_offset": offset, + "data_limit": batch_size + }) + + response = await self.client.post( + self._get_url("reports_transaction_flow"), + json=payload, + auth=(self.username, self.password) + ) + + if response.status_code != 200: + raise NewbookAPIError(f"Failed to fetch transaction flow: {response.status_code}") + + data = response.json() + if not data.get("success"): + raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}") + + records = data.get("data", []) + all_transactions.extend(records) + + # If we got fewer records than the limit, we're done + if len(records) < batch_size: + break + + offset += batch_size + await asyncio.sleep(0.75) + + logger.info(f"Fetched transaction flow: {len(all_transactions)} transactions") + return all_transactions + + async def get_gl_account_list(self) -> List[dict]: + """ + Fetch GL account list from Newbook. + Used for reconciliation sales breakdown column configuration. + """ + logger.info("Fetching GL account list from Newbook") + + payload = self._get_auth_payload() + + response = await self.client.post( + self._get_url("gl_account_list"), + json=payload, + auth=(self.username, self.password) + ) + + if response.status_code != 200: + raise NewbookAPIError(f"Failed to fetch GL accounts: {response.status_code}") + + data = response.json() + if not data.get("success"): + raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}") + + records = data.get("data", []) + logger.info(f"Fetched {len(records)} GL accounts") + return records diff --git a/backend/services/newbook_rates_client.py b/backend/services/newbook_rates_client.py new file mode 100644 index 0000000..caa2cca --- /dev/null +++ b/backend/services/newbook_rates_client.py @@ -0,0 +1,855 @@ +""" +Newbook Rates Client + +Fetches current rack rates from Newbook API for revenue forecasting. +Uses the bookings_availability_pricing endpoint to simulate booking requests. + +This client is READ-ONLY - it only queries available rates, never creates bookings. +""" +import os +import httpx +import asyncio +import logging +from datetime import date, timedelta +from decimal import Decimal +from typing import Optional, List, Dict + +logger = logging.getLogger(__name__) + + +class NewbookRatesError(Exception): + """Custom exception for Newbook rates API errors""" + pass + + +class NewbookRatesClient: + """ + Async client for fetching current rates from Newbook API. + + Uses bookings_availability_pricing endpoint which simulates a booking request. + Handles minimum stay restrictions by extending the stay period when needed. + + Rate limiting: ~100 requests/min, using 0.75s delay between requests + """ + + BASE_URL = "https://api.newbook.cloud/rest" + + def __init__(self, api_key: str = None, username: str = None, password: str = None, + region: str = None, vat_rate: Decimal = Decimal('0.20')): + self.api_key = api_key or os.getenv("NEWBOOK_API_KEY") + self.username = username or os.getenv("NEWBOOK_USERNAME") + self.password = password or os.getenv("NEWBOOK_PASSWORD") + self.region = region or os.getenv("NEWBOOK_REGION") + self.vat_rate = vat_rate + + if not all([self.api_key, self.username, self.password, self.region]): + logger.warning("Newbook credentials not fully configured") + + def _get_url(self, endpoint: str) -> str: + """Get full URL for an endpoint""" + return f"{self.BASE_URL}/{endpoint}" + + @classmethod + async def from_db(cls, db): + """Create client with credentials and VAT rate from database""" + from sqlalchemy import text + + # Get credentials from config + result = await 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')") + ) + rows = result.fetchall() + config = {row.config_key: row.config_value for row in rows} + + vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20')) + + return cls( + api_key=config.get('newbook_api_key'), + username=config.get('newbook_username'), + password=config.get('newbook_password'), + region=config.get('newbook_region'), + vat_rate=vat_rate + ) + + async def __aenter__(self): + self.client = httpx.AsyncClient(timeout=60.0) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.client.aclose() + + def _get_auth_payload(self) -> dict: + """Get base authentication payload""" + return { + "api_key": self.api_key, + "region": self.region + } + + async def get_category_rates( + self, + category_id: str, + from_date: date, + to_date: date, + guests_adults: int = 2, + guests_children: int = 0 + ) -> List[Dict]: + """ + Fetch current rates for a category over a date range. + + Uses daily=true to get per-night rates. Handles minimum stay + restrictions by extending the period when needed. + + Args: + category_id: Newbook category ID + from_date: Start date for rates + to_date: End date for rates (inclusive) + guests_adults: Number of adult guests (default 2) + guests_children: Number of child guests (default 0) + + Returns: + List of dicts with {date, gross_rate, net_rate} + """ + rates = [] + current_date = from_date + + while current_date <= to_date: + try: + # Fetch rates for up to 7 days at a time to optimize API calls + batch_end = min(current_date + timedelta(days=6), to_date) + batch_rates = await self._fetch_rates_batch( + category_id, current_date, batch_end, guests_adults, guests_children + ) + rates.extend(batch_rates) + + # Move to next batch + current_date = batch_end + timedelta(days=1) + + except Exception as e: + logger.error(f"Failed to fetch rates for category {category_id} starting {current_date}: {e}") + # Skip this batch and continue + current_date = current_date + timedelta(days=7) + + # Rate limiting - ALWAYS wait 1.5s between requests, even after errors + await asyncio.sleep(1.5) + + return rates + + async def get_single_night_rates( + self, + category_id: str, + from_date: date, + to_date: date, + guests_adults: int = 2, + guests_children: int = 0 + ) -> List[Dict]: + """ + Fetch rates with single-night queries for accurate per-day tariff availability. + + Unlike get_category_rates which batches, this queries each date individually + as a 1-night stay. This gives accurate tariff_success per night, catching + issues like Valentine's Day blocking only that night, not a whole week. + + Much slower but necessary for accurate bookability data. + + Args: + category_id: Newbook category ID + from_date: Start date for rates + to_date: End date for rates (inclusive) + guests_adults: Number of adult guests (default 2) + guests_children: Number of child guests (default 0) + + Returns: + List of dicts with {date, gross_rate, net_rate, tariffs_data} + """ + rates = [] + current_date = from_date + + while current_date <= to_date: + try: + # Single-night query for accurate tariff availability + batch_rates = await self._fetch_rates_batch( + category_id, current_date, current_date, guests_adults, guests_children + ) + rates.extend(batch_rates) + + except Exception as e: + logger.warning(f"Failed to fetch single-night rate for {category_id} on {current_date}: {e}") + # Continue with next date + + current_date += timedelta(days=1) + + # Rate limiting - wait between each single-night query + await asyncio.sleep(1.0) + + return rates + + async def fetch_single_date_all_categories( + self, + for_date: date, + guests_adults: int = 2, + guests_children: int = 0 + ) -> Dict[str, List[Dict]]: + """ + Fetch single-night rates for ALL categories for one date. + + Returns: + Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]} + """ + return await self._fetch_all_categories_batch( + for_date, guests_adults, guests_children + ) + + async def fetch_multi_night_for_date( + self, + for_date: date, + nights: int, + guests_adults: int = 2, + guests_children: int = 0 + ) -> Dict[str, Dict[str, bool]]: + """ + Fetch multi-night availability for ALL categories for one date. + + Returns: + Dict of {category_id: {tariff_name: available}} + """ + return await self._fetch_all_categories_multi_night( + for_date, nights, guests_adults, guests_children + ) + + async def get_all_categories_single_night_rates( + self, + from_date: date, + to_date: date, + guests_adults: int = 2, + guests_children: int = 0 + ) -> Dict[str, List[Dict]]: + """ + Fetch rates for ALL categories with single-night queries. + + More efficient than get_single_night_rates - omits category_id to get + all categories in a single API call per date. This reduces API calls + from (categories × days) to just (days). + + Args: + from_date: Start date for rates + to_date: End date for rates (inclusive) + guests_adults: Number of adult guests (default 2) + guests_children: Number of child guests (default 0) + + Returns: + Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}, ...]} + """ + all_rates: Dict[str, List[Dict]] = {} + current_date = from_date + total_days = (to_date - from_date).days + 1 + day_count = 0 + + while current_date <= to_date: + day_count += 1 + try: + # Single-night query WITHOUT category_id - returns ALL categories + category_rates = await self._fetch_all_categories_batch( + current_date, guests_adults, guests_children + ) + + # Merge into all_rates dict + for cat_id, rates in category_rates.items(): + if cat_id not in all_rates: + all_rates[cat_id] = [] + all_rates[cat_id].extend(rates) + + logger.info(f"Fetched {current_date} ({day_count}/{total_days}) - {len(category_rates)} categories") + + except Exception as e: + logger.warning(f"Failed to fetch rates for {current_date}: {e}") + # Continue with next date + + current_date += timedelta(days=1) + + # Rate limiting - wait between each query + await asyncio.sleep(1.0) + + return all_rates + + async def _fetch_all_categories_batch( + self, + for_date: date, + guests_adults: int, + guests_children: int, + retry_count: int = 0 + ) -> Dict[str, List[Dict]]: + """ + Fetch rates for ALL categories for a single date. + + Omits category_id from request - Newbook returns all available categories. + + Returns: + Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]} + """ + # Single-night query + period_from = f"{for_date.isoformat()} 14:00:00" + period_to = f"{(for_date + timedelta(days=1)).isoformat()} 10:00:00" + + payload = self._get_auth_payload() + payload.update({ + "period_from": period_from, + "period_to": period_to, + "adults": guests_adults, + "children": guests_children, + "infants": 0, + "daily_mode": "true" + # NO category_id - returns all categories + }) + + response = await self.client.post( + self._get_url("bookings_availability_pricing"), + json=payload, + auth=(self.username, self.password) + ) + + # Handle rate limiting with exponential backoff + if response.status_code == 429: + if retry_count < 3: + wait_time = 60 * (retry_count + 1) + logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3") + await asyncio.sleep(wait_time) + return await self._fetch_all_categories_batch( + for_date, guests_adults, guests_children, retry_count + 1 + ) + else: + raise NewbookRatesError(f"Rate limited after 3 retries") + + if response.status_code != 200: + raise NewbookRatesError(f"API error {response.status_code}: {response.text}") + + data = response.json() + + if not data.get("success"): + raise NewbookRatesError(f"API returned failure: {data.get('message')}") + + # Parse all categories from response + return self._parse_all_categories_tariffs(data, for_date) + + async def _fetch_all_categories_multi_night( + self, + for_date: date, + nights: int, + guests_adults: int = 2, + guests_children: int = 0, + retry_count: int = 0 + ) -> Dict[str, Dict[str, bool]]: + """ + Fetch multi-night availability for ALL categories for a specific date. + + Used to verify that rates with min_stay requirements are actually bookable. + + Args: + for_date: Check-in date + nights: Number of nights to query (e.g., 2 for min_stay=2) + guests_adults: Number of adult guests + guests_children: Number of child guests + + Returns: + Dict of {category_id: {tariff_name: available}} + """ + # Multi-night query + period_from = f"{for_date.isoformat()} 14:00:00" + period_to = f"{(for_date + timedelta(days=nights)).isoformat()} 10:00:00" + + payload = self._get_auth_payload() + payload.update({ + "period_from": period_from, + "period_to": period_to, + "adults": guests_adults, + "children": guests_children, + "infants": 0, + "daily_mode": "true" + }) + + response = await self.client.post( + self._get_url("bookings_availability_pricing"), + json=payload, + auth=(self.username, self.password) + ) + + # Handle rate limiting + if response.status_code == 429: + if retry_count < 3: + wait_time = 60 * (retry_count + 1) + logger.warning(f"Rate limited (multi-night), waiting {wait_time}s") + await asyncio.sleep(wait_time) + return await self._fetch_all_categories_multi_night( + for_date, nights, guests_adults, guests_children, retry_count + 1 + ) + else: + raise NewbookRatesError(f"Rate limited after 3 retries") + + if response.status_code != 200: + raise NewbookRatesError(f"API error {response.status_code}: {response.text}") + + data = response.json() + + if not data.get("success"): + raise NewbookRatesError(f"API returned failure: {data.get('message')}") + + # Parse availability by tariff name for each category + results: Dict[str, Dict[str, bool]] = {} + + if not isinstance(data.get("data"), dict): + return results + + for key, cat_data in data["data"].items(): + if not (key.isdigit() or str(key).isnumeric()): + continue + if not isinstance(cat_data, dict): + continue + + category_id = str(key) + tariffs_available = cat_data.get("tariffs_available", []) + + results[category_id] = {} + for tariff in tariffs_available: + tariff_name = tariff.get("tariff_name", "") + tariff_label = tariff.get("tariff_label", "") + # Check tariff_success (API returns string "true"/"false") + tariff_success = str(tariff.get("tariff_success", False)).lower() in ("true", "1") + # Available if API says success, OR if rates are quoted and no restriction message + is_available = tariff_success or ( + bool(tariff.get("tariffs_quoted")) and not tariff.get("tariff_message") + ) + # Store under both tariff_name and tariff_label for flexible matching + results[category_id][tariff_name] = is_available + if tariff_label and tariff_label != tariff_name: + results[category_id][tariff_label] = is_available + + return results + + async def get_multi_night_availability( + self, + dates_by_nights: Dict[int, List[date]], + guests_adults: int = 2, + guests_children: int = 0 + ) -> Dict[date, Dict[str, Dict[str, bool]]]: + """ + Fetch multi-night availability for specific dates grouped by stay length. + + Checks if a tariff is available when booking N nights starting from each date. + + Args: + dates_by_nights: Dict of {nights: [dates]} e.g., {2: [date1, date2], 3: [date3]} + guests_adults: Number of adult guests + guests_children: Number of child guests + + Returns: + Dict of {date: {category_id: {tariff_name: available}}} + """ + results: Dict[date, Dict[str, Dict[str, bool]]] = {} + + total_queries = sum(len(dates) for dates in dates_by_nights.values()) + query_count = 0 + + for nights, dates in dates_by_nights.items(): + for query_date in dates: + query_count += 1 + + try: + result = await self._fetch_all_categories_multi_night( + query_date, nights, guests_adults, guests_children + ) + results[query_date] = result + logger.info(f"Multi-night check {query_count}/{total_queries}: {query_date} ({nights} nights)") + except Exception as e: + logger.warning(f"Failed multi-night check for {query_date}: {e}") + + # Rate limiting + await asyncio.sleep(1.0) + + return results + + async def _fetch_rates_batch( + self, + category_id: str, + from_date: date, + to_date: date, + guests_adults: int, + guests_children: int, + retry_count: int = 0 + ) -> List[Dict]: + """ + Fetch rates for a batch of dates (up to 7 days). + + Handles minimum stay restrictions by extending the period and + extracting only the dates we need. + + Returns: + List of dicts with {date, gross_rate, net_rate} + """ + # Format dates with times (check-in 14:00, check-out 10:00) + period_from = f"{from_date.isoformat()} 14:00:00" + period_to = f"{(to_date + timedelta(days=1)).isoformat()} 10:00:00" + + payload = self._get_auth_payload() + payload.update({ + "period_from": period_from, + "period_to": period_to, + "adults": guests_adults, + "children": guests_children, + "infants": 0, + "category_id": category_id, + "daily_mode": "true" # Get per-night breakdown + }) + + response = await self.client.post( + self._get_url("bookings_availability_pricing"), + json=payload, + auth=(self.username, self.password) + ) + + # Handle rate limiting with exponential backoff + if response.status_code == 429: + if retry_count < 3: + wait_time = 60 * (retry_count + 1) # 60s, 120s, 180s + logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3") + await asyncio.sleep(wait_time) + return await self._fetch_rates_batch( + category_id, from_date, to_date, guests_adults, guests_children, retry_count + 1 + ) + else: + raise NewbookRatesError(f"Rate limited after 3 retries") + + if response.status_code != 200: + raise NewbookRatesError(f"API error {response.status_code}: {response.text}") + + data = response.json() + + if not data.get("success"): + # Check if minimum stay restriction + categories = data.get("data", {}).get("categories", []) + if categories: + cat = categories[0] if isinstance(categories, list) else categories.get(category_id, {}) + min_periods = cat.get("minimum_periods", 1) + + if min_periods > 1: + # Extend the stay to meet minimum and retry + extended_to = from_date + timedelta(days=min_periods) + logger.info(f"Minimum stay {min_periods} nights for category {category_id}, extending to {extended_to}") + return await self._fetch_rates_with_min_stay( + category_id, from_date, to_date, extended_to, + guests_adults, guests_children + ) + + raise NewbookRatesError(f"API returned failure: {data.get('message')}") + + # Parse tariffs_quoted from response + return self._parse_tariffs(data, from_date, to_date) + + async def _fetch_rates_with_min_stay( + self, + category_id: str, + from_date: date, + to_date: date, + extended_to: date, + guests_adults: int, + guests_children: int, + retry_count: int = 0 + ) -> List[Dict]: + """ + Fetch rates with extended period for minimum stay requirement. + + Args: + category_id: Newbook category ID + from_date: Original start date + to_date: Original end date (dates we want) + extended_to: Extended end date to meet minimum stay + guests_adults: Number of adults + guests_children: Number of children + + Returns: + List of rates for the original date range only + """ + period_from = f"{from_date.isoformat()} 14:00:00" + period_to = f"{(extended_to + timedelta(days=1)).isoformat()} 10:00:00" + + payload = self._get_auth_payload() + payload.update({ + "period_from": period_from, + "period_to": period_to, + "adults": guests_adults, + "children": guests_children, + "infants": 0, + "category_id": category_id, + "daily_mode": "true" + }) + + response = await self.client.post( + self._get_url("bookings_availability_pricing"), + json=payload, + auth=(self.username, self.password) + ) + + # Handle rate limiting with exponential backoff + if response.status_code == 429: + if retry_count < 3: + wait_time = 60 * (retry_count + 1) + logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry") + await asyncio.sleep(wait_time) + return await self._fetch_rates_with_min_stay( + category_id, from_date, to_date, extended_to, guests_adults, guests_children, retry_count + 1 + ) + else: + raise NewbookRatesError(f"Rate limited after 3 retries") + + if response.status_code != 200: + raise NewbookRatesError(f"API error {response.status_code}: {response.text}") + + data = response.json() + + if not data.get("success"): + raise NewbookRatesError(f"API returned failure even with extended stay: {data.get('message')}") + + # Parse tariffs but only return dates in our original range + return self._parse_tariffs(data, from_date, to_date) + + def _parse_tariffs(self, data: dict, from_date: date, to_date: date) -> List[Dict]: + """ + Parse tariffs from API response. + + With daily_mode=true, the API returns tariffs_quoted as a dict keyed by date. + Falls back to tariffs_available average if tariffs_quoted not available. + + Args: + data: Full API response + from_date: Start date to include + to_date: End date to include + + Returns: + List of dicts with {date, gross_rate, net_rate, tariffs_data} + tariffs_data contains all available tariff options for rate report + """ + rates = [] + tariffs_quoted = {} + fallback_rate = None + inventory_items = [] + all_tariffs_available = [] # Store all tariff options for reporting + + # Find tariffs data in the response + if isinstance(data.get("data"), dict): + for key in data["data"].keys(): + # Category IDs are numeric strings + if key.isdigit() or key.isnumeric(): + cat_data = data["data"][key] + if isinstance(cat_data, dict): + tariffs_available = cat_data.get("tariffs_available", []) + all_tariffs_available = tariffs_available # Capture all options + if tariffs_available: + first_tariff = tariffs_available[0] + # tariffs_quoted is a dict keyed by date string + tariffs_quoted = first_tariff.get("tariffs_quoted", {}) + # inventory_items are at tariff level (total for whole stay) + inventory_items = first_tariff.get("inventory_items", []) + # Fallback average rate + fallback_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0)) + break + + # If we have per-night tariffs_quoted dict, parse it + if isinstance(tariffs_quoted, dict) and tariffs_quoted: + num_nights = len(tariffs_quoted) + + # Calculate per-night inventory item amount for items already included in tariff + included_inventory_per_night = Decimal('0') + for item in inventory_items: + already_included = item.get('amount_already_included_in_tariff_total', '') + if str(already_included).lower() == 'true': + total_amount = Decimal(str(item.get('amount', 0) or 0)) + included_inventory_per_night += total_amount / num_nights + + for date_str, tariff in tariffs_quoted.items(): + try: + stay_date = date.fromisoformat(date_str) + except ValueError: + continue + + # Only include dates in our range + if stay_date < from_date or stay_date > to_date: + continue + + gross_rate = Decimal(str(tariff.get('amount', 0) or 0)) + # Net = (gross - included_inventory_per_night) / (1 + VAT) + gross_after_inventory = gross_rate - included_inventory_per_night + net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01')) + + # Build tariffs_data with day-specific rates + tariffs_data = self._build_tariffs_summary(all_tariffs_available, stay_date) + + rates.append({ + 'date': stay_date, + 'gross_rate': float(gross_rate), + 'net_rate': float(net_rate), + 'tariffs_data': tariffs_data + }) + + return rates + + # Fallback: use average_nightly_tariff and apply to all dates + if fallback_rate and fallback_rate > 0: + net_rate = (fallback_rate / (1 + self.vat_rate)).quantize(Decimal('0.01')) + current_date = from_date + while current_date <= to_date: + # Build tariffs_data (no day-specific rates in fallback) + tariffs_data = self._build_tariffs_summary(all_tariffs_available, current_date) + rates.append({ + 'date': current_date, + 'gross_rate': float(fallback_rate), + 'net_rate': float(net_rate), + 'tariffs_data': tariffs_data + }) + current_date += timedelta(days=1) + return rates + + logger.warning(f"No rate found in response for {from_date} to {to_date}") + return rates + + def _build_tariffs_summary(self, tariffs_available: list, for_date: date = None) -> dict: + """ + Build a summary of all available tariff options for rate reporting. + + Args: + tariffs_available: List of tariff dicts from API response + for_date: Optional specific date to extract day-specific rates + + Returns: + Dict with tariff summaries - tariff_count and list of tariff details + """ + if not tariffs_available: + return {} + + summary = { + 'tariff_count': len(tariffs_available), + 'tariffs': [] + } + + date_key = for_date.isoformat() if for_date else None + + for idx, tariff in enumerate(tariffs_available): + # Get day-specific rate from tariffs_quoted if available + day_rate = None + if date_key: + tariffs_quoted = tariff.get('tariffs_quoted', {}) + if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted: + day_quote = tariffs_quoted[date_key] + if isinstance(day_quote, dict): + day_rate = float(day_quote.get('amount', 0) or 0) + else: + day_rate = float(day_quote or 0) + + # API uses tariff_label for the name + message = tariff.get('tariff_message', '') + + # Extract minimum stay from message or dedicated field + min_stay = tariff.get('minimum_nights', None) + if min_stay is None and message: + # Try to parse from message like "Minimum 2 nights" or "2 Night Minimum" + import re + match = re.search(r'(\d+)\s*[Nn]ight\s*[Mm]inimum', message) + if not match: + match = re.search(r'[Mm]inimum\s+(\d+)\s*(?:night|period)', message) + if match: + min_stay = int(match.group(1)) + + # Extract advance booking requirement from message + min_advance_days = None + if message: + import re + advance_match = re.search(r'(\d+)\s*days?\s*in\s*advance', message, re.IGNORECASE) + if advance_match: + min_advance_days = int(advance_match.group(1)) + + tariff_info = { + 'name': tariff.get('tariff_label', 'Unknown'), + 'description': tariff.get('tariff_short_description', ''), + 'rate': day_rate, # Day-specific rate (None if not available) + 'average_nightly': float(tariff.get('average_nightly_tariff', 0) or 0), + 'success': str(tariff.get('tariff_success', False)).lower() in ('true', '1'), + 'message': message, + 'sort_order': idx, # Preserve Newbook ordering + 'min_stay': min_stay, # Minimum nights required (if any) + 'min_advance_days': min_advance_days, # Advance booking requirement (if any) + } + + summary['tariffs'].append(tariff_info) + + return summary + + def _parse_all_categories_tariffs(self, data: dict, for_date: date) -> Dict[str, List[Dict]]: + """ + Parse tariffs from API response for ALL categories. + + When category_id is omitted, data.data contains category IDs as keys, + each with their own tariffs_available. + + Args: + data: Full API response + for_date: The date we queried + + Returns: + Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]} + """ + results: Dict[str, List[Dict]] = {} + + if not isinstance(data.get("data"), dict): + return results + + for key, cat_data in data["data"].items(): + # Category IDs are numeric strings like "1", "8", etc. + if not (key.isdigit() or str(key).isnumeric()): + continue + + if not isinstance(cat_data, dict): + continue + + category_id = str(key) + tariffs_available = cat_data.get("tariffs_available", []) + + if not tariffs_available: + continue + + # Get the first (best) tariff for gross/net calculation + first_tariff = tariffs_available[0] + tariffs_quoted = first_tariff.get("tariffs_quoted", {}) + inventory_items = first_tariff.get("inventory_items", []) + + # Get rate for this date + date_key = for_date.isoformat() + gross_rate = Decimal('0') + net_rate = Decimal('0') + + if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted: + day_tariff = tariffs_quoted[date_key] + gross_rate = Decimal(str(day_tariff.get('amount', 0) or 0)) + + # Calculate included inventory per night + included_inventory = Decimal('0') + for item in inventory_items: + already_included = item.get('amount_already_included_in_tariff_total', '') + if str(already_included).lower() == 'true': + included_inventory += Decimal(str(item.get('amount', 0) or 0)) + + gross_after_inventory = gross_rate - included_inventory + net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01')) + else: + # Fallback to average + gross_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0)) + net_rate = (gross_rate / (1 + self.vat_rate)).quantize(Decimal('0.01')) + + # Build tariffs summary for all options + tariffs_data = self._build_tariffs_summary(tariffs_available, for_date) + + results[category_id] = [{ + 'date': for_date, + 'gross_rate': float(gross_rate), + 'net_rate': float(net_rate), + 'tariffs_data': tariffs_data + }] + + return results + diff --git a/backend/services/reconciliation_service.py b/backend/services/reconciliation_service.py new file mode 100644 index 0000000..babad82 --- /dev/null +++ b/backend/services/reconciliation_service.py @@ -0,0 +1,531 @@ +""" +Reconciliation Business Logic Service + +Ported from the WordPress plugin hotel-cashup-reconciliation. +Handles payment categorization, variance calculation, and report aggregation. +""" +import re +import logging +from datetime import date, datetime +from typing import List, Dict, Optional, Any +from decimal import Decimal + +logger = logging.getLogger(__name__) + + +# ============================================ +# PAYMENT CATEGORIZATION +# ============================================ + +def identify_card_type(transaction: dict) -> str: + """ + Categorize a Newbook transaction into a card type. + + Ported from PHP: HCR_Newbook_API::identify_card_type() + + Returns: 'cash', 'visa_mc', 'amex', 'bacs', or 'other' + """ + # Handle both old 'type' field and new 'payment_type' field + ptype = (transaction.get('payment_type') or transaction.get('type') or '').lower() + method = (transaction.get('method') or '').lower() + transaction_method = (transaction.get('payment_transaction_method') or '').lower() + combined = f"{ptype} {method}" + + # Cash must be identified first + if 'cash' in combined: + return 'cash' + + # BACS/Bank transfers + if any(kw in combined for kw in ['eft', 'bacs', 'bank transfer', 'banktransfer', 'direct debit']): + return 'bacs' + + # Amex - must be explicitly identified + if 'amex' in combined or 'american express' in combined: + return 'amex' + + # Visa/Mastercard - must be explicitly identified + if any(kw in combined for kw in ['visa', 'mastercard', 'master card', 'mc']): + return 'visa_mc' + + # For gateway/automated transactions, default to visa_mc (most common card type) + if transaction_method in ('automated', 'gateway', 'cc_gateway'): + if any(kw in combined for kw in ['card', 'credit', 'debit']): + return 'visa_mc' + # Gateway transactions are almost always card payments + return 'visa_mc' + + if ptype: + logger.warning(f"Unidentified payment type: '{ptype}' (method: '{method}', transaction_method: '{transaction_method}')") + + return 'other' + + +def convert_newbook_amount(amount: float) -> float: + """ + Convert Newbook amount from accounting perspective to revenue perspective. + + In Newbook: payments are negative, refunds are positive. + For reconciliation: payments should be positive, refunds negative. + """ + return -float(amount) + + +def process_transaction(transaction: dict) -> Optional[dict]: + """ + Process a single Newbook transaction into a payment record. + + Returns None if the transaction should be skipped. + """ + item_type = transaction.get('item_type', '') + + # Only process payments, refunds, and voided transactions + if item_type not in ('payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided'): + return None + + # Skip balance transfers (system-generated, always net to zero) + payment_type = transaction.get('payment_type', '') + if payment_type == 'balance_transfer': + return None + + amount = convert_newbook_amount(float(transaction.get('item_amount', 0))) + + return { + 'payment_id': transaction.get('item_id', ''), + 'booking_id': str(transaction.get('booking_id', '')), + 'guest_name': transaction.get('account_for_name', ''), + 'payment_date': transaction.get('item_date', ''), + 'payment_type': payment_type, + 'payment_method': '', + 'transaction_method': transaction.get('payment_transaction_method', 'manual'), + 'card_type': identify_card_type(transaction), + 'amount': amount, + 'tendered': 0, + 'processed_by': '', + 'item_type': item_type, + 'description': transaction.get('item_description', ''), + } + + +def categorize_payments(raw_transactions: List[dict]) -> List[dict]: + """ + Process raw Newbook API transactions into categorized payment records. + + Filters out non-payment items and balance transfers, converts amounts, + and identifies card types. + """ + payments = [] + for transaction in raw_transactions: + payment = process_transaction(transaction) + if payment is not None: + payments.append(payment) + return payments + + +def calculate_payment_totals(payments: List[dict]) -> dict: + """ + Calculate payment totals by reconciliation category. + + Ported from PHP: HCR_Newbook_API::calculate_payment_totals() + + Categories: + - cash: Physical cash payments + - manual_visa_mc: Card machine (PDQ) Visa/MC payments + - manual_amex: Card machine (PDQ) Amex payments + - gateway_visa_mc: Online/gateway Visa/MC payments + - gateway_amex: Online/gateway Amex payments + - bacs: Bank transfers + """ + totals = { + 'cash': 0.0, + 'manual_visa_mc': 0.0, + 'manual_amex': 0.0, + 'gateway_visa_mc': 0.0, + 'gateway_amex': 0.0, + 'bacs': 0.0 + } + + for payment in payments: + amount = float(payment.get('amount', 0)) + transaction_method = (payment.get('transaction_method') or '').lower() + card_type = payment.get('card_type', '') + + if card_type == 'cash': + totals['cash'] += amount + elif card_type == 'bacs': + totals['bacs'] += amount + elif transaction_method == 'manual': + if card_type == 'amex': + totals['manual_amex'] += amount + elif card_type == 'visa_mc': + totals['manual_visa_mc'] += amount + elif transaction_method in ('automated', 'gateway', 'cc_gateway'): + if card_type == 'amex': + totals['gateway_amex'] += amount + elif card_type == 'visa_mc': + totals['gateway_visa_mc'] += amount + + # Round all totals to 2 decimal places + return {k: round(v, 2) for k, v in totals.items()} + + +# ============================================ +# TILL SYSTEM TRANSACTIONS +# ============================================ + +def parse_till_transactions(raw_transactions: List[dict]) -> dict: + """ + Parse till system transactions from Newbook transaction data. + Extracts transactions where method is "manual" and item_description follows: + "Ticket: {number} - {payment_type}" + + Returns dict grouped by payment type with count and total. + """ + till_payments = {} + ticket_pattern = re.compile(r'^Ticket:\s*(\d+)\s*-\s*(.+)$', re.IGNORECASE) + + for transaction in raw_transactions: + item_type = transaction.get('item_type', '') + if item_type not in ('payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided'): + continue + + method = transaction.get('payment_transaction_method', '') + if method != 'manual': + continue + + description = transaction.get('item_description', '') + match = ticket_pattern.match(description) + if not match: + continue + + payment_type = match.group(2).strip() + + # Skip balance transfers + if payment_type == 'balance_transfer': + continue + + amount = convert_newbook_amount(float(transaction.get('item_amount', 0))) + if amount == 0: + continue + + if payment_type not in till_payments: + till_payments[payment_type] = { + 'payment_type': payment_type, + 'quantity': 0, + 'total': 0.0, + 'transactions': [] + } + + till_payments[payment_type]['quantity'] += 1 + till_payments[payment_type]['total'] += amount + till_payments[payment_type]['transactions'].append({ + 'ticket': match.group(1), + 'amount': amount, + 'item_type': item_type + }) + + # Round totals + for key in till_payments: + till_payments[key]['total'] = round(till_payments[key]['total'], 2) + + return till_payments + + +# ============================================ +# TRANSACTION BREAKDOWN +# ============================================ + +def build_transaction_breakdown(payments: List[dict]) -> dict: + """ + Group processed payments into a transaction breakdown for display. + + Groups: + - reception_manual: Manual payments at reception (PDQ entered by staff) + - reception_gateway: Automated/gateway payments at reception + - restaurant_bar: Payments from till system (description contains "Ticket:") + + Each group is further sub-grouped by payment type label. + Returns dict of groups, each containing sub-groups with transaction lists. + """ + ticket_pattern = re.compile(r'Ticket:\s*(\d+)\s*-\s*(.+)', re.IGNORECASE) + + reception_manual: Dict[str, list] = {} + reception_gateway: Dict[str, list] = {} + restaurant_bar: Dict[str, list] = {} + + for p in payments: + transaction_method = (p.get('transaction_method') or '').lower() + card_type = p.get('card_type', 'other') + payment_type = p.get('payment_type', '') + item_type = p.get('item_type', '') + amount = float(p.get('amount', 0)) + guest_name = p.get('guest_name', '') + payment_date = p.get('payment_date', '') + description = p.get('description', '') + is_voided = item_type in ('payments_voided', 'refunds_voided') + + # Extract time from date string + time_str = '' + if payment_date and ' ' in str(payment_date): + time_str = str(payment_date).split(' ')[1][:5] # HH:MM + + # Determine display type label + type_label = payment_type.title() if payment_type else 'Other' + if card_type == 'cash': + type_label = 'Cash' + elif card_type == 'bacs': + type_label = 'BACS' + elif card_type == 'amex': + type_label = 'Amex' + elif card_type == 'visa_mc': + type_label = 'Card' + + # Check for restaurant/bar till ticket pattern in description + ticket_match = ticket_pattern.search(description) if description else None + details = guest_name + if ticket_match: + ticket_num = ticket_match.group(1) + ticket_type = ticket_match.group(2).strip() + details = f"Ticket #{ticket_num} - {ticket_type}" + type_label = ticket_type.title() if ticket_type else type_label + + entry = { + 'time': time_str, + 'type': type_label, + 'details': details, + 'amount': round(amount, 2), + 'is_voided': is_voided, + 'is_refund': item_type in ('refunds_raised', 'refunds_voided'), + 'item_type': item_type, + 'payment_id': p.get('payment_id', ''), + 'booking_id': p.get('booking_id', ''), + } + + # Route to appropriate group + if ticket_match: + if type_label not in restaurant_bar: + restaurant_bar[type_label] = [] + restaurant_bar[type_label].append(entry) + elif transaction_method in ('automated', 'gateway', 'cc_gateway'): + if type_label not in reception_gateway: + reception_gateway[type_label] = [] + reception_gateway[type_label].append(entry) + else: + # Manual and default go to reception_manual + if type_label not in reception_manual: + reception_manual[type_label] = [] + reception_manual[type_label].append(entry) + + # Calculate subtotals for each group + def with_subtotals(group: Dict[str, list]) -> dict: + result = {} + group_total = 0.0 + group_count = 0 + for key, transactions in group.items(): + subtotal = round(sum(t['amount'] for t in transactions), 2) + result[key] = { + 'transactions': transactions, + 'subtotal': subtotal, + 'count': len(transactions), + } + group_total += subtotal + group_count += len(transactions) + return {'groups': result, 'total': round(group_total, 2), 'count': group_count} + + return { + 'reception_manual': with_subtotals(reception_manual), + 'reception_gateway': with_subtotals(reception_gateway), + 'restaurant_bar': with_subtotals(restaurant_bar), + } + + +# ============================================ +# VARIANCE CALCULATION +# ============================================ + +def calculate_variance(banked: float, reported: float) -> float: + """ + Calculate variance between banked (manual count) and reported (Newbook). + + Positive = over (extra cash/payments found) + Negative = short (missing cash/payments) + """ + return round(banked - reported, 2) + + +def get_variance_status(variance: float, threshold: float = 10.0) -> str: + """ + Determine variance status for display. + + Returns: 'balanced', 'over', or 'short' + """ + if abs(variance) <= threshold: + return 'balanced' + elif variance > 0: + return 'over' + else: + return 'short' + + +def build_reconciliation_rows( + banked_totals: dict, + reported_totals: dict +) -> List[dict]: + """ + Build reconciliation comparison rows for each category. + + banked_totals: From manual entry (cash count + card machines) + reported_totals: From Newbook payments + + Returns list of rows with category, banked, reported, variance. + """ + categories = [ + ('Cash', 'cash'), + ('PDQ Visa/MC', 'manual_visa_mc'), + ('PDQ Amex', 'manual_amex'), + ('Gateway Visa/MC', 'gateway_visa_mc'), + ('Gateway Amex', 'gateway_amex'), + ('BACS', 'bacs'), + ] + + rows = [] + for label, key in categories: + banked = banked_totals.get(key, 0.0) + reported = reported_totals.get(key, 0.0) + variance = calculate_variance(banked, reported) + rows.append({ + 'category': label, + 'key': key, + 'banked_amount': round(banked, 2), + 'reported_amount': round(reported, 2), + 'variance': variance, + 'status': get_variance_status(variance) + }) + + return rows + + +# ============================================ +# MULTI-DAY REPORT AGGREGATION +# ============================================ + +def build_multi_day_report( + cash_ups: List[dict], + payment_totals_by_date: Dict[str, dict], + daily_stats: List[dict], + sales_breakdown: List[dict], +) -> dict: + """ + Build multi-day report with 3 tables: + 1. Daily Reconciliation Summary (banked vs reported by category per day) + 2. Sales Breakdown (GL categories vs days) + 3. Occupancy Stats (rooms, people, rates per day) + + Returns dict with three table datasets. + """ + # Table 1: Daily Reconciliation Summary + recon_summary = [] + total_banked = { + 'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0, + 'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0 + } + total_reported = { + 'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0, + 'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0 + } + + for cash_up in cash_ups: + date_str = cash_up['session_date'] + reported = payment_totals_by_date.get(date_str, {}) + + # Build banked totals from cash_up data + banked = { + 'cash': float(cash_up.get('total_cash_counted', 0)), + 'manual_visa_mc': 0.0, + 'manual_amex': 0.0, + 'gateway_visa_mc': 0.0, + 'gateway_amex': 0.0, + 'bacs': 0.0 + } + + # Card machine totals from cash_up + for card in cash_up.get('card_machines', []): + machine_name = card.get('machine_name', '').lower() + banked['manual_visa_mc'] += float(card.get('visa_mc_amount', 0)) + banked['manual_amex'] += float(card.get('amex_amount', 0)) + + # Reported amounts from Newbook + reported_amounts = { + 'cash': float(reported.get('cash', 0)), + 'manual_visa_mc': float(reported.get('manual_visa_mc', 0)), + 'manual_amex': float(reported.get('manual_amex', 0)), + 'gateway_visa_mc': float(reported.get('gateway_visa_mc', 0)), + 'gateway_amex': float(reported.get('gateway_amex', 0)), + 'bacs': float(reported.get('bacs', 0)), + } + + # Calculate row variances + row_variance = {} + for key in banked: + row_variance[key] = round(banked[key] - reported_amounts[key], 2) + total_banked[key] += banked[key] + total_reported[key] += reported_amounts[key] + + recon_summary.append({ + 'date': date_str, + 'status': cash_up.get('status', ''), + 'banked': {k: round(v, 2) for k, v in banked.items()}, + 'reported': {k: round(v, 2) for k, v in reported_amounts.items()}, + 'variance': row_variance, + 'banked_total': round(sum(banked.values()), 2), + 'reported_total': round(sum(reported_amounts.values()), 2), + }) + + # Totals row + total_variance = {} + for key in total_banked: + total_variance[key] = round(total_banked[key] - total_reported[key], 2) + + recon_totals = { + 'banked': {k: round(v, 2) for k, v in total_banked.items()}, + 'reported': {k: round(v, 2) for k, v in total_reported.items()}, + 'variance': total_variance, + 'banked_total': round(sum(total_banked.values()), 2), + 'reported_total': round(sum(total_reported.values()), 2), + } + + # Table 2: Sales Breakdown + sales_by_date = {} + all_categories = set() + for row in sales_breakdown: + d = row['business_date'] + cat = row['category'] + amt = float(row['net_amount']) + all_categories.add(cat) + if d not in sales_by_date: + sales_by_date[d] = {} + sales_by_date[d][cat] = amt + + # Table 3: Occupancy Stats + occupancy_data = [] + for stat in daily_stats: + occupancy_data.append({ + 'date': stat['business_date'], + 'gross_sales': float(stat.get('gross_sales', 0)), + 'rooms_sold': int(stat.get('rooms_sold', 0)), + 'total_people': int(stat.get('total_people', 0)), + 'debtors_creditors': float(stat.get('debtors_creditors_balance', 0)), + }) + + return { + 'reconciliation_summary': { + 'rows': recon_summary, + 'totals': recon_totals, + }, + 'sales_breakdown': { + 'categories': sorted(list(all_categories)), + 'by_date': sales_by_date, + }, + 'occupancy': { + 'rows': occupancy_data, + } + } diff --git a/backend/services/resos_client.py b/backend/services/resos_client.py new file mode 100644 index 0000000..1c09d13 --- /dev/null +++ b/backend/services/resos_client.py @@ -0,0 +1,177 @@ +""" +Resos API Client + +CRITICAL: This client is READ-ONLY. All methods use GET requests only. +NO data is written, modified, or deleted in Resos. +Data flows ONE WAY: Resos → Local Database +""" +import os +import httpx +import base64 +import asyncio +import logging +from datetime import date, timedelta +from typing import List, Optional + +logger = logging.getLogger(__name__) + + +class ResosAPIError(Exception): + """Custom exception for Resos API errors""" + pass + + +class ResosClient: + """ + Async client for Resos API + + Rate limiting: ~60 requests/min, using 1s delay between requests + Pagination: Uses skip/limit, max 100 per request + Date filtering: Uses fromDateTime/toDateTime + """ + + BASE_URL = "https://api.resos.com/v1" + + def __init__(self, api_key: str = None): + # Use provided credentials or fall back to environment variables + self.api_key = api_key or os.getenv("RESOS_API_KEY") + if self.api_key: + # HTTP Basic Auth: base64_encode(api_key + ':') + self.auth_header = f"Basic {base64.b64encode(f'{self.api_key}:'.encode()).decode()}" + else: + self.auth_header = None + logger.warning("Resos API key not configured") + + @classmethod + async def from_db(cls, db): + """Create client with credentials from database""" + from api.config import _get_config_value + + api_key = await _get_config_value(db, "resos_api_key") + return cls(api_key=api_key) + + async def __aenter__(self): + self.client = httpx.AsyncClient(timeout=30.0) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.client.aclose() + + async def test_connection(self) -> bool: + """Test API connection by fetching opening hours""" + try: + response = await self.client.get( + f"{self.BASE_URL}/openingHours", + headers={"Authorization": self.auth_header} + ) + return response.status_code == 200 + except Exception as e: + logger.error(f"Resos connection test failed: {e}") + return False + + async def get_bookings( + self, + from_date: date, + to_date: date + ) -> List[dict]: + """ + Fetch bookings for date range with pagination and rate limiting. + + Returns list of booking objects with structure: + { + '_id': 'booking_id', + 'date': '2026-01-20', + 'time': '19:00', + 'people': 2, + 'status': 'confirmed', + 'source': 'website', + 'guest': {...}, + 'customFields': [...], + 'restaurantNotes': [...] + } + """ + all_bookings = [] + offset = 0 + + from_datetime = f"{from_date}T00:00:00" + to_datetime = f"{to_date}T23:59:59" + + while True: + logger.info(f"Fetching Resos bookings: {from_date} to {to_date} (offset: {offset})") + + response = await self.client.get( + f"{self.BASE_URL}/bookings", + headers={"Authorization": self.auth_header}, + params={ + "fromDateTime": from_datetime, + "toDateTime": to_datetime, + "limit": 100, + "skip": offset + } + ) + + if response.status_code != 200: + error_body = response.text + logger.error(f"Resos API error {response.status_code}: {error_body}") + raise ResosAPIError(f"Failed to fetch bookings: {response.status_code} - {error_body}") + + data = response.json() + page_bookings = data if isinstance(data, list) else [] + + if not page_bookings: + break + + all_bookings.extend(page_bookings) + logger.info(f"Fetched {len(page_bookings)} bookings (offset {offset})") + + # If we got fewer than the limit, we've reached the end + if len(page_bookings) < 100: + break + + offset += 100 + + # Rate limiting: 1 request per second + await asyncio.sleep(1) + + logger.info(f"Total bookings fetched: {len(all_bookings)}") + return all_bookings + + async def get_opening_hours(self) -> List[dict]: + """ + Fetch opening hours/service periods + + Returns list of opening hour objects: + { + '_id': 'opening_hour_id', + 'name': 'Dinner', + 'startTime': '18:00', + 'endTime': '22:00', + 'days': ['monday', 'tuesday', 'wednesday', ...] + } + """ + response = await self.client.get( + f"{self.BASE_URL}/openingHours", + headers={"Authorization": self.auth_header}, + params={"showDeleted": "false", "onlySpecial": "false"} + ) + + if response.status_code != 200: + raise ResosAPIError(f"Failed to fetch opening hours: {response.status_code}") + + return response.json() + + async def get_custom_field_definitions(self) -> List[dict]: + """ + Fetch custom field definitions + + Returns field definitions with choice options for dropdowns/radios + """ + response = await self.client.get( + f"{self.BASE_URL}/customFields", + headers={"Authorization": self.auth_header} + ) + + if response.status_code != 200: + raise ResosAPIError(f"Failed to fetch custom fields: {response.status_code}") + + return response.json() diff --git a/backend/services/scraper_backends/__init__.py b/backend/services/scraper_backends/__init__.py new file mode 100644 index 0000000..d54c86a --- /dev/null +++ b/backend/services/scraper_backends/__init__.py @@ -0,0 +1,20 @@ +""" +Scraper backends for booking.com rate scraping. + +Provides pluggable backends to allow switching between: +- playwright_local: Direct Playwright (default) +- playwright_proxy: Playwright with rotating proxies (future) +- apify_backend: Apify scraping service (future) +""" + +from .base import ScraperBackend, ScraperResult, HotelData, RateData, AvailabilityStatus +from .playwright_local import PlaywrightLocalBackend + +__all__ = [ + 'ScraperBackend', + 'ScraperResult', + 'HotelData', + 'RateData', + 'AvailabilityStatus', + 'PlaywrightLocalBackend', +] diff --git a/backend/services/scraper_backends/base.py b/backend/services/scraper_backends/base.py new file mode 100644 index 0000000..3d71b0d --- /dev/null +++ b/backend/services/scraper_backends/base.py @@ -0,0 +1,152 @@ +""" +Abstract base class for booking.com scraper backends. + +Defines the interface that all scraper backends must implement, +allowing easy switching between local Playwright, proxied Playwright, +or external services like Apify. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import date +from decimal import Decimal +from typing import List, Optional, Dict, Any +from enum import Enum + + +class AvailabilityStatus(str, Enum): + """Availability status for a hotel rate.""" + AVAILABLE = 'available' # Rate found, bookable + SOLD_OUT = 'sold_out' # Hotel shows no availability + NO_DATA = 'no_data' # Couldn't determine (scraper issue) + + +@dataclass +class RateData: + """Rate data for a single hotel on a single date.""" + hotel_id: Optional[str] = None # Our internal hotel_id (filled after DB lookup) + booking_com_id: str = '' # Hotel ID from booking.com + rate_date: date = None + availability_status: AvailabilityStatus = AvailabilityStatus.NO_DATA + rate_gross: Optional[Decimal] = None + currency: str = 'GBP' + room_type: Optional[str] = None + breakfast_included: Optional[bool] = None + free_cancellation: Optional[bool] = None + no_prepayment: Optional[bool] = None + rooms_left: Optional[int] = None # "Only X rooms left" + available_qty: Optional[int] = None # Future: from hotel page dropdown + + +@dataclass +class HotelData: + """Hotel data discovered from search results.""" + booking_com_id: str + name: str + booking_com_url: Optional[str] = None + star_rating: Optional[Decimal] = None + review_score: Optional[Decimal] = None + review_count: Optional[int] = None + + +@dataclass +class ScraperResult: + """Result from a scraping operation.""" + success: bool + blocked: bool = False # True if anti-scrape blocking detected + block_reason: Optional[str] = None # CAPTCHA, rate limit, etc. + hotels: List[HotelData] = field(default_factory=list) + rates: List[RateData] = field(default_factory=list) + error_message: Optional[str] = None + page_content_sample: Optional[str] = None # For debugging + + +class ScraperBackend(ABC): + """ + Abstract base class for scraper backends. + + All backends must implement these methods to provide a consistent + interface for the main booking_scraper.py service. + """ + + # Common block detection signals + BLOCK_SIGNALS = [ + 'captcha', + 'unusual traffic', + 'access denied', + 'please verify', + 'too many requests', + 'are you a robot', + 'verify you are human', + 'security check', + ] + + @abstractmethod + async def scrape_location_search( + self, + location: str, + check_in: date, + check_out: date, + adults: int = 2, + pages: int = 2 + ) -> ScraperResult: + """ + Scrape booking.com location search results. + + Args: + location: Location name (e.g., "Bowness-on-Windermere") + check_in: Check-in date + check_out: Check-out date (typically check_in + 1 for single night) + adults: Number of adults for search + pages: Number of search result pages to scrape + + Returns: + ScraperResult with hotels and rates found + """ + pass + + @abstractmethod + async def scrape_hotel_page( + self, + hotel_url: str, + check_in: date, + check_out: date, + adults: int = 2 + ) -> ScraperResult: + """ + Scrape an individual hotel page for detailed rates. + + Future expansion - not used in initial implementation. + Will provide available_qty from room dropdowns. + + Args: + hotel_url: Full booking.com URL for the hotel + check_in: Check-in date + check_out: Check-out date + adults: Number of adults + + Returns: + ScraperResult with detailed rate information + """ + pass + + @abstractmethod + async def close(self): + """Clean up any resources (browser instances, etc.).""" + pass + + def detect_blocking(self, page_content: str) -> tuple[bool, Optional[str]]: + """ + Check if page content shows anti-scrape response. + + Args: + page_content: HTML content of the page + + Returns: + Tuple of (is_blocked, reason) + """ + content_lower = page_content.lower() + for signal in self.BLOCK_SIGNALS: + if signal in content_lower: + return True, signal + return False, None diff --git a/backend/services/scraper_backends/playwright_local.py b/backend/services/scraper_backends/playwright_local.py new file mode 100644 index 0000000..dfe6540 --- /dev/null +++ b/backend/services/scraper_backends/playwright_local.py @@ -0,0 +1,401 @@ +""" +Local Playwright backend for booking.com scraping. + +Uses Playwright with Chromium to scrape search results. +No proxy - direct connection. Suitable for low-volume scraping. +""" + +import asyncio +import logging +import random +import re +from datetime import date +from decimal import Decimal, InvalidOperation +from typing import List, Optional +from urllib.parse import urlencode + +from playwright.async_api import async_playwright, Browser, BrowserContext, Page + +from .base import ( + ScraperBackend, + ScraperResult, + HotelData, + RateData, + AvailabilityStatus +) + +logger = logging.getLogger(__name__) + + +class PlaywrightLocalBackend(ScraperBackend): + """ + Local Playwright backend using Chromium. + + Features: + - Rotates user agents + - Random delays between requests + - Mimics human scroll behavior + - Uses data-testid selectors for stability + """ + + USER_AGENTS = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0", + ] + + def __init__(self, proxy_config: dict = None): + """ + Initialize the backend. + + Args: + proxy_config: Optional proxy configuration (for future use) + """ + self.proxy_config = proxy_config + self._playwright = None + self._browser: Optional[Browser] = None + + async def _ensure_browser(self) -> Browser: + """Ensure browser is running, start if needed.""" + if self._browser is None or not self._browser.is_connected(): + self._playwright = await async_playwright().start() + self._browser = await self._playwright.chromium.launch( + headless=True, + args=[ + '--disable-blink-features=AutomationControlled', + '--no-sandbox', + '--disable-dev-shm-usage', + ] + ) + return self._browser + + async def _create_context(self) -> BrowserContext: + """Create a new browser context with random user agent.""" + browser = await self._ensure_browser() + context = await browser.new_context( + user_agent=random.choice(self.USER_AGENTS), + viewport={'width': 1920, 'height': 1080}, + locale='en-GB', + timezone_id='Europe/London', + ) + return context + + def _build_search_url( + self, + location: str, + check_in: date, + check_out: date, + adults: int, + offset: int = 0 + ) -> str: + """Build booking.com search URL with parameters.""" + params = { + 'ss': location, + 'checkin': check_in.isoformat(), + 'checkout': check_out.isoformat(), + 'group_adults': adults, + 'no_rooms': 1, + 'group_children': 0, + } + if offset > 0: + params['offset'] = offset + + return f"https://www.booking.com/searchresults.en-gb.html?{urlencode(params)}" + + def _parse_price(self, price_text: str) -> Optional[Decimal]: + """Parse price from text like '£150' or 'GBP 150'.""" + if not price_text: + return None + # Remove currency symbols and extract number + cleaned = re.sub(r'[£$€,\s]', '', price_text) + # Find first number (including decimals) + match = re.search(r'[\d,]+(?:\.\d{2})?', cleaned) + if match: + try: + return Decimal(match.group().replace(',', '')) + except InvalidOperation: + return None + return None + + def _extract_hotel_id(self, url: str) -> Optional[str]: + """Extract hotel ID from booking.com URL.""" + if not url: + return None + # URL format: /hotel/gb/hotel-name.en-gb.html or ?dest_id=123 + # Try to extract from URL path + match = re.search(r'/hotel/[a-z]{2}/([^/]+)\.', url) + if match: + return match.group(1) + # Try dest_id parameter + match = re.search(r'dest_id=(-?\d+)', url) + if match: + return match.group(1) + return None + + async def _human_like_scroll(self, page: Page): + """Simulate human-like scrolling behavior.""" + # Scroll down in increments + for _ in range(3): + await page.mouse.wheel(0, random.randint(300, 600)) + await asyncio.sleep(random.uniform(0.3, 0.8)) + + async def _extract_search_results(self, page: Page, rate_date: date) -> tuple[List[HotelData], List[RateData]]: + """Extract hotel and rate data from search results page.""" + hotels = [] + rates = [] + + # Wait for property cards - booking.com uses data-testid + try: + await page.wait_for_selector('[data-testid="property-card"]', timeout=15000) + except Exception as e: + logger.warning(f"No property cards found: {e}") + return hotels, rates + + # Get all property cards + cards = await page.query_selector_all('[data-testid="property-card"]') + logger.info(f"Found {len(cards)} property cards") + + for card in cards: + try: + hotel = HotelData(booking_com_id='', name='') + rate = RateData(rate_date=rate_date) + + # Hotel name + name_el = await card.query_selector('[data-testid="title"]') + if name_el: + hotel.name = (await name_el.inner_text()).strip() + + if not hotel.name: + continue # Skip if no name found + + # Hotel URL and ID + link_el = await card.query_selector('[data-testid="title-link"]') + if link_el: + hotel.booking_com_url = await link_el.get_attribute('href') + hotel.booking_com_id = self._extract_hotel_id(hotel.booking_com_url) or '' + + rate.booking_com_id = hotel.booking_com_id + + # Star rating - look for star icons or rating text + stars_el = await card.query_selector('[data-testid="rating-stars"]') + if stars_el: + stars_text = await stars_el.get_attribute('aria-label') or '' + match = re.search(r'(\d+)', stars_text) + if match: + hotel.star_rating = Decimal(match.group(1)) + + # Review score + score_el = await card.query_selector('[data-testid="review-score"]') + if score_el: + score_text = await score_el.inner_text() + match = re.search(r'([\d.]+)', score_text) + if match: + try: + hotel.review_score = Decimal(match.group(1)) + except InvalidOperation: + pass + + # Check for no availability message FIRST + no_avail_el = await card.query_selector('[data-testid="availability-message"]') + if no_avail_el: + avail_text = (await no_avail_el.inner_text()).lower() + if 'no availability' in avail_text or 'sold out' in avail_text: + rate.availability_status = AvailabilityStatus.SOLD_OUT + hotels.append(hotel) + rates.append(rate) + continue + + # Price + price_el = await card.query_selector('[data-testid="price-and-discounted-price"]') + if not price_el: + # Try alternative selector + price_el = await card.query_selector('[data-testid="price"]') + + if price_el: + price_text = await price_el.inner_text() + rate.rate_gross = self._parse_price(price_text) + if rate.rate_gross: + rate.availability_status = AvailabilityStatus.AVAILABLE + + # Room type + room_el = await card.query_selector('[data-testid="recommended-units"]') + if room_el: + rate.room_type = (await room_el.inner_text()).strip() + + # Rate option badges - try multiple selectors + # Breakfast included + breakfast_el = await card.query_selector('[data-testid="breakfast-included"]') + if not breakfast_el: + # Check text content for breakfast mentions + card_text = (await card.inner_text()).lower() + rate.breakfast_included = 'breakfast included' in card_text + else: + rate.breakfast_included = True + + # Free cancellation + cancel_el = await card.query_selector('[data-testid="cancellation-policy"]') + if cancel_el: + cancel_text = (await cancel_el.inner_text()).lower() + rate.free_cancellation = 'free cancellation' in cancel_text + else: + card_text = (await card.inner_text()).lower() + rate.free_cancellation = 'free cancellation' in card_text + + # No prepayment + prepay_el = await card.query_selector('[data-testid="no-prepayment"]') + if prepay_el: + rate.no_prepayment = True + else: + card_text = (await card.inner_text()).lower() + rate.no_prepayment = 'no prepayment' in card_text + + # Rooms left / scarcity indicator + scarcity_el = await card.query_selector('[data-testid="availability-rate"]') + if scarcity_el: + scarcity_text = await scarcity_el.inner_text() + match = re.search(r'(\d+)\s*room', scarcity_text.lower()) + if match: + rate.rooms_left = int(match.group(1)) + + hotels.append(hotel) + rates.append(rate) + + except Exception as e: + logger.warning(f"Error extracting hotel data: {e}") + continue + + return hotels, rates + + async def scrape_location_search( + self, + location: str, + check_in: date, + check_out: date, + adults: int = 2, + pages: int = 2 + ) -> ScraperResult: + """ + Scrape booking.com location search results. + + Args: + location: Location name + check_in: Check-in date + check_out: Check-out date (check_in + 1 for single night rate) + adults: Number of adults + pages: Number of result pages to scrape + + Returns: + ScraperResult with hotels and rates found + """ + all_hotels = [] + all_rates = [] + seen_hotel_ids = set() + + context = None + page = None + + try: + context = await self._create_context() + page = await context.new_page() + + for page_num in range(pages): + # Random delay between pages (3-7 seconds) + if page_num > 0: + delay = random.uniform(3, 7) + logger.info(f"Waiting {delay:.1f}s before page {page_num + 1}") + await asyncio.sleep(delay) + + # Build URL with offset for pagination (25 results per page) + url = self._build_search_url( + location, check_in, check_out, adults, + offset=page_num * 25 + ) + + logger.info(f"Scraping page {page_num + 1}: {url}") + + try: + await page.goto(url, wait_until='networkidle', timeout=30000) + except Exception as e: + logger.warning(f"Page load timeout, continuing: {e}") + + # Check for blocking + content = await page.content() + is_blocked, reason = self.detect_blocking(content) + if is_blocked: + logger.warning(f"Blocking detected: {reason}") + return ScraperResult( + success=False, + blocked=True, + block_reason=reason, + hotels=all_hotels, + rates=all_rates, + page_content_sample=content[:1000] + ) + + # Human-like scrolling + await self._human_like_scroll(page) + + # Extract data + hotels, rates = await self._extract_search_results(page, check_in) + + # Deduplicate by booking_com_id + for hotel, rate in zip(hotels, rates): + if hotel.booking_com_id and hotel.booking_com_id not in seen_hotel_ids: + seen_hotel_ids.add(hotel.booking_com_id) + all_hotels.append(hotel) + all_rates.append(rate) + + logger.info(f"Page {page_num + 1}: found {len(hotels)} hotels, {len(all_hotels)} total unique") + + return ScraperResult( + success=True, + blocked=False, + hotels=all_hotels, + rates=all_rates + ) + + except Exception as e: + logger.error(f"Scrape error: {e}") + return ScraperResult( + success=False, + blocked=False, + error_message=str(e), + hotels=all_hotels, + rates=all_rates + ) + finally: + if page: + await page.close() + if context: + await context.close() + + async def scrape_hotel_page( + self, + hotel_url: str, + check_in: date, + check_out: date, + adults: int = 2 + ) -> ScraperResult: + """ + Scrape individual hotel page for detailed rates. + + Future expansion - placeholder for now. + Will extract available_qty from room dropdowns. + """ + # Not implemented in Phase 2a + logger.warning("scrape_hotel_page not yet implemented") + return ScraperResult( + success=False, + error_message="Hotel page scraping not yet implemented" + ) + + async def close(self): + """Clean up browser resources.""" + if self._browser: + await self._browser.close() + self._browser = None + if self._playwright: + await self._playwright.stop() + self._playwright = None diff --git a/backend/utils/__init__.py b/backend/utils/__init__.py new file mode 100644 index 0000000..cc22858 --- /dev/null +++ b/backend/utils/__init__.py @@ -0,0 +1,22 @@ +""" +Backend utilities module. +""" +from .time_alignment import ( + get_prior_year_daily, + get_prior_year_weekly, + get_prior_year_week_dates, + get_prior_year_monthly, + get_comparison_info, + SQL_PRIOR_YEAR_DAILY, + SQL_PRIOR_YEAR_OFFSET, +) + +__all__ = [ + 'get_prior_year_daily', + 'get_prior_year_weekly', + 'get_prior_year_week_dates', + 'get_prior_year_monthly', + 'get_comparison_info', + 'SQL_PRIOR_YEAR_DAILY', + 'SQL_PRIOR_YEAR_OFFSET', +] diff --git a/backend/utils/capacity.py b/backend/utils/capacity.py new file mode 100644 index 0000000..e49e100 --- /dev/null +++ b/backend/utils/capacity.py @@ -0,0 +1,116 @@ +""" +Room capacity utilities + +Functions for getting bookable room counts accounting for maintenance. +""" +from datetime import date +from sqlalchemy import text + + +def get_bookable_cap_sync(db, forecast_date: date = None, fallback_value: int = 25) -> int: + """ + Get the bookable rooms cap for a specific date (synchronous version). + + Bookable = Total Rooms - Maintenance - Allotted + + Args: + db: Synchronous database session + forecast_date: Specific date to get cap for (optional) + fallback_value: Default if no data found + + Returns: + Bookable room count (cap for room forecasts) + """ + # First try to get specific date's bookable count from stats + if forecast_date: + result = db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE date = :target_date AND bookable_count IS NOT NULL + """), {"target_date": forecast_date}) + row = result.fetchone() + if row and row.bookable_count is not None: + return int(row.bookable_count) + + # Try occupancy report data for future dates + result = db.execute(text(""" + SELECT + SUM(COALESCE(o.available, 0) - COALESCE(o.maintenance, 0)) as bookable + FROM newbook_occupancy_report_data o + JOIN newbook_room_categories c ON o.category_id = c.site_id + WHERE o.date = :target_date AND c.is_included = true + """), {"target_date": forecast_date}) + row = result.fetchone() + if row and row.bookable is not None: + return int(row.bookable) + + # Fall back to most recent bookable_count + result = db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count IS NOT NULL + ORDER BY date DESC + LIMIT 1 + """)) + row = result.fetchone() + if row and row.bookable_count: + return int(row.bookable_count) + + return fallback_value + + +async def get_bookable_cap(db, forecast_date: date = None, fallback_value: int = 25) -> int: + """ + Get the bookable rooms cap for a specific date. + + Bookable = Total Rooms - Maintenance - Allotted + + For future dates without stats data, tries occupancy report data first, + then falls back to most recent bookable_count from stats. + + Args: + db: Database session + forecast_date: Specific date to get cap for (optional) + fallback_value: Default if no data found + + Returns: + Bookable room count (cap for room forecasts) + """ + # First try to get specific date's bookable count from stats + if forecast_date: + result = await db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE date = :target_date AND bookable_count IS NOT NULL + """), {"target_date": forecast_date}) + row = result.fetchone() + # Accept 0 as valid (all rooms in maintenance) + if row and row.bookable_count is not None: + return int(row.bookable_count) + + # Try occupancy report data for future dates + result = await db.execute(text(""" + SELECT + SUM(COALESCE(o.available, 0) - COALESCE(o.maintenance, 0)) as bookable + FROM newbook_occupancy_report_data o + JOIN newbook_room_categories c ON o.category_id = c.site_id + WHERE o.date = :target_date AND c.is_included = true + """), {"target_date": forecast_date}) + row = result.fetchone() + # Accept 0 as valid bookable count (all rooms in maintenance) + if row and row.bookable is not None: + return int(row.bookable) + + # Fall back to most recent bookable_count + result = await db.execute(text(""" + SELECT bookable_count + FROM newbook_bookings_stats + WHERE bookable_count IS NOT NULL + ORDER BY date DESC + LIMIT 1 + """)) + row = result.fetchone() + if row and row.bookable_count: + return int(row.bookable_count) + + return fallback_value diff --git a/backend/utils/time_alignment.py b/backend/utils/time_alignment.py new file mode 100644 index 0000000..dc378d9 --- /dev/null +++ b/backend/utils/time_alignment.py @@ -0,0 +1,161 @@ +""" +Time alignment utilities for prior year comparisons. + +Ensures consistent comparison logic across the application: +- Daily: 364 days (52 weeks) for day-of-week alignment (Mon→Mon, Sat→Sat) +- Weekly: ISO week number matching (Week 6 2026 vs Week 6 2025) +- Monthly: Same month, prior year + +This module should be used whenever comparing to prior year data. +""" +from datetime import date, timedelta +from typing import Tuple, Optional + + +def get_prior_year_daily(target_date: date) -> date: + """ + Get the comparable date from prior year for daily comparisons. + + Uses 364 days (exactly 52 weeks) to ensure day-of-week alignment: + - Monday → Monday + - Saturday → Saturday + + Example: + Wed 11 Feb 2026 → Wed 12 Feb 2025 (not 11 Feb 2025 which was a different day) + + Args: + target_date: The date to find comparison for + + Returns: + The prior year date with same day of week + """ + return target_date - timedelta(days=364) + + +def get_prior_year_weekly(target_date: date) -> Tuple[int, int]: + """ + Get the ISO week and year for weekly year-over-year comparison. + + Uses ISO week numbers so Week 6 of 2026 compares to Week 6 of 2025. + This ensures full weeks are compared (Mon-Sun) regardless of calendar dates. + + Args: + target_date: Any date within the target week + + Returns: + Tuple of (year, week_number) for the comparison week + """ + iso_cal = target_date.isocalendar() + return (iso_cal.year - 1, iso_cal.week) + + +def get_prior_year_week_dates(target_date: date) -> Tuple[date, date]: + """ + Get the start and end dates of the same ISO week in the prior year. + + Useful for querying data for the entire comparison week. + + Args: + target_date: Any date within the target week + + Returns: + Tuple of (week_start, week_end) for the prior year's matching week + """ + iso_cal = target_date.isocalendar() + prior_year = iso_cal.year - 1 + prior_week = iso_cal.week + + # Handle edge case: if prior year doesn't have this week number + # (can happen with week 53), fall back to last week of prior year + try: + # Find the first day of the target week in prior year + # Week 1 day 1 of the prior year + jan_1_prior = date(prior_year, 1, 1) + jan_1_iso = jan_1_prior.isocalendar() + + # Calculate days to add to get to the target week + # First, get to week 1 day 1 + days_to_week_1 = (1 - jan_1_iso.weekday) % 7 + week_1_monday = jan_1_prior + timedelta(days=days_to_week_1) + + # Adjust if Jan 1 is in the previous year's last week + if jan_1_iso.week != 1: + week_1_monday = jan_1_prior + timedelta(days=(7 - jan_1_prior.weekday())) + + # Now add weeks to get to target week + week_start = week_1_monday + timedelta(weeks=prior_week - 1) + + # Verify we got the right week + if week_start.isocalendar().week != prior_week: + # Fallback: use last week of prior year + dec_28_prior = date(prior_year, 12, 28) # Always in last week + dec_28_iso = dec_28_prior.isocalendar() + week_start = dec_28_prior - timedelta(days=dec_28_prior.weekday()) + + week_end = week_start + timedelta(days=6) + return (week_start, week_end) + + except (ValueError, AttributeError): + # Fallback to simpler calculation + prior_date = get_prior_year_daily(target_date) + week_start = prior_date - timedelta(days=prior_date.weekday()) + week_end = week_start + timedelta(days=6) + return (week_start, week_end) + + +def get_prior_year_monthly(target_date: date) -> Tuple[date, date]: + """ + Get the start and end dates of the same month in the prior year. + + Args: + target_date: Any date within the target month + + Returns: + Tuple of (month_start, month_end) for the prior year's matching month + """ + prior_year = target_date.year - 1 + month = target_date.month + + # First day of the month + month_start = date(prior_year, month, 1) + + # Last day of the month + if month == 12: + month_end = date(prior_year + 1, 1, 1) - timedelta(days=1) + else: + month_end = date(prior_year, month + 1, 1) - timedelta(days=1) + + return (month_start, month_end) + + +# SQL helper constants for use in queries +SQL_PRIOR_YEAR_DAILY = "INTERVAL '364 days'" # For daily comparisons +SQL_PRIOR_YEAR_OFFSET = 364 # Days offset for daily DOW alignment + + +def get_comparison_info(target_date: date) -> dict: + """ + Get formatted comparison information for display. + + Useful for showing users what date/week is being compared. + + Args: + target_date: The date being forecasted/analyzed + + Returns: + Dict with comparison details + """ + prior_daily = get_prior_year_daily(target_date) + prior_week_year, prior_week_num = get_prior_year_weekly(target_date) + + day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + + return { + "target_date": target_date, + "target_day": day_names[target_date.weekday()], + "target_iso_week": target_date.isocalendar().week, + "prior_year_date": prior_daily, + "prior_year_day": day_names[prior_daily.weekday()], # Should match target_day + "prior_year_iso_week": prior_week_num, + "comparison_note": f"vs {day_names[prior_daily.weekday()]} {prior_daily.strftime('%d %b %Y')}" + } diff --git a/db/init_clean.sql b/db/init_clean.sql new file mode 100644 index 0000000..7410a54 --- /dev/null +++ b/db/init_clean.sql @@ -0,0 +1,872 @@ +-- Forecasting Application Database Schema +-- PostgreSQL 15+ +-- Database: forecast_data (created via POSTGRES_DB env var) + +-- Enable UUID extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ============================================ +-- USERS & AUTHENTICATION +-- ============================================ + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Default admin user (password: admin123 - change in production!) +INSERT INTO users (username, password_hash, display_name) VALUES +('admin', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYH0lHXG0Kku', 'Administrator') +ON CONFLICT (username) DO NOTHING; + +-- ============================================ +-- API KEYS (for external integrations) +-- ============================================ + +CREATE TABLE IF NOT EXISTS api_keys ( + id SERIAL PRIMARY KEY, + key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA256 hash of key (never store plaintext) + key_prefix VARCHAR(20) NOT NULL, -- First chars for display (e.g., "fk_abc123...") + name VARCHAR(100) NOT NULL, -- Descriptive name (e.g., "Kitchen Flash App") + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + last_used_at TIMESTAMP, + created_by VARCHAR(100) +); + +CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); +CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active); + +-- ============================================ +-- SYSTEM CONFIGURATION +-- ============================================ + +CREATE TABLE IF NOT EXISTS system_config ( + id SERIAL PRIMARY KEY, + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value TEXT, + is_encrypted BOOLEAN DEFAULT FALSE, + description TEXT, + updated_at TIMESTAMP DEFAULT NOW(), + updated_by VARCHAR(100) +); + +-- Default config entries +INSERT INTO system_config (config_key, description) VALUES +('newbook_api_key', 'Newbook API Key'), +('newbook_username', 'Newbook Username'), +('newbook_password', 'Newbook Password'), +('newbook_region', 'Newbook Region Code'), +('resos_api_key', 'Resos API Key'), +('total_rooms', 'Total number of hotel rooms'), +('hotel_name', 'Hotel/Property Name'), +('timezone', 'Local timezone (e.g., Europe/London)'), +('accommodation_vat_rate', 'VAT rate for accommodation (e.g., 0.20 for 20%)'), +('sync_newbook_enabled', 'Enable automatic Newbook sync (true/false)'), +('sync_resos_enabled', 'Enable automatic Resos sync (true/false)'), +('sync_schedule_time', 'Time for daily sync (HH:MM format)'), +('sync_newbook_bookings_enabled', 'Enable automatic Newbook bookings data sync (true/false)'), +('sync_newbook_bookings_type', 'Newbook bookings sync type (incremental/full)'), +('sync_newbook_bookings_time', 'Newbook bookings sync time (HH:MM)'), +('sync_newbook_occupancy_enabled', 'Enable automatic Newbook occupancy report sync (true/false)'), +('sync_newbook_occupancy_time', 'Newbook occupancy report sync time (HH:MM)'), +('last_bookings_aggregation_at', 'Timestamp of last bookings stats aggregation'), +('sync_newbook_earned_revenue_enabled', 'Enable automatic Newbook earned revenue sync (true/false)'), +('sync_newbook_earned_revenue_time', 'Newbook earned revenue sync time (HH:MM)'), +('last_revenue_aggregation_at', 'Timestamp of last revenue aggregation'), +('sync_newbook_current_rates_enabled', 'Enable automatic Newbook current rates sync for pickup-v2 (true/false)'), +('sync_newbook_current_rates_time', 'Newbook current rates sync time (HH:MM)') +ON CONFLICT (config_key) DO NOTHING; + +-- Set defaults +UPDATE system_config SET config_value = '80' WHERE config_key = 'total_rooms' AND config_value IS NULL; +UPDATE system_config SET config_value = 'Europe/London' WHERE config_key = 'timezone' AND config_value IS NULL; +UPDATE system_config SET config_value = '0.20' WHERE config_key = 'accommodation_vat_rate' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_resos_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:00' WHERE config_key = 'sync_schedule_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_bookings_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = 'incremental' WHERE config_key = 'sync_newbook_bookings_type' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:00' WHERE config_key = 'sync_newbook_bookings_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_occupancy_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:00' WHERE config_key = 'sync_newbook_occupancy_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_earned_revenue_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:10' WHERE config_key = 'sync_newbook_earned_revenue_time' AND config_value IS NULL; +UPDATE system_config SET config_value = 'false' WHERE config_key = 'sync_newbook_current_rates_enabled' AND config_value IS NULL; +UPDATE system_config SET config_value = '05:20' WHERE config_key = 'sync_newbook_current_rates_time' AND config_value IS NULL; + +-- ============================================ +-- TAX RATES (date-based tax configuration) +-- ============================================ + +CREATE TABLE IF NOT EXISTS tax_rates ( + id SERIAL PRIMARY KEY, + tax_type VARCHAR(50) NOT NULL, -- 'accommodation_vat', 'food_vat', etc. + rate DECIMAL(5,4) NOT NULL, -- e.g., 0.20 for 20% + effective_from DATE NOT NULL, -- Date this rate becomes effective + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(tax_type, effective_from) +); + +CREATE INDEX IF NOT EXISTS idx_tax_rates_type ON tax_rates(tax_type); +CREATE INDEX IF NOT EXISTS idx_tax_rates_effective ON tax_rates(tax_type, effective_from); + +-- Default accommodation VAT rate (20% from 2022-01-01) +INSERT INTO tax_rates (tax_type, rate, effective_from) VALUES +('accommodation_vat', 0.20, '2022-01-01') +ON CONFLICT (tax_type, effective_from) DO NOTHING; + +-- ============================================ +-- NEWBOOK ROOM CATEGORIES (for occupancy settings) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_room_categories ( + id SERIAL PRIMARY KEY, + site_id VARCHAR(50) NOT NULL UNIQUE, + site_name VARCHAR(255) NOT NULL, + site_type VARCHAR(100), + room_count INTEGER DEFAULT 0, + is_included BOOLEAN DEFAULT TRUE, + display_order INTEGER DEFAULT 0, + fetched_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================ +-- NEWBOOK GL ACCOUNTS (for revenue mapping) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_gl_accounts ( + id SERIAL PRIMARY KEY, + gl_account_id VARCHAR(50) NOT NULL UNIQUE, + gl_code VARCHAR(50), + gl_name VARCHAR(255), + gl_group_id VARCHAR(50), + gl_group_name VARCHAR(255), + department VARCHAR(20), -- 'accommodation', 'dry', 'wet', or null + last_seen_date DATE, + total_amount DECIMAL(14,2) DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + fetched_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_gl_accounts_department ON newbook_gl_accounts(department); +CREATE INDEX IF NOT EXISTS idx_gl_accounts_group ON newbook_gl_accounts(gl_group_name); + +-- ============================================ +-- NEWBOOK BOOKINGS DATA (historical booking data) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_bookings_data ( + id SERIAL PRIMARY KEY, + newbook_id VARCHAR(50) NOT NULL UNIQUE, + booking_reference VARCHAR(100), + bookings_group_id VARCHAR(50), + booking_placed TIMESTAMP, -- When booking was created (for lead time calculations) + arrival_date DATE NOT NULL, + departure_date DATE NOT NULL, + nights INTEGER, + adults INTEGER DEFAULT 0, + children INTEGER DEFAULT 0, + infants INTEGER DEFAULT 0, + total_guests INTEGER, + category_id VARCHAR(50), + room_type VARCHAR(100), + site_id VARCHAR(50), + room_number VARCHAR(50), + status VARCHAR(50), + total_amount DECIMAL(12,2), + tariff_name VARCHAR(255), + tariff_total DECIMAL(12,2), + travel_agent_id VARCHAR(50), + travel_agent_name VARCHAR(255), + travel_agent_commission DECIMAL(12,2), + booking_source_id VARCHAR(50), + booking_source_name VARCHAR(255), + booking_parent_source_id VARCHAR(50), + booking_parent_source_name VARCHAR(255), + booking_method_id VARCHAR(50), + booking_method_name VARCHAR(100), + raw_json JSONB, + fetched_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bookings_arrival ON newbook_bookings_data(arrival_date); +CREATE INDEX IF NOT EXISTS idx_bookings_status ON newbook_bookings_data(status); +CREATE INDEX IF NOT EXISTS idx_bookings_placed ON newbook_bookings_data(booking_placed); + +-- ============================================ +-- NEWBOOK EARNED REVENUE DATA (historical revenue data) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_earned_revenue_data ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + gl_account_id VARCHAR(50), + gl_code VARCHAR(50), + gl_name VARCHAR(255), + amount_gross DECIMAL(12,2) DEFAULT 0, + amount_net DECIMAL(12,2) DEFAULT 0, + revenue_type VARCHAR(30), + fetched_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, gl_account_id) +); + +CREATE INDEX IF NOT EXISTS idx_earned_revenue_data_date ON newbook_earned_revenue_data(date); +CREATE INDEX IF NOT EXISTS idx_earned_revenue_data_type ON newbook_earned_revenue_data(date, revenue_type); + +-- ============================================ +-- NEWBOOK NET REVENUE DATA (aggregated by department) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_net_revenue_data ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL UNIQUE, + accommodation DECIMAL(12,2) DEFAULT 0, -- Net accommodation revenue + dry DECIMAL(12,2) DEFAULT 0, -- Net dry (food) revenue + wet DECIMAL(12,2) DEFAULT 0, -- Net wet (beverage) revenue + aggregated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_net_revenue_data_date ON newbook_net_revenue_data(date); + +-- ============================================ +-- NEWBOOK OCCUPANCY REPORT DATA (official capacity & occupancy) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_occupancy_report_data ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + category_id VARCHAR(50) NOT NULL, + category_name VARCHAR(255), + available INTEGER DEFAULT 0, -- Total configured rooms for category + occupied INTEGER DEFAULT 0, -- Official occupied per Newbook + maintenance INTEGER DEFAULT 0, -- Rooms offline (deduct from available for bookable rooms) + allotted INTEGER DEFAULT 0, -- Block allocations + revenue_gross DECIMAL(12,2) DEFAULT 0, + revenue_net DECIMAL(12,2) DEFAULT 0, + occupancy_pct DECIMAL(5,2), + fetched_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, category_id) +); + +CREATE INDEX IF NOT EXISTS idx_occupancy_report_data_date ON newbook_occupancy_report_data(date); + +-- ============================================ +-- SYNC LOGGING +-- ============================================ + +CREATE TABLE IF NOT EXISTS sync_log ( + id SERIAL PRIMARY KEY, + sync_type VARCHAR(30) NOT NULL, + source VARCHAR(20) NOT NULL, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + status VARCHAR(20) NOT NULL, + records_fetched INTEGER, + records_created INTEGER, + records_updated INTEGER, + date_from DATE, + date_to DATE, + error_message TEXT, + triggered_by VARCHAR(100) +); + +-- ============================================ +-- NEWBOOK BOOKINGS STATS (aggregated daily stats) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_bookings_stats ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL UNIQUE, + + -- Room availability (from newbook_occupancy_report_data) + rooms_count INTEGER DEFAULT 0, -- Total available rooms (included categories) + maintenance_count INTEGER DEFAULT 0, -- Rooms offline/maintenance + bookable_count INTEGER DEFAULT 0, -- rooms_count - maintenance_count + + -- Occupancy totals (from bookings) + booking_count INTEGER DEFAULT 0, -- Occupied rooms (bookings staying this night) + guests_count INTEGER DEFAULT 0, + adults_count INTEGER DEFAULT 0, + children_count INTEGER DEFAULT 0, + infants_count INTEGER DEFAULT 0, + + -- Occupancy percentages + total_occupancy_pct DECIMAL(5,2), -- booking_count / rooms_count * 100 + bookable_occupancy_pct DECIMAL(5,2), -- booking_count / bookable_count * 100 + + -- Revenue totals + guest_rate_total DECIMAL(12,2) DEFAULT 0, -- SUM of calculated_amount (gross) + net_booking_rev_total DECIMAL(12,2) DEFAULT 0, -- SUM of net accommodation + + -- Per-category breakdowns (JSONB) + occupancy_by_category JSONB DEFAULT '{}', + revenue_by_category JSONB DEFAULT '{}', + availability_by_category JSONB DEFAULT '{}', + + -- Pickup-V2: Rate statistics per category for bounds calculation + -- Structure: { "category_id": { "min_net": 120, "max_net": 200, "adr_net": 155, "rooms": 12 } } + rate_stats_by_category JSONB DEFAULT '{}', + + aggregated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_bookings_stats_date ON newbook_bookings_stats(date); + +-- ============================================ +-- NEWBOOK BOOKING PACE (lead-time snapshots for forecasting) +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_booking_pace ( + id SERIAL PRIMARY KEY, + arrival_date DATE NOT NULL UNIQUE, + + -- Monthly intervals: months 7-12 (6 columns) + d365 INTEGER, -- 12 months out + d330 INTEGER, -- 11 months out + d300 INTEGER, -- 10 months out + d270 INTEGER, -- 9 months out + d240 INTEGER, -- 8 months out + d210 INTEGER, -- 7 months out + + -- Weekly intervals: weeks 5-25 (21 columns) + d177 INTEGER, d170 INTEGER, d163 INTEGER, d156 INTEGER, d149 INTEGER, + d142 INTEGER, d135 INTEGER, d128 INTEGER, d121 INTEGER, d114 INTEGER, + d107 INTEGER, d100 INTEGER, d93 INTEGER, d86 INTEGER, d79 INTEGER, + d72 INTEGER, d65 INTEGER, d58 INTEGER, d51 INTEGER, d44 INTEGER, d37 INTEGER, + + -- Daily intervals: days 0-30 (31 columns) + d30 INTEGER, d29 INTEGER, d28 INTEGER, d27 INTEGER, d26 INTEGER, + d25 INTEGER, d24 INTEGER, d23 INTEGER, d22 INTEGER, d21 INTEGER, + d20 INTEGER, d19 INTEGER, d18 INTEGER, d17 INTEGER, d16 INTEGER, + d15 INTEGER, d14 INTEGER, d13 INTEGER, d12 INTEGER, d11 INTEGER, + d10 INTEGER, d9 INTEGER, d8 INTEGER, d7 INTEGER, d6 INTEGER, + d5 INTEGER, d4 INTEGER, d3 INTEGER, d2 INTEGER, d1 INTEGER, d0 INTEGER, + + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_booking_pace_arrival ON newbook_booking_pace(arrival_date); + +-- ============================================ +-- PICKUP-V2: CATEGORY BOOKING PACE (per-category room counts at lead times) +-- ============================================ + +CREATE TABLE IF NOT EXISTS category_booking_pace ( + id SERIAL PRIMARY KEY, + arrival_date DATE NOT NULL, + category_id VARCHAR(50) NOT NULL, + + -- Monthly intervals: months 7-12 (6 columns) + d365 INTEGER, d330 INTEGER, d300 INTEGER, d270 INTEGER, d240 INTEGER, d210 INTEGER, + + -- Weekly intervals: weeks 5-25 (21 columns) + d177 INTEGER, d170 INTEGER, d163 INTEGER, d156 INTEGER, d149 INTEGER, + d142 INTEGER, d135 INTEGER, d128 INTEGER, d121 INTEGER, d114 INTEGER, + d107 INTEGER, d100 INTEGER, d93 INTEGER, d86 INTEGER, d79 INTEGER, + d72 INTEGER, d65 INTEGER, d58 INTEGER, d51 INTEGER, d44 INTEGER, d37 INTEGER, + + -- Daily intervals: days 0-30 (31 columns) + d30 INTEGER, d29 INTEGER, d28 INTEGER, d27 INTEGER, d26 INTEGER, + d25 INTEGER, d24 INTEGER, d23 INTEGER, d22 INTEGER, d21 INTEGER, + d20 INTEGER, d19 INTEGER, d18 INTEGER, d17 INTEGER, d16 INTEGER, + d15 INTEGER, d14 INTEGER, d13 INTEGER, d12 INTEGER, d11 INTEGER, + d10 INTEGER, d9 INTEGER, d8 INTEGER, d7 INTEGER, d6 INTEGER, + d5 INTEGER, d4 INTEGER, d3 INTEGER, d2 INTEGER, d1 INTEGER, d0 INTEGER, + + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(arrival_date, category_id) +); + +CREATE INDEX IF NOT EXISTS idx_category_booking_pace_arrival ON category_booking_pace(arrival_date); +CREATE INDEX IF NOT EXISTS idx_category_booking_pace_category ON category_booking_pace(category_id); + +-- ============================================ +-- PICKUP-V2: REVENUE PACE (booked accommodation revenue at lead times) +-- ============================================ + +CREATE TABLE IF NOT EXISTS revenue_pace ( + id SERIAL PRIMARY KEY, + stay_date DATE NOT NULL UNIQUE, + + -- Monthly intervals: months 7-12 (6 columns) - DECIMAL for revenue + d365 DECIMAL(12,2), d330 DECIMAL(12,2), d300 DECIMAL(12,2), + d270 DECIMAL(12,2), d240 DECIMAL(12,2), d210 DECIMAL(12,2), + + -- Weekly intervals: weeks 5-25 (21 columns) + d177 DECIMAL(12,2), d170 DECIMAL(12,2), d163 DECIMAL(12,2), d156 DECIMAL(12,2), d149 DECIMAL(12,2), + d142 DECIMAL(12,2), d135 DECIMAL(12,2), d128 DECIMAL(12,2), d121 DECIMAL(12,2), d114 DECIMAL(12,2), + d107 DECIMAL(12,2), d100 DECIMAL(12,2), d93 DECIMAL(12,2), d86 DECIMAL(12,2), d79 DECIMAL(12,2), + d72 DECIMAL(12,2), d65 DECIMAL(12,2), d58 DECIMAL(12,2), d51 DECIMAL(12,2), d44 DECIMAL(12,2), d37 DECIMAL(12,2), + + -- Daily intervals: days 0-30 (31 columns) + d30 DECIMAL(12,2), d29 DECIMAL(12,2), d28 DECIMAL(12,2), d27 DECIMAL(12,2), d26 DECIMAL(12,2), + d25 DECIMAL(12,2), d24 DECIMAL(12,2), d23 DECIMAL(12,2), d22 DECIMAL(12,2), d21 DECIMAL(12,2), + d20 DECIMAL(12,2), d19 DECIMAL(12,2), d18 DECIMAL(12,2), d17 DECIMAL(12,2), d16 DECIMAL(12,2), + d15 DECIMAL(12,2), d14 DECIMAL(12,2), d13 DECIMAL(12,2), d12 DECIMAL(12,2), d11 DECIMAL(12,2), + d10 DECIMAL(12,2), d9 DECIMAL(12,2), d8 DECIMAL(12,2), d7 DECIMAL(12,2), d6 DECIMAL(12,2), + d5 DECIMAL(12,2), d4 DECIMAL(12,2), d3 DECIMAL(12,2), d2 DECIMAL(12,2), d1 DECIMAL(12,2), d0 DECIMAL(12,2), + + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_revenue_pace_stay ON revenue_pace(stay_date); + +-- ============================================ +-- PICKUP-V2: CURRENT RATES FROM NEWBOOK (for ceiling calculations) +-- Now with rate history tracking - stores snapshots when rates change +-- ============================================ + +CREATE TABLE IF NOT EXISTS newbook_current_rates ( + id SERIAL PRIMARY KEY, + category_id VARCHAR(50) NOT NULL, + rate_date DATE NOT NULL, + rate_name VARCHAR(255), + rate_gross DECIMAL(12,2), + rate_net DECIMAL(12,2), + tariffs_data JSONB DEFAULT '{}', -- All available tariff options with availability status + valid_from TIMESTAMP DEFAULT NOW(), -- When this rate version started + last_verified_at TIMESTAMP DEFAULT NOW() -- Last time we confirmed rate is still current + -- No UNIQUE constraint - allows multiple versions per (category_id, rate_date) +); + +CREATE INDEX IF NOT EXISTS idx_current_rates_date ON newbook_current_rates(rate_date); +CREATE INDEX IF NOT EXISTS idx_current_rates_category ON newbook_current_rates(category_id); +CREATE INDEX IF NOT EXISTS idx_current_rates_tariffs ON newbook_current_rates USING gin(tariffs_data); +CREATE INDEX IF NOT EXISTS idx_current_rates_latest ON newbook_current_rates(category_id, rate_date, valid_from DESC); + +-- Migration: Add tariffs_data column if missing (for existing databases) +ALTER TABLE newbook_current_rates ADD COLUMN IF NOT EXISTS tariffs_data JSONB DEFAULT '{}'; + +-- Migration: Convert from old schema to new snapshot schema +-- Rename fetched_at to valid_from if it exists +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_name = 'newbook_current_rates' AND column_name = 'fetched_at') THEN + ALTER TABLE newbook_current_rates RENAME COLUMN fetched_at TO valid_from; + END IF; +END $$; + +-- Add last_verified_at column if missing +ALTER TABLE newbook_current_rates ADD COLUMN IF NOT EXISTS last_verified_at TIMESTAMP DEFAULT NOW(); + +-- Drop unique constraint if it exists (allows rate history) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_constraint + WHERE conname = 'newbook_current_rates_category_id_rate_date_key') THEN + ALTER TABLE newbook_current_rates + DROP CONSTRAINT newbook_current_rates_category_id_rate_date_key; + END IF; +END $$; + +-- ============================================ +-- FORECASTING TABLES (for Prophet, XGBoost, CatBoost models) +-- ============================================ + +-- Forecast metrics configuration +CREATE TABLE IF NOT EXISTS forecast_metrics ( + id SERIAL PRIMARY KEY, + metric_code VARCHAR(50) NOT NULL UNIQUE, + metric_name VARCHAR(100) NOT NULL, + description TEXT, + unit VARCHAR(20), + is_active BOOLEAN DEFAULT TRUE, + use_prophet BOOLEAN DEFAULT TRUE, + use_xgboost BOOLEAN DEFAULT TRUE, + use_pickup BOOLEAN DEFAULT FALSE, + use_catboost BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Default metrics for forecasting +INSERT INTO forecast_metrics (metric_code, metric_name, description, unit, use_prophet, use_xgboost, use_pickup, use_catboost) VALUES +('hotel_occupancy_pct', 'Hotel Occupancy %', 'Percentage of available rooms occupied', '%', TRUE, TRUE, TRUE, TRUE), +('hotel_room_nights', 'Room Nights', 'Number of rooms sold', 'rooms', TRUE, TRUE, TRUE, TRUE), +('hotel_guests', 'Guest Count', 'Total guests staying', 'guests', TRUE, TRUE, FALSE, TRUE), +('hotel_arrivals', 'Arrivals', 'Number of check-ins', 'arrivals', TRUE, TRUE, FALSE, TRUE) +ON CONFLICT (metric_code) DO NOTHING; + +-- Daily metrics (actuals storage - populated from newbook_bookings_stats) +CREATE TABLE IF NOT EXISTS daily_metrics ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + metric_code VARCHAR(50) NOT NULL, + actual_value DECIMAL(12,2), + source VARCHAR(50) DEFAULT 'newbook', + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, metric_code) +); + +CREATE INDEX IF NOT EXISTS idx_daily_metrics_date ON daily_metrics(date); +CREATE INDEX IF NOT EXISTS idx_daily_metrics_code ON daily_metrics(metric_code, date); + +-- Forecasts storage +CREATE TABLE IF NOT EXISTS forecasts ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + model_type VARCHAR(20) NOT NULL, -- 'prophet', 'xgboost', 'pickup', 'catboost' + predicted_value DECIMAL(12,2) NOT NULL, + lower_bound DECIMAL(12,2), + upper_bound DECIMAL(12,2), + generated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(forecast_date, forecast_type, model_type, generated_at) +); + +CREATE INDEX IF NOT EXISTS idx_forecasts_date ON forecasts(forecast_date); +CREATE INDEX IF NOT EXISTS idx_forecasts_type ON forecasts(forecast_type, model_type); +CREATE INDEX IF NOT EXISTS idx_forecasts_generated ON forecasts(generated_at DESC); + +-- Actual vs forecast comparison +CREATE TABLE IF NOT EXISTS actual_vs_forecast ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + metric_type VARCHAR(50) NOT NULL, + actual_value DECIMAL(12,2), + budget_value DECIMAL(12,2), + -- Prophet + prophet_forecast DECIMAL(12,2), + prophet_lower DECIMAL(12,2), + prophet_upper DECIMAL(12,2), + prophet_error DECIMAL(12,4), + prophet_pct_error DECIMAL(8,4), + -- XGBoost + xgboost_forecast DECIMAL(12,2), + xgboost_error DECIMAL(12,4), + xgboost_pct_error DECIMAL(8,4), + -- Pickup + pickup_forecast DECIMAL(12,2), + pickup_error DECIMAL(12,4), + pickup_pct_error DECIMAL(8,4), + -- CatBoost + catboost_forecast DECIMAL(12,2), + catboost_error DECIMAL(12,4), + catboost_pct_error DECIMAL(8,4), + -- Analysis + best_model VARCHAR(20), + calculated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(date, metric_type) +); + +CREATE INDEX IF NOT EXISTS idx_actual_vs_forecast_date ON actual_vs_forecast(date); +CREATE INDEX IF NOT EXISTS idx_actual_vs_forecast_type ON actual_vs_forecast(metric_type); + +-- Prophet decomposition storage +CREATE TABLE IF NOT EXISTS prophet_decomposition ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + trend DECIMAL(12,2), + yearly_seasonality DECIMAL(12,2), + weekly_seasonality DECIMAL(12,2), + daily_seasonality DECIMAL(12,2), + holiday_effects JSONB, + regressor_effects JSONB, + generated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_prophet_decomposition_date ON prophet_decomposition(forecast_date, forecast_type); + +-- XGBoost SHAP explanations +CREATE TABLE IF NOT EXISTS xgboost_explanations ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + base_value DECIMAL(12,2), + feature_values JSONB, + shap_values JSONB, + top_positive JSONB, + top_negative JSONB, + generated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_xgboost_explanations_date ON xgboost_explanations(forecast_date, forecast_type); + +-- Pickup model explanations +CREATE TABLE IF NOT EXISTS pickup_explanations ( + id SERIAL PRIMARY KEY, + run_id UUID, + forecast_date DATE NOT NULL, + forecast_type VARCHAR(50) NOT NULL, + current_otb DECIMAL(12,2), + days_out INTEGER, + comparison_date DATE, + comparison_otb DECIMAL(12,2), + comparison_final DECIMAL(12,2), + pickup_curve_pct DECIMAL(8,4), + pickup_curve_stddev DECIMAL(8,4), + pace_vs_prior_pct DECIMAL(8,4), + projection_method VARCHAR(50), + projected_value DECIMAL(12,2), + confidence_note TEXT, + generated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_pickup_explanations_date ON pickup_explanations(forecast_date, forecast_type); + +-- Monthly budgets from FD +CREATE TABLE IF NOT EXISTS monthly_budgets ( + id SERIAL PRIMARY KEY, + year INTEGER NOT NULL, + month INTEGER NOT NULL CHECK (month >= 1 AND month <= 12), + budget_type VARCHAR(50) NOT NULL, + budget_value DECIMAL(12,2) NOT NULL, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(year, month, budget_type) +); + +CREATE INDEX IF NOT EXISTS idx_monthly_budgets_year ON monthly_budgets(year, budget_type); + +-- Daily budgets (distributed from monthly) +CREATE TABLE IF NOT EXISTS daily_budgets ( + id SERIAL PRIMARY KEY, + date DATE NOT NULL, + budget_type VARCHAR(50) NOT NULL, + budget_value DECIMAL(12,2), + distribution_method VARCHAR(50), + prior_year_pct DECIMAL(10,6), + monthly_budget_id INTEGER REFERENCES monthly_budgets(id), + calculated_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP, + UNIQUE(date, budget_type) +); + +CREATE INDEX IF NOT EXISTS idx_daily_budgets_date ON daily_budgets(date, budget_type); + +-- Forecast snapshots (for tracking how forecasts evolve over time) +CREATE TABLE IF NOT EXISTS forecast_snapshots ( + id SERIAL PRIMARY KEY, + snapshot_date DATE NOT NULL, + target_date DATE NOT NULL, + metric_code VARCHAR(50) NOT NULL, + days_out INTEGER NOT NULL, + prophet_value DECIMAL(12,2), + xgboost_value DECIMAL(12,2), + pickup_value DECIMAL(12,2), + catboost_value DECIMAL(12,2), + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(snapshot_date, target_date, metric_code) +); + +CREATE INDEX IF NOT EXISTS idx_forecast_snapshots_target ON forecast_snapshots(target_date, metric_code); + +-- ============================================ +-- USER ROLES (migration for existing users table) +-- ============================================ + +ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) DEFAULT 'admin'; + +-- ============================================ +-- RECONCILIATION: CASH UP SESSIONS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_cash_ups ( + id SERIAL PRIMARY KEY, + session_date DATE NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'final')), + total_float_counted DECIMAL(10,2) DEFAULT 0.00, + total_cash_counted DECIMAL(10,2) DEFAULT 0.00, + notes TEXT, + submitted_at TIMESTAMP, + submitted_by INTEGER REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_recon_cash_ups_date ON recon_cash_ups(session_date); +CREATE INDEX IF NOT EXISTS idx_recon_cash_ups_status ON recon_cash_ups(status); + +-- ============================================ +-- RECONCILIATION: DENOMINATION COUNTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_denominations ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + count_type VARCHAR(20) NOT NULL DEFAULT 'takings' CHECK (count_type IN ('float', 'takings')), + denomination_type VARCHAR(20) NOT NULL CHECK (denomination_type IN ('note', 'coin')), + denomination_value DECIMAL(10,2) NOT NULL, + quantity INTEGER DEFAULT NULL, + value_entered DECIMAL(10,2) DEFAULT NULL, + total_amount DECIMAL(10,2) NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_recon_denoms_cashup ON recon_denominations(cash_up_id); + +-- ============================================ +-- RECONCILIATION: CARD MACHINE DATA +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_card_machines ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + machine_name VARCHAR(100) NOT NULL, + total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + amex_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + visa_mc_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_cards_cashup ON recon_card_machines(cash_up_id); + +-- ============================================ +-- RECONCILIATION: NEWBOOK PAYMENT RECORDS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_payment_records ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER REFERENCES recon_cash_ups(id) ON DELETE SET NULL, + newbook_payment_id VARCHAR(100), + booking_id VARCHAR(100), + guest_name VARCHAR(255), + payment_date TIMESTAMP NOT NULL, + payment_type VARCHAR(100), + payment_method VARCHAR(50), + transaction_method VARCHAR(50), + card_type VARCHAR(50), + amount DECIMAL(10,2) NOT NULL, + tendered DECIMAL(10,2), + processed_by VARCHAR(255), + item_type VARCHAR(50), + synced_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_recon_payments_cashup ON recon_payment_records(cash_up_id); +CREATE INDEX IF NOT EXISTS idx_recon_payments_date ON recon_payment_records(payment_date); + +-- ============================================ +-- RECONCILIATION: RECONCILIATION RESULTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_reconciliation ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + category VARCHAR(50) NOT NULL, + banked_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + reported_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + variance DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_recon_cashup ON recon_reconciliation(cash_up_id); + +-- ============================================ +-- RECONCILIATION: DAILY STATISTICS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_daily_stats ( + id SERIAL PRIMARY KEY, + business_date DATE NOT NULL UNIQUE, + gross_sales DECIMAL(10,2) DEFAULT 0.00, + debtors_creditors_balance DECIMAL(10,2) DEFAULT 0.00, + rooms_sold INTEGER DEFAULT 0, + total_people INTEGER DEFAULT 0, + source VARCHAR(50) DEFAULT 'manual', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_recon_daily_date ON recon_daily_stats(business_date); + +-- ============================================ +-- RECONCILIATION: SALES BREAKDOWN +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_sales_breakdown ( + id SERIAL PRIMARY KEY, + business_date DATE NOT NULL, + category VARCHAR(100) NOT NULL, + net_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_sales_date ON recon_sales_breakdown(business_date); + +-- ============================================ +-- RECONCILIATION: FLOAT COUNTS (Petty Cash, Change Tin, Safe Cash) +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_float_counts ( + id SERIAL PRIMARY KEY, + count_type VARCHAR(20) NOT NULL CHECK (count_type IN ('petty_cash', 'change_tin', 'safe_cash')), + count_date TIMESTAMP NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT NOW(), + total_counted DECIMAL(10,2) DEFAULT 0.00, + total_receipts DECIMAL(10,2) DEFAULT 0.00, + target_amount DECIMAL(10,2) DEFAULT 0.00, + variance DECIMAL(10,2) DEFAULT 0.00, + notes TEXT +); + +CREATE INDEX IF NOT EXISTS idx_recon_floats_type ON recon_float_counts(count_type); +CREATE INDEX IF NOT EXISTS idx_recon_floats_date ON recon_float_counts(count_date); + +-- ============================================ +-- RECONCILIATION: FLOAT DENOMINATION COUNTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_float_denominations ( + id SERIAL PRIMARY KEY, + float_count_id INTEGER NOT NULL REFERENCES recon_float_counts(id) ON DELETE CASCADE, + denomination_value DECIMAL(10,2) NOT NULL, + quantity INTEGER DEFAULT 0, + total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 +); + +CREATE INDEX IF NOT EXISTS idx_recon_float_denoms_fc ON recon_float_denominations(float_count_id); + +-- ============================================ +-- RECONCILIATION: FLOAT RECEIPTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_float_receipts ( + id SERIAL PRIMARY KEY, + float_count_id INTEGER NOT NULL REFERENCES recon_float_counts(id) ON DELETE CASCADE, + receipt_value DECIMAL(10,2) NOT NULL, + receipt_description VARCHAR(255) +); + +CREATE INDEX IF NOT EXISTS idx_recon_float_receipts_fc ON recon_float_receipts(float_count_id); + +-- ============================================ +-- RECONCILIATION: CASH COUNT ATTACHMENTS +-- ============================================ + +CREATE TABLE IF NOT EXISTS recon_attachments ( + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES recon_cash_ups(id) ON DELETE CASCADE, + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_type VARCHAR(50) NOT NULL, + file_size BIGINT NOT NULL, + uploaded_by INTEGER NOT NULL REFERENCES users(id), + uploaded_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_recon_attachments_cashup ON recon_attachments(cash_up_id); + +-- ============================================ +-- RECONCILIATION: SYSTEM CONFIG ENTRIES +-- ============================================ + +INSERT INTO system_config (config_key, config_value, description) VALUES +('recon_expected_till_float', '300.00', 'Expected till float amount (GBP)'), +('recon_variance_threshold', '10.00', 'Variance threshold for highlighting (GBP)'), +('recon_default_report_days', '7', 'Default number of days for multi-day reports'), +('recon_petty_cash_target', '200.00', 'Target amount for petty cash float'), +('recon_change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20.00,"1.00":20.00,"0.50":10.00,"0.20":10.00,"0.10":5.00,"0.05":5.00}', 'Change tin denomination breakdown targets (JSON)'), +('recon_denominations', '{"notes":[50.00,20.00,10.00,5.00],"coins":[2.00,1.00,0.50,0.20,0.10,0.05,0.02,0.01]}', 'GBP denominations configuration (JSON)'), +('recon_sales_breakdown_columns', '[]', 'Sales breakdown column configuration (JSON)'), +('recon_safe_cash_target', '0.00', 'Target amount for safe cash float') +ON CONFLICT (config_key) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..542e2f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + backend: + build: ./backend + security_opt: + - apparmor=unconfined + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - APP_SLUG=forecasting + - NEWBOOK_API_KEY=${NEWBOOK_API_KEY:-} + - NEWBOOK_USERNAME=${NEWBOOK_USERNAME:-} + - NEWBOOK_PASSWORD=${NEWBOOK_PASSWORD:-} + - NEWBOOK_REGION=${NEWBOOK_REGION:-AU} + - RESOS_API_KEY=${RESOS_API_KEY:-} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-} + volumes: + - recon_uploads:/app/uploads/reconciliation + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 60s + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + VITE_HOTEL_NAME: ${VITE_HOTEL_NAME:-Hotel} + security_opt: + - apparmor=unconfined + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + driver: bridge + +volumes: + recon_uploads: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..5eb6377 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-alpine AS builder + +ARG VITE_HOTEL_NAME=Hotel +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME + +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html/forecasting +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..441595e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Forecasting + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..6971442 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name _; + + # 1. Central auth proxy + location /forecasting/api/auth/ { + proxy_pass http://10.10.10.101:3001/api/auth/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + # 2. App backend (Python FastAPI on port 8000) + location /forecasting/api/ { + proxy_pass http://backend:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Cookie $http_cookie; + proxy_read_timeout 300s; + proxy_connect_timeout 30s; + client_max_body_size 50M; + } + + # 3. Health + location /forecasting/health { + proxy_pass http://backend:8000/health; + } + + # 4. SPA fallback + location /forecasting/ { + root /usr/share/nginx/html; + try_files $uri $uri/ /forecasting/index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..aa2b4d2 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5509 @@ +{ + "name": "forecasting-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "forecasting-frontend", + "version": "1.0.0", + "dependencies": { + "@tanstack/react-query": "^5.32.0", + "axios": "^1.6.8", + "lucide-react": "^0.395.0", + "plotly.js": "^2.29.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-plotly.js": "^2.6.0", + "react-router-dom": "^6.22.0" + }, + "devDependencies": { + "@types/plotly.js": "^2.12.29", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "@types/react-plotly.js": "^2.6.4", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@choojs/findup": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", + "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", + "license": "MIT", + "dependencies": { + "commander": "^2.15.1" + }, + "bin": { + "findup": "bin/findup.js" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC" + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/@plotly/d3": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.2.tgz", + "integrity": "sha512-wvsNmh1GYjyJfyEBPKJLTMzgf2c2bEbSIL50lmqVUi+o1NHaLPi1Lb4v7VxXXJn043BhNyrxUrWI85Q+zmjOVA==", + "license": "BSD-3-Clause" + }, + "node_modules/@plotly/d3-sankey": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey/-/d3-sankey-0.7.2.tgz", + "integrity": "sha512-2jdVos1N3mMp3QW0k2q1ph7Gd6j5PY1YihBrwpkFnKqO+cqtZq3AdEYUeSGXMeLsBDQYiqTVcihYfk8vr5tqhw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1", + "d3-collection": "1", + "d3-shape": "^1.2.0" + } + }, + "node_modules/@plotly/d3-sankey-circular": { + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@plotly/d3-sankey-circular/-/d3-sankey-circular-0.33.1.tgz", + "integrity": "sha512-FgBV1HEvCr3DV7RHhDsPXyryknucxtfnLwPtCKKxdolKyTFYoLX/ibEfX39iFYIL7DYbVeRtP43dbFcrHNE+KQ==", + "license": "MIT", + "dependencies": { + "d3-array": "^1.2.1", + "d3-collection": "^1.0.4", + "d3-shape": "^1.2.0", + "elementary-circuits-directed-graph": "^1.0.4" + } + }, + "node_modules/@plotly/mapbox-gl": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@plotly/mapbox-gl/-/mapbox-gl-1.13.4.tgz", + "integrity": "sha512-sR3/Pe5LqT/fhYgp4rT4aSFf1rTsxMbGiH6Hojc7PH36ny5Bn17iVFUjpzycafETURuFbLZUfjODO8LvSI+5zQ==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/@plotly/point-cluster": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@plotly/point-cluster/-/point-cluster-3.1.9.tgz", + "integrity": "sha512-MwaI6g9scKf68Orpr1pHZ597pYx9uP8UEFXLPbsCmuw3a84obwz6pnMXGc90VhgDNeNiLEdlmuK7CPo+5PIxXw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "binary-search-bounds": "^2.0.4", + "clamp": "^1.0.1", + "defined": "^1.0.0", + "dtype": "^2.0.0", + "flatten-vertex-data": "^1.0.2", + "is-obj": "^1.0.1", + "math-log2": "^1.0.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@turf/area": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/area/-/area-7.3.5.tgz", + "integrity": "sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/bbox": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/bbox/-/bbox-7.3.5.tgz", + "integrity": "sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/centroid": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/centroid/-/centroid-7.3.5.tgz", + "integrity": "sha512-hkWaqwGFdOn6Tf0EWfn2yn1XZ1FWE1h2C5ZWstDMu/FxYO5DB+YjlmOFPl4K6SmSOEgdV07eK2vDCyPeTHqKGA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", + "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/meta": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz", + "integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "node_modules/@types/plotly.js": { + "version": "2.35.14", + "resolved": "https://registry.npmjs.org/@types/plotly.js/-/plotly.js-2.35.14.tgz", + "integrity": "sha512-CcD/32JcK19+xWH4FFpmYez/5X9kOjUcBr8Hxh7gQ/3Z32gIoLLy/L9xvC7DG5YikPvJjq6QN05B9+MCRu/Ncw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-plotly.js": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@types/react-plotly.js/-/react-plotly.js-2.6.4.tgz", + "integrity": "sha512-AU6w1u3qEGM0NmBA69PaOgNc0KPFA/+qkH6Uu9EBTJ45/WYOUoXi9AF5O15PRM2klpHSiHAAs4WnlI+OZAFmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/plotly.js": "*", + "@types/react": "*" + } + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/abs-svg-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", + "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/almost-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/almost-equal/-/almost-equal-1.1.0.tgz", + "integrity": "sha512-0V/PkoculFl5+0Lp47JoxUcO0xSxhIBvm+BxHdD/OgXNmdRpRHCFnKVuUoWyS9EzQP+otSGv0m9Lb4yVkQBn2A==", + "license": "MIT" + }, + "node_modules/array-bounds": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-bounds/-/array-bounds-1.0.1.tgz", + "integrity": "sha512-8wdW3ZGk6UjMPJx/glyEt0sLzzwAE1bhToPsO1W2pbpR2gULyxe3BjSiuJFheP50T/GgODVPz2fuMUmIywt8cQ==", + "license": "MIT" + }, + "node_modules/array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-normalize": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array-normalize/-/array-normalize-1.1.4.tgz", + "integrity": "sha512-fCp0wKFLjvSPmCn4F5Tiw4M3lpMZoHlCjfcs7nNzuj3vqQQ1/a8cgB9DXcpDSn18c+coLnaW7rqfcYCvKbyJXg==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.0" + } + }, + "node_modules/array-range": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-range/-/array-range-1.0.1.tgz", + "integrity": "sha512-shdaI1zT3CVNL2hnx9c0JMc0ZogGaxDs5e85akgHWKYa0yVbIyp06Ind3dVkTj/uuFrzaHBOyqFzo+VV6aXgtA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-search-bounds": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz", + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==", + "license": "MIT" + }, + "node_modules/bit-twiddle": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", + "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==", + "license": "MIT" + }, + "node_modules/bitmap-sdf": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", + "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", + "license": "MIT" + }, + "node_modules/bl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.1.tgz", + "integrity": "sha512-6Pesp1w0DEX1N550i/uGV/TqucVL4AM/pgThFSN/Qq9si1/DF9aIHs1BxD8V/QU0HoeHO6cQRTAuYnLPKq1e4g==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-fit": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/canvas-fit/-/canvas-fit-1.5.0.tgz", + "integrity": "sha512-onIcjRpz69/Hx5bB5HGbYKUF2uC6QT6Gp+pfpGm3A7mPfcluSLV5v4Zu+oflDUwLdUw0rLIBhUbi0v8hM4FJQQ==", + "license": "MIT", + "dependencies": { + "element-size": "^1.1.1" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clamp": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz", + "integrity": "sha512-kgMuFyE78OC6Dyu3Dy7vcx4uy97EIbVxJB/B0eJ3bUNAkwdNcxYzgKltnyADiYwsR7SEqkkUPsEUT//OVS6XMA==", + "license": "MIT" + }, + "node_modules/color-alpha": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/color-alpha/-/color-alpha-1.0.4.tgz", + "integrity": "sha512-lr8/t5NPozTSqli+duAN+x+no/2WaKTeWvxhHGN+aXT6AJ8vPlzLa7UriyjWak0pSC2jHol9JgjBYnnHsGha9A==", + "license": "MIT", + "dependencies": { + "color-parse": "^1.3.8" + } + }, + "node_modules/color-alpha/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz", + "integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-normalize": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/color-normalize/-/color-normalize-1.5.0.tgz", + "integrity": "sha512-rUT/HDXMr6RFffrR53oX3HGWkDOP9goSAQGBkUaAYKjOE2JxozccdGyufageWDlInRAjm/jYPrf/Y38oa+7obw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-rgba": "^2.1.1", + "dtype": "^2.0.0" + } + }, + "node_modules/color-parse": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-2.0.0.tgz", + "integrity": "sha512-g2Z+QnWsdHLppAbrpcFWo629kLOnOPtpxYV69GCqm92gqSgyXbzlfyN3MXs0412fPBkFmiuS+rXposgBgBa6Kg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-rgba": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/color-rgba/-/color-rgba-2.1.1.tgz", + "integrity": "sha512-VaX97wsqrMwLSOR6H7rU1Doa2zyVdmShabKrPEIFywLlHoibgD3QW9Dw6fSqM4+H/LfjprDNAUUW31qEQcGzNw==", + "license": "MIT", + "dependencies": { + "clamp": "^1.0.1", + "color-parse": "^1.3.8", + "color-space": "^1.14.6" + } + }, + "node_modules/color-rgba/node_modules/color-parse": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/color-parse/-/color-parse-1.4.3.tgz", + "integrity": "sha512-BADfVl/FHkQkyo8sRBwMYBqemqsgnu7JZAwUgvBvuwwuNUZAhSvLTbsEErS5bQXzOjDR0dWzJ4vXN2Q+QoPx0A==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0" + } + }, + "node_modules/color-space": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/color-space/-/color-space-1.16.0.tgz", + "integrity": "sha512-A6WMiFzunQ8KEPFmj02OnnoUnqhmSaHaZ/0LVFcPTdlvm8+3aMJ5x1HRHy3bDHPkovkf4sS0f4wsVvwk71fKkg==", + "license": "MIT", + "dependencies": { + "hsluv": "^0.0.3", + "mumath": "^3.3.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/country-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/country-regex/-/country-regex-1.1.0.tgz", + "integrity": "sha512-iSPlClZP8vX7MC3/u6s3lrDuoQyhQukh5LyABJ3hvfzbQ3Yyayd4fp04zjLnfi267B/B2FkumcWWgrbban7sSA==", + "license": "MIT" + }, + "node_modules/css-font": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-font/-/css-font-1.2.0.tgz", + "integrity": "sha512-V4U4Wps4dPDACJ4WpgofJ2RT5Yqwe1lEH6wlOOaIxMi0gTjdIijsc5FmxQlZ7ZZyKQkkutqqvULOp07l9c7ssA==", + "license": "MIT", + "dependencies": { + "css-font-size-keywords": "^1.0.0", + "css-font-stretch-keywords": "^1.0.1", + "css-font-style-keywords": "^1.0.1", + "css-font-weight-keywords": "^1.0.0", + "css-global-keywords": "^1.0.1", + "css-system-font-keywords": "^1.0.0", + "pick-by-alias": "^1.2.0", + "string-split-by": "^1.0.0", + "unquote": "^1.1.0" + } + }, + "node_modules/css-font-size-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", + "license": "MIT" + }, + "node_modules/css-font-stretch-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", + "license": "MIT" + }, + "node_modules/css-font-style-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", + "license": "MIT" + }, + "node_modules/css-font-weight-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", + "license": "MIT" + }, + "node_modules/css-global-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/css-global-keywords/-/css-global-keywords-1.0.1.tgz", + "integrity": "sha512-X1xgQhkZ9n94WDwntqst5D/FKkmiU0GlJSFZSV3kLvyJ1WC5VeyoXDOuleUD+SIuH9C7W05is++0Woh0CGfKjQ==", + "license": "MIT" + }, + "node_modules/css-loader": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.4.tgz", + "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-system-font-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", + "license": "MIT" + }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/d3-array": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", + "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-collection": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", + "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", + "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-force": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", + "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-collection": "1", + "d3-dispatch": "1", + "d3-quadtree": "1", + "d3-timer": "1" + } + }, + "node_modules/d3-format": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", + "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", + "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1" + } + }, + "node_modules/d3-geo-projection": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-2.9.0.tgz", + "integrity": "sha512-ZULvK/zBn87of5rWAfFMc9mJOipeSo57O+BBitsKIXmU4rTVAnX1kSsJkE0R+TxY8pGNoM1nbyRRE7GYHhdOEQ==", + "license": "BSD-3-Clause", + "dependencies": { + "commander": "2", + "d3-array": "1", + "d3-geo": "^1.12.0", + "resolve": "^1.1.10" + }, + "bin": { + "geo2svg": "bin/geo2svg", + "geograticule": "bin/geograticule", + "geoproject": "bin/geoproject", + "geoquantize": "bin/geoquantize", + "geostitch": "bin/geostitch" + } + }, + "node_modules/d3-hierarchy": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", + "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-quadtree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", + "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-time": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", + "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-time-format": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", + "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-time": "1" + } + }, + "node_modules/d3-timer": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", + "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", + "license": "BSD-3-Clause" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/defined": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", + "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-kerning": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-kerning/-/detect-kerning-2.1.2.tgz", + "integrity": "sha512-I3JIbrnKPAntNLl1I6TpSQQdQ4AutYzv/sKMFKbepawV/hlH0GmYKhUoOEMd4xqaUHT+Bm0f4127lh5qs1m1tw==", + "license": "MIT" + }, + "node_modules/draw-svg-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/draw-svg-path/-/draw-svg-path-1.0.0.tgz", + "integrity": "sha512-P8j3IHxcgRMcY6sDzr0QvJDLzBnJJqpTG33UZ2Pvp8rw0apCHhJCWqYprqrXjrgHnJ6tuhP1iTJSAodPDHxwkg==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "~0.1.1", + "normalize-svg-path": "~0.1.0" + } + }, + "node_modules/dtype": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz", + "integrity": "sha512-s2YVcLKdFGS0hpFqJaTwscsyt0E8nNFdmo73Ocd81xNPj4URI4rj6D60A+vFMIw7BXWlb4yRkEwfBqcZzPGiZg==", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dup/-/dup-1.0.0.tgz", + "integrity": "sha512-Bz5jxMMC0wgp23Zm15ip1x8IhYRqJvF3nFC0UInJUDkN1z4uNPk9jTnfCUJXbOGiQ1JbXLQsiV41Fb+HXcj5BA==", + "license": "MIT" + }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "license": "ISC" + }, + "node_modules/element-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/element-size/-/element-size-1.1.1.tgz", + "integrity": "sha512-eaN+GMOq/Q+BIWy0ybsgpcYImjGIdNLyjLFJU4XsLHXYQao5jCNb36GyN6C2qwmDDYSfIBmKpPpr4VnBdLCsPQ==", + "license": "MIT" + }, + "node_modules/elementary-circuits-directed-graph": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/elementary-circuits-directed-graph/-/elementary-circuits-directed-graph-1.3.1.tgz", + "integrity": "sha512-ZEiB5qkn2adYmpXGnJKkxT8uJHlW/mxmBpmeqawEHzPxh9HkLD4/1mFYX5l0On+f6rcPIt8/EWlRU2Vo3fX6dQ==", + "license": "MIT", + "dependencies": { + "strongly-connected-components": "^1.0.1" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", + "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "license": "MIT", + "peer": true + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-scope/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/falafel": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/falafel/-/falafel-2.2.5.tgz", + "integrity": "sha512-HuC1qF9iTnHDnML9YZAdCDQwT0yKl/U55K4XSUXqGAA2GLoafFgWRqdAbhWJxXaYD4pyoVxAJ8wH670jMpI9DQ==", + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "isarray": "^2.0.1" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, + "node_modules/fast-isnumeric": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-isnumeric/-/fast-isnumeric-1.1.4.tgz", + "integrity": "sha512-1mM8qOr2LYz8zGaUdmiqRDiuue00Dxjgcb1NQR7TnhLVh6sQyngP9xvLo7Sl7LZpP/sk5eb+bcyWXw530NTBZw==", + "license": "MIT", + "dependencies": { + "is-string-blank": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/flatten-vertex-data": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz", + "integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==", + "license": "MIT", + "dependencies": { + "dtype": "^2.0.0" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/font-atlas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/font-atlas/-/font-atlas-2.1.0.tgz", + "integrity": "sha512-kP3AmvX+HJpW4w3d+PiPR2X6E1yvsBXt2yhuCw+yReO9F1WYhvZwx3c95DGZGwg9xYzDGrgJYa885xmVA+28Cg==", + "license": "MIT", + "dependencies": { + "css-font": "^1.0.0" + } + }, + "node_modules/font-measure": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/font-measure/-/font-measure-1.2.2.tgz", + "integrity": "sha512-mRLEpdrWzKe9hbfaF3Qpr06TAjquuBVP5cHy4b3hyeNdjc9i0PO6HniGsX5vjL5OWv7+Bd++NiooNpT/s8BvIA==", + "license": "MIT", + "dependencies": { + "css-font": "^1.2.0" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/from2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", + "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "readable-stream": "^2.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/get-canvas-context": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-canvas-context/-/get-canvas-context-1.0.2.tgz", + "integrity": "sha512-LnpfLf/TNzr9zVOGiIY6aKCz8EKuXmlYNV7CM2pUjBa/B+c2I15tS7KLySep75+FuerJdmArvJLcsAXWEy2H0A==", + "license": "MIT" + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gl-mat4": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gl-mat4/-/gl-mat4-1.2.0.tgz", + "integrity": "sha512-sT5C0pwB1/e9G9AvAoLsoaJtbMGjfd/jfxo8jMCKqYYEnjZuFvqV5rehqar0538EmssjdDeiEWnKyBSTw7quoA==", + "license": "Zlib" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/gl-text": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/gl-text/-/gl-text-1.4.0.tgz", + "integrity": "sha512-o47+XBqLCj1efmuNyCHt7/UEJmB9l66ql7pnobD6p+sgmBUdzfMZXIF0zD2+KRfpd99DJN+QXdvTFAGCKCVSmQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.2", + "color-normalize": "^1.5.0", + "css-font": "^1.2.0", + "detect-kerning": "^2.1.2", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "font-atlas": "^2.1.0", + "font-measure": "^1.2.2", + "gl-util": "^3.1.2", + "is-plain-obj": "^1.1.0", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "parse-unit": "^1.0.1", + "pick-by-alias": "^1.2.0", + "regl": "^2.0.0", + "to-px": "^1.0.1", + "typedarray-pool": "^1.1.0" + } + }, + "node_modules/gl-util": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/gl-util/-/gl-util-3.1.3.tgz", + "integrity": "sha512-dvRTggw5MSkJnCbh74jZzSoTOGnVYK+Bt+Ckqm39CVcl6+zSsxqWk4lr5NKhkqXHL6qvZAU9h17ZF8mIskY9mA==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1", + "is-firefox": "^1.0.3", + "is-plain-obj": "^1.1.0", + "number-is-integer": "^1.0.1", + "object-assign": "^4.1.0", + "pick-by-alias": "^1.2.0", + "weak-map": "^1.0.5" + } + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/glsl-inject-defines": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/glsl-inject-defines/-/glsl-inject-defines-1.0.3.tgz", + "integrity": "sha512-W49jIhuDtF6w+7wCMcClk27a2hq8znvHtlGnrYkSWEr8tHe9eA2dcnohlcAmxLYBSpSSdzOkRdyPTrx9fw49+A==", + "license": "MIT", + "dependencies": { + "glsl-token-inject-block": "^1.0.0", + "glsl-token-string": "^1.0.1", + "glsl-tokenizer": "^2.0.2" + } + }, + "node_modules/glsl-resolve": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/glsl-resolve/-/glsl-resolve-0.0.1.tgz", + "integrity": "sha512-xxFNsfnhZTK9NBhzJjSBGX6IOqYpvBHxxmo+4vapiljyGNCY0Bekzn0firQkQrazK59c1hYxMDxYS8MDlhw4gA==", + "license": "MIT", + "dependencies": { + "resolve": "^0.6.1", + "xtend": "^2.1.2" + } + }, + "node_modules/glsl-resolve/node_modules/resolve": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-0.6.3.tgz", + "integrity": "sha512-UHBY3viPlJKf85YijDUcikKX6tmF4SokIDp518ZDVT92JNDcG5uKIthaT/owt3Sar0lwtOafsQuwrg22/v2Dwg==", + "license": "MIT" + }, + "node_modules/glsl-resolve/node_modules/xtend": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.2.0.tgz", + "integrity": "sha512-SLt5uylT+4aoXxXuwtQp5ZnMMzhDb1Xkg4pEqc00WUJCQifPfV9Ub1VrNhp9kXkrjZD2I2Hl8WnjP37jzZLPZw==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/glsl-token-assignments": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-assignments/-/glsl-token-assignments-2.0.2.tgz", + "integrity": "sha512-OwXrxixCyHzzA0U2g4btSNAyB2Dx8XrztY5aVUCjRSh4/D0WoJn8Qdps7Xub3sz6zE73W3szLrmWtQ7QMpeHEQ==", + "license": "MIT" + }, + "node_modules/glsl-token-defines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-defines/-/glsl-token-defines-1.0.0.tgz", + "integrity": "sha512-Vb5QMVeLjmOwvvOJuPNg3vnRlffscq2/qvIuTpMzuO/7s5kT+63iL6Dfo2FYLWbzuiycWpbC0/KV0biqFwHxaQ==", + "license": "MIT", + "dependencies": { + "glsl-tokenizer": "^2.0.0" + } + }, + "node_modules/glsl-token-depth": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-depth/-/glsl-token-depth-1.1.2.tgz", + "integrity": "sha512-eQnIBLc7vFf8axF9aoi/xW37LSWd2hCQr/3sZui8aBJnksq9C7zMeUYHVJWMhFzXrBU7fgIqni4EhXVW4/krpg==", + "license": "MIT" + }, + "node_modules/glsl-token-descope": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/glsl-token-descope/-/glsl-token-descope-1.0.2.tgz", + "integrity": "sha512-kS2PTWkvi/YOeicVjXGgX5j7+8N7e56srNDEHDTVZ1dcESmbmpmgrnpjPcjxJjMxh56mSXYoFdZqb90gXkGjQw==", + "license": "MIT", + "dependencies": { + "glsl-token-assignments": "^2.0.0", + "glsl-token-depth": "^1.1.0", + "glsl-token-properties": "^1.0.0", + "glsl-token-scope": "^1.1.0" + } + }, + "node_modules/glsl-token-inject-block": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/glsl-token-inject-block/-/glsl-token-inject-block-1.1.0.tgz", + "integrity": "sha512-q/m+ukdUBuHCOtLhSr0uFb/qYQr4/oKrPSdIK2C4TD+qLaJvqM9wfXIF/OOBjuSA3pUoYHurVRNao6LTVVUPWA==", + "license": "MIT" + }, + "node_modules/glsl-token-properties": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-properties/-/glsl-token-properties-1.0.1.tgz", + "integrity": "sha512-dSeW1cOIzbuUoYH0y+nxzwK9S9O3wsjttkq5ij9ZGw0OS41BirKJzzH48VLm8qLg+au6b0sINxGC0IrGwtQUcA==", + "license": "MIT" + }, + "node_modules/glsl-token-scope": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/glsl-token-scope/-/glsl-token-scope-1.1.2.tgz", + "integrity": "sha512-YKyOMk1B/tz9BwYUdfDoHvMIYTGtVv2vbDSLh94PT4+f87z21FVdou1KNKgF+nECBTo0fJ20dpm0B1vZB1Q03A==", + "license": "MIT" + }, + "node_modules/glsl-token-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glsl-token-string/-/glsl-token-string-1.0.1.tgz", + "integrity": "sha512-1mtQ47Uxd47wrovl+T6RshKGkRRCYWhnELmkEcUAPALWGTFe2XZpH3r45XAwL2B6v+l0KNsCnoaZCSnhzKEksg==", + "license": "MIT" + }, + "node_modules/glsl-token-whitespace-trim": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/glsl-token-whitespace-trim/-/glsl-token-whitespace-trim-1.0.0.tgz", + "integrity": "sha512-ZJtsPut/aDaUdLUNtmBYhaCmhIjpKNg7IgZSfX5wFReMc2vnj8zok+gB/3Quqs0TsBSX/fGnqUUYZDqyuc2xLQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", + "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", + "license": "MIT", + "dependencies": { + "through2": "^0.6.3" + } + }, + "node_modules/glsl-tokenizer/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/readable-stream": { + "version": "1.0.34", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "node_modules/glsl-tokenizer/node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/glsl-tokenizer/node_modules/through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", + "license": "MIT", + "dependencies": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/glslify": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/glslify/-/glslify-7.1.1.tgz", + "integrity": "sha512-bud98CJ6kGZcP9Yxcsi7Iz647wuDz3oN+IZsjCRi5X1PI7t/xPKeL0mOwXJjo+CRZMqvq0CkSJiywCcY7kVYog==", + "license": "MIT", + "dependencies": { + "bl": "^2.2.1", + "concat-stream": "^1.5.2", + "duplexify": "^3.4.5", + "falafel": "^2.1.0", + "from2": "^2.3.0", + "glsl-resolve": "0.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glslify-bundle": "^5.0.0", + "glslify-deps": "^1.2.5", + "minimist": "^1.2.5", + "resolve": "^1.1.5", + "stack-trace": "0.0.9", + "static-eval": "^2.0.5", + "through2": "^2.0.1", + "xtend": "^4.0.0" + }, + "bin": { + "glslify": "bin.js" + } + }, + "node_modules/glslify-bundle": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/glslify-bundle/-/glslify-bundle-5.1.1.tgz", + "integrity": "sha512-plaAOQPv62M1r3OsWf2UbjN0hUYAB7Aph5bfH58VxJZJhloRNbxOL9tl/7H71K7OLJoSJ2ZqWOKk3ttQ6wy24A==", + "license": "MIT", + "dependencies": { + "glsl-inject-defines": "^1.0.1", + "glsl-token-defines": "^1.0.0", + "glsl-token-depth": "^1.1.1", + "glsl-token-descope": "^1.0.2", + "glsl-token-scope": "^1.1.1", + "glsl-token-string": "^1.0.1", + "glsl-token-whitespace-trim": "^1.0.0", + "glsl-tokenizer": "^2.0.2", + "murmurhash-js": "^1.0.0", + "shallow-copy": "0.0.1" + } + }, + "node_modules/glslify-deps": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/glslify-deps/-/glslify-deps-1.3.2.tgz", + "integrity": "sha512-7S7IkHWygJRjcawveXQjRXLO2FTjijPDYC7QfZyAQanY+yGLCFHYnPtsGT9bdyHiwPTw/5a1m1M9hamT2aBpag==", + "license": "ISC", + "dependencies": { + "@choojs/findup": "^0.2.0", + "events": "^3.2.0", + "glsl-resolve": "0.0.1", + "glsl-tokenizer": "^2.0.0", + "graceful-fs": "^4.1.2", + "inherits": "^2.0.1", + "map-limit": "0.0.1", + "resolve": "^1.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/has-hover": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-hover/-/has-hover-1.0.1.tgz", + "integrity": "sha512-0G6w7LnlcpyDzpeGUTuT0CEw05+QlMuGVk1IHNAlHrGJITGodjZu3x8BNDUMfKJSZXNB2ZAclqc1bvrd+uUpfg==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-passive-events": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-passive-events/-/has-passive-events-1.0.0.tgz", + "integrity": "sha512-2vSj6IeIsgvsRMyeQ0JaCX5Q3lX4zMn5HpoVc7MEhQ6pv8Iq9rsXjsp+E5ZwaT7T0xhMT0KmU8gtt1EFVdbJiw==", + "license": "MIT", + "dependencies": { + "is-browser": "^2.0.1" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hsluv": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/hsluv/-/hsluv-0.0.3.tgz", + "integrity": "sha512-08iL2VyCRbkQKBySkSh6m8zMUa3sADAxGVWs3Z1aPcUkTJeK0ETG4Fc27tEmQBGUAXZjIsXOZqBvacuVNSC/fQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/is-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz", + "integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finite": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", + "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-firefox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-firefox/-/is-firefox-1.0.3.tgz", + "integrity": "sha512-6Q9ITjvWIm0Xdqv+5U12wgOKEM2KoBw4Y926m0OFkvlCxnbG94HKAsVz8w3fWcfAS5YA2fJORXX1dLrkprCCxA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-mobile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-4.0.0.tgz", + "integrity": "sha512-mlcHZA84t1qLSuWkt2v0I2l61PYdyQDt4aG1mLIXF5FDMm4+haBCxCPYSr/uwqQNRk1MiTizn0ypEuRAOLRAew==", + "license": "MIT" + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-string-blank": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-string-blank/-/is-string-blank-1.0.1.tgz", + "integrity": "sha512-9H+ZBCVs3L9OYqv8nuUAzpcT9OTgMD1yAWrG7ihlnibdkbtB850heAmYWxHuXc4CHy4lKeK69tN+ny1K7gBIrw==", + "license": "MIT" + }, + "node_modules/is-svg-path": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-svg-path/-/is-svg-path-1.0.2.tgz", + "integrity": "sha512-Lj4vePmqpPR1ZnRctHv8ltSh1OrSxHkhUkd7wi+VQdcdP15/KvQFyk7LhNuM7ZW0EVbJz8kZLVmL9quLrfq4Kg==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.395.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.395.0.tgz", + "integrity": "sha512-6hzdNH5723A4FLaYZWpK50iyZH8iS2Jq5zuPRRotOFkhu6kxxJiebVdJ72tCR5XkiIeYFOU5NUawFZOac+VeYw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/map-limit": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/map-limit/-/map-limit-0.0.1.tgz", + "integrity": "sha512-pJpcfLPnIF/Sk3taPW21G/RQsEEirGaFpCW3oXRwH9dnFHPHNGjNyvh++rdmC2fNqEaTw2MhYJraoJWAHx8kEg==", + "license": "MIT", + "dependencies": { + "once": "~1.3.0" + } + }, + "node_modules/map-limit/node_modules/once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/geojson-vt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/maplibre-gl/node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-log2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/math-log2/-/math-log2-1.0.1.tgz", + "integrity": "sha512-9W0yGtkaMAkf74XGYVy4Dqw3YUMnTNB2eeiw9aQbUl4A3KmuCEHTt2DgAB07ENzOYAjsYSAYufkAq0Zd+jU7zA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/mouse-change": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/mouse-change/-/mouse-change-1.4.0.tgz", + "integrity": "sha512-vpN0s+zLL2ykyyUDh+fayu9Xkor5v/zRD9jhSqjRS1cJTGS0+oakVZzNm5n19JvvEj0you+MXlYTpNxUDQUjkQ==", + "license": "MIT", + "dependencies": { + "mouse-event": "^1.0.0" + } + }, + "node_modules/mouse-event": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/mouse-event/-/mouse-event-1.0.5.tgz", + "integrity": "sha512-ItUxtL2IkeSKSp9cyaX2JLUuKk2uMoxBg4bbOWVd29+CskYJR9BGsUqtXenNzKbnDshvupjUewDIYVrOB6NmGw==", + "license": "MIT" + }, + "node_modules/mouse-event-offset": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mouse-event-offset/-/mouse-event-offset-3.0.2.tgz", + "integrity": "sha512-s9sqOs5B1Ykox3Xo8b3Ss2IQju4UwlW6LSR+Q5FXWpprJ5fzMLefIIItr3PH8RwzfGy6gxs/4GAmiNuZScE25w==", + "license": "MIT" + }, + "node_modules/mouse-wheel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mouse-wheel/-/mouse-wheel-1.2.0.tgz", + "integrity": "sha512-+OfYBiUOCTWcTECES49neZwL5AoGkXE+lFjIvzwNCnYRlso+EnfvovcBxGoyQ0yQt806eSPjS675K0EwWknXmw==", + "license": "MIT", + "dependencies": { + "right-now": "^1.0.0", + "signum": "^1.0.0", + "to-px": "^1.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mumath": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/mumath/-/mumath-3.3.4.tgz", + "integrity": "sha512-VAFIOG6rsxoc7q/IaY3jdjmrsuX9f15KlRLYTHmixASBZkZEKC1IFqE2BC5CdhXmK6WLM1Re33z//AGmeRI6FA==", + "deprecated": "Redundant dependency in your project.", + "license": "Unlicense", + "dependencies": { + "almost-equal": "^1.1.0" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/native-promise-only": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", + "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", + "license": "MIT" + }, + "node_modules/needle": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/needle/-/needle-2.9.1.tgz", + "integrity": "sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.6", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + }, + "bin": { + "needle": "bin/needle" + }, + "engines": { + "node": ">= 4.4.x" + } + }, + "node_modules/needle/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-svg-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-0.1.0.tgz", + "integrity": "sha512-1/kmYej2iedi5+ROxkRESL/pI02pkg0OBnaR4hJkSIX6+ORzepwbuUXfrdZaPjysTsJInj0Rj5NuX027+dMBvA==", + "license": "MIT" + }, + "node_modules/number-is-integer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-integer/-/number-is-integer-1.0.1.tgz", + "integrity": "sha512-Dq3iuiFBkrbmuQjGFFF3zckXNCQoSD37/SdSbgcBailUx6knDvDwb5CympBgcoWHy36sfS12u74MHYkXyHq6bg==", + "license": "MIT", + "dependencies": { + "is-finite": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parenthesis": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/parenthesis/-/parenthesis-3.1.8.tgz", + "integrity": "sha512-KF/U8tk54BgQewkJPvB4s/US3VQY68BRDpH638+7O/n58TpnwiwnOtGIOsT2/i+M78s61BBpeC83STB88d8sqw==", + "license": "MIT" + }, + "node_modules/parse-rect": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/parse-rect/-/parse-rect-1.2.0.tgz", + "integrity": "sha512-4QZ6KYbnE6RTwg9E0HpLchUM9EZt6DnDxajFZZDSV4p/12ZJEvPO702DZpGvRYEPo00yKDys7jASi+/w7aO8LA==", + "license": "MIT", + "dependencies": { + "pick-by-alias": "^1.2.0" + } + }, + "node_modules/parse-svg-path": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.1.2.tgz", + "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", + "license": "MIT" + }, + "node_modules/parse-unit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-unit/-/parse-unit-1.0.1.tgz", + "integrity": "sha512-hrqldJHokR3Qj88EIlV/kAyAi/G5R2+R56TBANxNMy0uPlYcttx0jnMW6Yx5KsKPSbC3KddM/7qQm3+0wEXKxg==", + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT" + }, + "node_modules/pick-by-alias": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pick-by-alias/-/pick-by-alias-1.2.0.tgz", + "integrity": "sha512-ESj2+eBxhGrcA1azgHs7lARG5+5iLakc/6nlfbpjcLl00HuuUOIuORhYXN4D1HfvMSKuVtFQjAlnwi1JHEeDIw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/plotly.js": { + "version": "2.35.3", + "resolved": "https://registry.npmjs.org/plotly.js/-/plotly.js-2.35.3.tgz", + "integrity": "sha512-7RaC6FxmCUhpD6H4MpD+QLUu3hCn76I11rotRefrh3m1iDvWqGnVqVk9dSaKmRAhFD3vsNsYea0OxnR1rc2IzQ==", + "license": "MIT", + "dependencies": { + "@plotly/d3": "3.8.2", + "@plotly/d3-sankey": "0.7.2", + "@plotly/d3-sankey-circular": "0.33.1", + "@plotly/mapbox-gl": "1.13.4", + "@turf/area": "^7.1.0", + "@turf/bbox": "^7.1.0", + "@turf/centroid": "^7.1.0", + "base64-arraybuffer": "^1.0.2", + "canvas-fit": "^1.5.0", + "color-alpha": "1.0.4", + "color-normalize": "1.5.0", + "color-parse": "2.0.0", + "color-rgba": "2.1.1", + "country-regex": "^1.1.0", + "css-loader": "^7.1.2", + "d3-force": "^1.2.1", + "d3-format": "^1.4.5", + "d3-geo": "^1.12.1", + "d3-geo-projection": "^2.9.0", + "d3-hierarchy": "^1.1.9", + "d3-interpolate": "^3.0.1", + "d3-time": "^1.1.0", + "d3-time-format": "^2.2.3", + "fast-isnumeric": "^1.1.4", + "gl-mat4": "^1.2.0", + "gl-text": "^1.4.0", + "has-hover": "^1.0.1", + "has-passive-events": "^1.0.0", + "is-mobile": "^4.0.0", + "maplibre-gl": "^4.5.2", + "mouse-change": "^1.4.0", + "mouse-event-offset": "^3.0.2", + "mouse-wheel": "^1.2.0", + "native-promise-only": "^0.8.1", + "parse-svg-path": "^0.1.2", + "point-in-polygon": "^1.1.0", + "polybooljs": "^1.2.2", + "probe-image-size": "^7.2.3", + "regl": "npm:@plotly/regl@^2.1.2", + "regl-error2d": "^2.0.12", + "regl-line2d": "^3.1.3", + "regl-scatter2d": "^3.3.1", + "regl-splom": "^1.0.14", + "strongly-connected-components": "^1.0.1", + "style-loader": "^4.0.0", + "superscript-text": "^1.0.0", + "svg-path-sdf": "^1.1.3", + "tinycolor2": "^1.4.2", + "to-px": "1.0.1", + "topojson-client": "^3.1.0", + "webgl-context": "^2.2.0", + "world-calendars": "^1.0.3" + } + }, + "node_modules/point-in-polygon": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz", + "integrity": "sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw==", + "license": "MIT" + }, + "node_modules/polybooljs": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/polybooljs/-/polybooljs-1.2.2.tgz", + "integrity": "sha512-ziHW/02J0XuNuUtmidBc6GXE8YohYydp3DWPWXYsd7O721TjcmN+k6ezjdwkDqep+gnWnFY+yqZHvzElra2oCg==", + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/probe-image-size": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/probe-image-size/-/probe-image-size-7.3.0.tgz", + "integrity": "sha512-7CaDeBwiAbh6ohXsvLbAZhO7wzsZAmaevfxe39qvCwRh8LyaZfDlBGGLU1CCTgrTLtCOdwBBhjOrIHaIIimHfQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "lodash.merge": "^4.6.2", + "needle": "^2.5.2", + "stream-parser": "~0.3.1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-plotly.js": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-plotly.js/-/react-plotly.js-2.6.0.tgz", + "integrity": "sha512-g93xcyhAVCSt9kV1svqG1clAEdL6k3U+jjuSzfTV7owaSU9Go6Ph8bl25J+jKfKvIGAEYpe4qj++WHJuc9IaeA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "plotly.js": ">1.34.0", + "react": ">0.13.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/regl": { + "name": "@plotly/regl", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@plotly/regl/-/regl-2.1.2.tgz", + "integrity": "sha512-Mdk+vUACbQvjd0m/1JJjOOafmkp/EpmHjISsopEz5Av44CBq7rPC05HHNbYGKVyNUF2zmEoBS/TT0pd0SPFFyw==", + "license": "MIT" + }, + "node_modules/regl-error2d": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/regl-error2d/-/regl-error2d-2.0.12.tgz", + "integrity": "sha512-r7BUprZoPO9AbyqM5qlJesrSRkl+hZnVKWKsVp7YhOl/3RIpi4UDGASGJY0puQ96u5fBYw/OlqV24IGcgJ0McA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-line2d": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/regl-line2d/-/regl-line2d-3.1.3.tgz", + "integrity": "sha512-fkgzW+tTn4QUQLpFKsUIE0sgWdCmXAM3ctXcCgoGBZTSX5FE2A0M7aynz7nrZT5baaftLrk9te54B+MEq4QcSA==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-find-index": "^1.0.2", + "array-normalize": "^1.1.4", + "color-normalize": "^1.5.0", + "earcut": "^2.1.5", + "es6-weak-map": "^2.0.3", + "flatten-vertex-data": "^1.0.2", + "object-assign": "^4.1.1", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0" + } + }, + "node_modules/regl-scatter2d": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/regl-scatter2d/-/regl-scatter2d-3.4.0.tgz", + "integrity": "sha512-DavKQlHsI+iHZuLgOL+yGkg+sPd94CS+7FCBWkcQ6s/TbaNfUsF9eN591fjjSWIoKrGNfb/SEGhsXR5lXjqZ2w==", + "license": "MIT", + "dependencies": { + "@plotly/point-cluster": "^3.1.9", + "array-bounds": "^1.0.1", + "color-id": "^1.1.0", + "color-normalize": "^1.5.0", + "flatten-vertex-data": "^1.0.2", + "glslify": "^7.0.0", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "to-float32": "^1.1.0", + "update-diff": "^1.1.0" + } + }, + "node_modules/regl-splom": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/regl-splom/-/regl-splom-1.0.14.tgz", + "integrity": "sha512-OiLqjmPRYbd7kDlHC6/zDf6L8lxgDC65BhC8JirhP4ykrK4x22ZyS+BnY8EUinXKDeMgmpRwCvUmk7BK4Nweuw==", + "license": "MIT", + "dependencies": { + "array-bounds": "^1.0.1", + "array-range": "^1.0.1", + "color-alpha": "^1.0.4", + "flatten-vertex-data": "^1.0.2", + "parse-rect": "^1.2.0", + "pick-by-alias": "^1.2.0", + "raf": "^3.4.1", + "regl-scatter2d": "^3.2.3" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/right-now": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/right-now/-/right-now-1.0.0.tgz", + "integrity": "sha512-DA8+YS+sMIVpbsuKgy+Z67L9Lxb1p05mNxRpDPNksPDEFir4vmBlUtuN9jkTGn9YMMdlBuK7XQgFiz6ws+yhSg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shallow-copy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/shallow-copy/-/shallow-copy-0.0.1.tgz", + "integrity": "sha512-b6i4ZpVuUxB9h5gfCxPiusKYkqTMOjEbBs4wMaFbkfia4yFv92UKZ6Df8WXcKbn08JNL/abvg3FnMAOfakDvUw==", + "license": "MIT" + }, + "node_modules/signum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/signum/-/signum-1.0.0.tgz", + "integrity": "sha512-yodFGwcyt59XRh7w5W3jPcIQb3Bwi21suEfT7MAWnBX3iCdklJpgDgvGT9o04UonglZN5SNMfJFkHIR/jO8GHw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stack-trace": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.9.tgz", + "integrity": "sha512-vjUc6sfgtgY0dxCdnc40mK6Oftjo9+2K8H/NG81TMhgL392FtiPA9tn9RLyTxXmTLPJPjF3VyzFp6bsWFLisMQ==", + "engines": { + "node": "*" + } + }, + "node_modules/static-eval": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.1.1.tgz", + "integrity": "sha512-MgWpQ/ZjGieSVB3eOJVs4OA2LT/q1vx98KPCTTQPzq/aLr0YUXTsgryTXr4SLfR0ZfUUCiedM9n/ABeDIyy4mA==", + "license": "MIT", + "dependencies": { + "escodegen": "^2.1.0" + } + }, + "node_modules/stream-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/stream-parser/-/stream-parser-0.3.1.tgz", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", + "license": "MIT", + "dependencies": { + "debug": "2" + } + }, + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-split-by": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string-split-by/-/string-split-by-1.0.0.tgz", + "integrity": "sha512-KaJKY+hfpzNyet/emP81PJA9hTVSfxNLS9SFTWxdCnnW1/zOOwiV248+EfoX7IQFcBaOp4G5YE6xTJMF+pLg6A==", + "license": "MIT", + "dependencies": { + "parenthesis": "^3.1.5" + } + }, + "node_modules/strongly-connected-components": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strongly-connected-components/-/strongly-connected-components-1.0.1.tgz", + "integrity": "sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==", + "license": "MIT" + }, + "node_modules/style-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", + "license": "MIT", + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" + } + }, + "node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/supercluster/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/superscript-text": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/superscript-text/-/superscript-text-1.0.0.tgz", + "integrity": "sha512-gwu8l5MtRZ6koO0icVTlmN5pm7Dhh1+Xpe9O4x6ObMAsW+3jPbW14d1DsBq1F4wiI+WOFjXF35pslgec/G8yCQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-arc-to-cubic-bezier": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", + "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==", + "license": "ISC" + }, + "node_modules/svg-path-bounds": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/svg-path-bounds/-/svg-path-bounds-1.0.2.tgz", + "integrity": "sha512-H4/uAgLWrppIC0kHsb2/dWUYSmb4GE5UqH06uqWBcg6LBjX2fu0A8+JrO2/FJPZiSsNOKZAhyFFgsLTdYUvSqQ==", + "license": "MIT", + "dependencies": { + "abs-svg-path": "^0.1.1", + "is-svg-path": "^1.0.1", + "normalize-svg-path": "^1.0.0", + "parse-svg-path": "^0.1.2" + } + }, + "node_modules/svg-path-bounds/node_modules/normalize-svg-path": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/normalize-svg-path/-/normalize-svg-path-1.1.0.tgz", + "integrity": "sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==", + "license": "MIT", + "dependencies": { + "svg-arc-to-cubic-bezier": "^3.0.0" + } + }, + "node_modules/svg-path-sdf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/svg-path-sdf/-/svg-path-sdf-1.1.3.tgz", + "integrity": "sha512-vJJjVq/R5lSr2KLfVXVAStktfcfa1pNFjFOgyJnzZFXlO/fDZ5DmM8FpnSKKzLPfEYTVeXuVBTHF296TpxuJVg==", + "license": "MIT", + "dependencies": { + "bitmap-sdf": "^1.0.0", + "draw-svg-path": "^1.0.0", + "is-svg-path": "^1.0.1", + "parse-svg-path": "^0.1.2", + "svg-path-bounds": "^1.0.1" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/to-float32": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/to-float32/-/to-float32-1.1.0.tgz", + "integrity": "sha512-keDnAusn/vc+R3iEiSDw8TOF7gPiTLdK1ArvWtYbJQiVfmRg6i/CAvbKq3uIS0vWroAC7ZecN3DjQKw3aSklUg==", + "license": "MIT" + }, + "node_modules/to-px": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-px/-/to-px-1.0.1.tgz", + "integrity": "sha512-2y3LjBeIZYL19e5gczp14/uRWFDtDUErJPVN3VU9a7SJO+RjGRtYR47aMN2bZgGlxvW4ZcEz2ddUPVHXcMfuXw==", + "license": "MIT", + "dependencies": { + "parse-unit": "^1.0.1" + } + }, + "node_modules/topojson-client": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz", + "integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==", + "license": "ISC", + "dependencies": { + "commander": "2" + }, + "bin": { + "topo2geo": "bin/topo2geo", + "topomerge": "bin/topomerge", + "topoquantize": "bin/topoquantize" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typedarray-pool": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typedarray-pool/-/typedarray-pool-1.2.0.tgz", + "integrity": "sha512-YTSQbzX43yvtpfRtIDAYygoYtgT+Rpjuxy9iOpczrjpXLgGoyG7aS5USJXV2d3nn8uHTeb9rXDvzS27zUg5KYQ==", + "license": "MIT", + "dependencies": { + "bit-twiddle": "^1.0.0", + "dup": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-diff/-/update-diff-1.1.0.tgz", + "integrity": "sha512-rCiBPiHxZwT4+sBhEbChzpO5hYHjm91kScWgdHf4Qeafs6Ba7MBl+d9GlGv72bcTZQO0sLmtQS1pHSWoCLtN/A==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/weak-map": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/weak-map/-/weak-map-1.0.8.tgz", + "integrity": "sha512-lNR9aAefbGPpHO7AEnY0hCFjz1eTkWCXYvkTRrTHs9qv8zJp+SkVYpzfLIFXQQiG3tVvbNFQgVg2bQS8YGgxyw==", + "license": "Apache-2.0" + }, + "node_modules/webgl-context": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/webgl-context/-/webgl-context-2.2.0.tgz", + "integrity": "sha512-q/fGIivtqTT7PEoF07axFIlHNk/XCPaYpq64btnepopSWvKNFkoORlQYgqDigBIuGA1ExnFd/GnSUnBNEPQY7Q==", + "license": "MIT", + "dependencies": { + "get-canvas-context": "^1.0.1" + } + }, + "node_modules/webpack": { + "version": "5.108.4", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.108.4.tgz", + "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.2", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", + "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack/node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/world-calendars": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/world-calendars/-/world-calendars-1.0.4.tgz", + "integrity": "sha512-VGRnLJS+xJmGDPodgJRnGIDwGu0s+Cr9V2HB3EzlDZ5n0qb8h5SJtGUEkjrphZYAglEiXZ6kiXdmk0H/h/uu/w==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..07e3992 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "forecasting-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.32.0", + "axios": "^1.6.8", + "lucide-react": "^0.395.0", + "plotly.js": "^2.29.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-plotly.js": "^2.6.0", + "react-router-dom": "^6.22.0" + }, + "devDependencies": { + "@types/plotly.js": "^2.12.29", + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", + "@types/react-plotly.js": "^2.6.4", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..69aed7a --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,34 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import AuthGate from './components/AuthGate' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Forecasts from './pages/Forecasts' +import History from './pages/History' +import Bookability from './pages/Bookability' +import CompetitorRates from './pages/CompetitorRates' +import Accuracy from './pages/Accuracy' +import Settings from './pages/Settings' + +export default function App() { + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..065cdb1 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,20 @@ +import axios from 'axios' + +const BASE = '/forecasting/api' + +const api = axios.create({ + baseURL: BASE, + withCredentials: true, +}) + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname) + } + return Promise.reject(error) + } +) + +export default api diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..12dd399 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,47 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import type { User } from '../types' + +interface AuthCtx { user: User } +const Ctx = createContext(null) + +export function useAuth() { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useAuth outside AuthGate') + return ctx +} + +export default function AuthGate({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null) + const [checking, setChecking] = useState(true) + + useEffect(() => { + fetch('/forecasting/api/auth/verify?app=forecasting', { credentials: 'include' }) + .then(r => { + if (!r.ok) throw new Error('unauth') + return r.json() + }) + .then(data => setUser({ + email: data.email || data.sub || '', + name: data.name || data.display_name || '', + is_admin: data.is_admin ?? false, + caps: data.caps ?? [], + })) + .catch(() => { + window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname) + }) + .finally(() => setChecking(false)) + }, []) + + if (checking) { + return ( +
+
+
+ ) + } + + if (!user) return null + + return {children} +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..9e8286f --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,59 @@ +import { NavLink } from 'react-router-dom' +import { + TrendingUp, BarChart2, Calendar, Target, Globe, History, + Settings, Bot, +} from 'lucide-react' +import { useAuth } from './AuthGate' +import { can } from '../types' +import type { ReactNode } from 'react' + +const ICON = { size: 16, strokeWidth: 1.75 } + +const NAV = [ + { to: '/dashboard', label: 'Dashboard', icon: Bot, cap: 'view' }, + { to: '/forecasts', label: 'Forecasts', icon: TrendingUp, cap: 'view' }, + { to: '/history', label: 'History', icon: History, cap: 'view' }, + { to: '/bookability', label: 'Bookability', icon: Calendar, cap: 'view_bookability' }, + { to: '/competitor-rates', label: 'Competitors', icon: Globe, cap: 'view_competitor_rates' }, + { to: '/accuracy', label: 'Accuracy', icon: Target, cap: 'view_accuracy' }, + { to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' }, +] + +export default function Layout({ children }: { children: ReactNode }) { + const { user } = useAuth() + const items = NAV.filter(n => can(user, n.cap)) + + return ( +
+ + +
+ + Forecasting + +
+ +
{children}
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..dda0db7 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,361 @@ +:root { + --navy: #1a1a2e; + --navy-dark: #0f0f20; + --gold: #c9a84c; + --gold-light: #e8c96d; + --surface: rgba(255,255,255,0.07); + --surface-2: rgba(255,255,255,0.08); + --text: rgba(255,255,255,0.88); + --text-muted: rgba(255,255,255,0.48); + --body-bg: #f4f5f7; + --card-bg: #ffffff; + --card-border: #e4e8ee; + --text-dark: #1e293b; + --text-mid: #64748b; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08); + --danger: #dc2626; + --success: #16a34a; + --warning: #d97706; + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--body-bg); + color: var(--text-dark); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; +} + +/* App shell layout */ +.app-shell { + display: grid; + grid-template-columns: 220px 1fr; + grid-template-rows: auto 1fr; + min-height: 100vh; +} + +.sidebar { + grid-column: 1; + grid-row: 1 / -1; + background: var(--navy); + display: flex; + flex-direction: column; + padding: 0; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.sidebar-logo { + display: flex; + align-items: center; + gap: 10px; + padding: 20px 16px 16px; + font-size: 15px; + font-weight: 600; + color: var(--text); + border-bottom: 1px solid var(--surface); +} + +.sidebar-nav { + flex: 1; + padding: 12px 8px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.sidebar-nav a { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: 8px; + color: var(--text-muted); + text-decoration: none; + font-size: 13.5px; + transition: background 0.15s, color 0.15s; +} + +.sidebar-nav a:hover { background: var(--surface); color: var(--text); } +.sidebar-nav a.active { background: rgba(201,168,76,0.15); color: var(--gold); } + +.sidebar-user { + padding: 12px 16px; + font-size: 12px; + color: var(--text-muted); + border-top: 1px solid var(--surface); +} + +/* Top bar — mobile/collapsed fallback */ +.top-bar { + display: none; +} + +.page-content { + grid-column: 2; + grid-row: 1 / -1; + min-width: 0; + padding: 24px; + background: var(--body-bg); +} + +/* Cards */ +.card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--card-border); + font-size: 14px; + font-weight: 600; + color: var(--text-dark); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-body { padding: 20px; } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + border-radius: 7px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, opacity 0.15s; +} + +.btn:disabled { opacity: 0.5; cursor: not-allowed; } + +.btn-primary { + background: var(--gold); + color: var(--navy); +} +.btn-primary:hover:not(:disabled) { background: var(--gold-light); } + +.btn-secondary { + background: var(--navy); + color: var(--text); +} +.btn-secondary:hover:not(:disabled) { background: var(--navy-dark); } + +.btn-outline { + background: transparent; + color: var(--text-dark); + border: 1px solid var(--card-border); +} +.btn-outline:hover:not(:disabled) { background: var(--body-bg); } + +.btn-danger { + background: var(--danger); + color: white; +} +.btn-danger:hover:not(:disabled) { opacity: 0.85; } + +.btn-sm { padding: 4px 10px; font-size: 12px; } + +/* Form elements */ +input, select, textarea { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 7px; + padding: 7px 10px; + font-size: 13px; + color: var(--text-dark); + width: 100%; + outline: none; + transition: border-color 0.15s; +} +input:focus, select:focus, textarea:focus { border-color: var(--gold); } + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 20px; + font-size: 11px; + font-weight: 500; +} +.badge-success { background: #dcfce7; color: #16a34a; } +.badge-warning { background: #fef3c7; color: #d97706; } +.badge-danger { background: #fee2e2; color: #dc2626; } +.badge-info { background: #dbeafe; color: #2563eb; } +.badge-neutral { background: #f1f5f9; color: #64748b; } + +/* Tables */ +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th { + text-align: left; + padding: 8px 12px; + background: #f8fafc; + border-bottom: 1px solid var(--card-border); + font-weight: 600; + color: var(--text-mid); + white-space: nowrap; +} +td { + padding: 8px 12px; + border-bottom: 1px solid #f1f5f9; + color: var(--text-dark); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: #f8fafc; } + +/* Page header */ +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + gap: 16px; +} +.page-title { + font-size: 20px; + font-weight: 700; + color: var(--text-dark); +} +.page-subtitle { font-size: 13px; color: var(--text-mid); margin-top: 2px; } + +/* Sub-nav tabs */ +.sub-nav { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--card-border); + margin-bottom: 24px; + overflow-x: auto; +} +.sub-nav-item { + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + color: var(--text-mid); + cursor: pointer; + border-bottom: 2px solid transparent; + white-space: nowrap; + background: none; + border-left: none; + border-right: none; + border-top: none; + transition: color 0.15s, border-color 0.15s; +} +.sub-nav-item:hover { color: var(--text-dark); } +.sub-nav-item.active { color: var(--gold); border-bottom-color: var(--gold); } + +/* Status dot */ +.status-dot { + width: 8px; height: 8px; + border-radius: 50%; + display: inline-block; +} +.status-dot.green { background: var(--success); } +.status-dot.yellow { background: var(--warning); } +.status-dot.red { background: var(--danger); } +.status-dot.grey { background: #94a3b8; } + +/* Spinner */ +.spinner { + width: 20px; height: 20px; + border: 2px solid var(--card-border); + border-top-color: var(--gold); + border-radius: 50%; + animation: spin 0.7s linear infinite; + display: inline-block; +} +@keyframes spin { to { transform: rotate(360deg); } } + +/* Loading / empty states */ +.loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 20px; + color: var(--text-mid); + font-size: 13px; +} + +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-mid); + font-size: 13px; +} + +/* Grid helpers */ +.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } +.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } + +/* Stat card */ +.stat-card { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: var(--radius); + padding: 16px 20px; + box-shadow: var(--shadow-sm); +} +.stat-label { font-size: 12px; color: var(--text-mid); margin-bottom: 4px; } +.stat-value { font-size: 24px; font-weight: 700; color: var(--text-dark); } +.stat-delta { font-size: 12px; margin-top: 4px; } +.stat-delta.positive { color: var(--success); } +.stat-delta.negative { color: var(--danger); } +.stat-delta.neutral { color: var(--text-mid); } + +/* Responsive — at narrow widths hide sidebar, show top-bar */ +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } + .sidebar { display: none; } + .top-bar { + display: flex; + align-items: center; + gap: 12px; + background: var(--navy); + padding: 0 16px; + height: 52px; + grid-column: 1; + overflow-x: auto; + } + .top-bar-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + margin-right: 8px; + } + .top-bar-nav { display: flex; gap: 4px; } + .top-bar-nav a { + color: var(--text-muted); + text-decoration: none; + font-size: 12.5px; + padding: 6px 10px; + border-radius: 6px; + white-space: nowrap; + } + .top-bar-nav a:hover { color: var(--text); background: var(--surface); } + .top-bar-nav a.active { color: var(--gold); } + .page-content { + grid-column: 1; + padding: 16px; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..5bb0138 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import App from './App' +import './index.css' + +const qc = new QueryClient({ + defaultOptions: { queries: { retry: 1, staleTime: 30_000 } }, +}) + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/Accuracy.tsx b/frontend/src/pages/Accuracy.tsx new file mode 100644 index 0000000..34cceba --- /dev/null +++ b/frontend/src/pages/Accuracy.tsx @@ -0,0 +1,1350 @@ +import React, { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import Plot from 'react-plotly.js' +import type Plotly from 'plotly.js' +import api from '../api' + +type AccuracyPage = 'backtest' | 'accuracy' | 'weights' | 'progress' +type GroupByOption = 'lead_time' | 'day_of_week' | 'month' + +// ============================================ +// METRICS EXPLANATION COMPONENT +// ============================================ + +const MetricsLegend: React.FC<{ type: 'accuracy' | 'weights' }> = ({ type }) => { + const [isExpanded, setIsExpanded] = useState(false) + + const accuracyMetrics = [ + { + name: 'MAPE (Mean Absolute Percentage Error)', + formula: '|Actual - Forecast| / Actual × 100', + description: 'Average percentage deviation from actual values. Useful for comparing across different scales.', + interpretation: 'Lower is better', + guide: '< 10% = Excellent, 10-20% = Good, 20-30% = Fair, > 30% = Poor', + color: '#16a34a', + }, + { + name: 'MAE (Mean Absolute Error)', + formula: '|Actual - Forecast|', + description: 'Average absolute difference between forecast and actual. In the same units as the metric (e.g., rooms or % occupancy).', + interpretation: 'Lower is better', + guide: 'Depends on metric scale - compare between models at the same lead time', + color: '#16a34a', + }, + { + name: 'Sample Size (n)', + formula: 'Count of forecast/actual pairs', + description: 'Number of data points used to calculate the accuracy. Larger samples give more reliable metrics.', + interpretation: 'Higher is more reliable', + guide: 'n > 100 is statistically meaningful', + color: '#2563eb', + }, + ] + + const weightMetrics = [ + { + name: 'Weight', + formula: '(1/MAPE) / Sum(1/MAPE for all models)', + description: 'Relative model performance based on inverse MAPE. Models with lower error get higher weight.', + interpretation: 'Higher is better', + guide: 'Weights sum to 100% within each lead time bracket. Use to blend model forecasts.', + color: '#16a34a', + }, + { + name: 'Lead Time Bracket', + formula: 'Days between forecast creation and target date', + description: 'Grouping of forecasts by how far ahead they predicted. Models may excel at different horizons.', + interpretation: 'Compare models within same bracket', + guide: '0-7 = Short-term, 8-30 = Medium-term, 31+ = Long-term', + color: '#2563eb', + }, + ] + + const metrics = type === 'accuracy' ? accuracyMetrics : weightMetrics + + return ( +
+ + + {isExpanded && ( +
+ {metrics.map((metric, idx) => ( +
+
+ {metric.name} + {metric.interpretation} +
+
{metric.formula}
+

{metric.description}

+
Guide: {metric.guide}
+
+ ))} + + {type === 'accuracy' && ( +
+ MAPE Color Scale: +
+ {'<5% Excellent'} + {'5-10% Good'} + {'10-15% Fair'} + {'15-25% Moderate'} + {'>25% Poor'} +
+
+ )} +
+ )} +
+ ) +} + +const legendStyles: Record = { + container: { + marginBottom: '16px', + background: '#f4f5f7', + borderRadius: '10px', + border: '1px solid #f1f5f9', + overflow: 'hidden', + }, + toggle: { + width: '100%', + padding: '12px', + background: 'transparent', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '8px', + color: '#64748b', + fontSize: '13px', + fontWeight: 500, + textAlign: 'left', + }, + toggleIcon: { fontSize: '10px', color: '#94a3b8' }, + content: { + padding: '12px', + paddingTop: 0, + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: '12px', + }, + metricCard: { + background: '#ffffff', + borderRadius: '8px', + padding: '12px', + border: '1px solid #f1f5f9', + }, + metricHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: '4px', + gap: '8px', + }, + metricName: { fontWeight: 600, color: '#1e293b', fontSize: '13px' }, + metricBadge: { + fontSize: '10px', + padding: '2px 6px', + borderRadius: '4px', + color: 'white', + fontWeight: 500, + whiteSpace: 'nowrap', + }, + metricFormula: { + background: '#f4f5f7', + padding: '4px 8px', + borderRadius: '4px', + marginBottom: '4px', + fontSize: '11px', + fontFamily: 'monospace', + color: '#64748b', + }, + metricDescription: { margin: '0 0 4px 0', fontSize: '11px', color: '#64748b', lineHeight: 1.4 }, + metricGuide: { + fontSize: '11px', + color: '#1e293b', + background: '#f4f5f7', + padding: '4px 8px', + borderRadius: '4px', + }, + colorGuide: { + gridColumn: '1 / -1', + background: '#ffffff', + borderRadius: '8px', + padding: '12px', + border: '1px solid #f1f5f9', + }, + colorScale: { display: 'flex', flexWrap: 'wrap', gap: '4px', marginTop: '4px' }, + colorItem: { + padding: '4px 8px', + borderRadius: '4px', + fontSize: '11px', + color: 'white', + fontWeight: 500, + }, +} + +interface BacktestStatus { + model: string + metric_code: string + total_snapshots: number + with_actuals: number + first_perception: string + last_perception: string + perception_dates: number +} + +interface AccuracyBracket { + model: string + lead_bracket: string + n: number + mae: number | null + mape: number | null +} + +interface ModelWeight { + model: string + lead_bracket: string + mape: number | null + weight: number +} + +interface ProductionWeight { + metric_code: string + snapshot_metric: string + is_pace_metric: boolean + models: { + [key: string]: { + mape: number | null + weight: number + sample_count: number + } + } + total_samples: number +} + +interface DayOfWeekAccuracy { + model: string + dow_num: number + day_name: string + n: number + mae: number | null + mape: number | null +} + +interface MonthAccuracy { + model: string + month_num: number + month_name: string + n: number + mae: number | null + mape: number | null +} + +const Accuracy: React.FC = () => { + const [activePage, setActivePage] = useState('backtest') + + const menuItems: { id: AccuracyPage; label: string }[] = [ + { id: 'backtest', label: 'Batch Backtest' }, + { id: 'accuracy', label: 'Accuracy Metrics' }, + { id: 'weights', label: 'Model Weights' }, + { id: 'progress', label: 'Forecast Progress' }, + ] + + return ( +
+
+

Accuracy

+ +
+ +
+ {activePage === 'backtest' && } + {activePage === 'accuracy' && } + {activePage === 'weights' && } + {activePage === 'progress' && } +
+
+ ) +} + +// ============================================ +// BATCH BACKTEST PAGE +// ============================================ + +const BacktestPage: React.FC = () => { + const queryClient = useQueryClient() + const [startDate, setStartDate] = useState('2025-01-06') + const [endDate, setEndDate] = useState('2025-12-29') + const [forecastDays, setForecastDays] = useState(365) + const [selectedModel, setSelectedModel] = useState('xgboost') + const [metric, setMetric] = useState('occupancy') + const [excludeCovid, setExcludeCovid] = useState(false) + const [runningModel, setRunningModel] = useState(null) + + const { data: statusData, isLoading: statusLoading } = useQuery({ + queryKey: ['backtest-status'], + queryFn: async () => { + const res = await api.get('/backtest/batch/status') + return res.data + }, + refetchInterval: runningModel ? 5000 : false, + }) + + const runBacktestMutation = useMutation({ + mutationFn: async (model: string) => { + const params = new URLSearchParams({ + start_perception: startDate, + end_perception: endDate, + forecast_days: forecastDays.toString(), + metric: metric, + model: model, + exclude_covid: excludeCovid.toString(), + }) + const res = await api.post(`/backtest/batch?${params.toString()}`) + return res.data + }, + onSuccess: (_, model) => { + setRunningModel(model) + queryClient.invalidateQueries({ queryKey: ['backtest-status'] }) + }, + onError: (error) => { + console.error('Backtest error:', error) + setRunningModel(null) + } + }) + + const backfillMutation = useMutation({ + mutationFn: async () => { + const res = await api.post('/backtest/backfill-actuals') + return res.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backtest-status'] }) + } + }) + + const deleteModelMutation = useMutation({ + mutationFn: async ({ model, metricCode }: { model: string; metricCode: string }) => { + const params = new URLSearchParams({ metric_code: metricCode }) + const res = await api.delete(`/backtest/snapshots/${model}?${params.toString()}`) + return res.data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['backtest-status'] }) + queryClient.invalidateQueries({ queryKey: ['accuracy-by-bracket'] }) + queryClient.invalidateQueries({ queryKey: ['model-weights'] }) + } + }) + + const getMetricLabel = (code: string) => { + const labels: Record = { + occupancy: 'Occupancy %', rooms: 'Room Nights', guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', arr: 'ARR (Net)', net_accom: 'Net Accomm Rev', + net_dry: 'Net Dry Rev', net_wet: 'Net Wet Rev', + } + return labels[code] || code.toUpperCase() + } + + const handleDeleteModel = (model: string, metricCode: string) => { + if (window.confirm(`Delete ${model.toUpperCase()} backtest data for ${getMetricLabel(metricCode)}? This cannot be undone.`)) { + deleteModelMutation.mutate({ model, metricCode }) + } + } + + const handleRunBacktest = () => { runBacktestMutation.mutate(selectedModel) } + + const models = ['xgboost', 'pickup', 'pickup_avg', 'prophet', 'catboost', 'blended'] + + return ( +
+

Batch Backtest

+

+ Run backtests from multiple perception dates (every Monday) to evaluate model accuracy by lead time. +

+ +
+

Run New Backtest

+
+
+ + setStartDate(e.target.value)} style={styles.input} /> +
+
+ + setEndDate(e.target.value)} style={styles.input} /> +
+
+ + setForecastDays(parseInt(e.target.value))} style={styles.input} min={30} max={365} /> +
+
+ + +
+
+ + + {!['occupancy', 'rooms'].includes(metric) && ( + Pickup models unavailable for this metric (requires booking pace data) + )} +
+
+
+ + + Train models using data from May 2021+ only (excludes COVID lockdown periods). Results stored with "_postcovid" suffix. + +
+
+ + +
+ {runningModel && ( +
+ {runningModel.toUpperCase()} backtest running in background. Status will update automatically. +
+ )} +
+ +
+

Backtest Status

+ {statusLoading ? ( +
Loading status...
+ ) : statusData && statusData.length > 0 ? ( + <> + {[...new Set(statusData.map(s => s.metric_code))].sort().map(metricCode => ( +
+

{getMetricLabel(metricCode)}

+
+ {statusData.filter(s => s.metric_code === metricCode).map((status) => ( +
+
+ {status.model.toUpperCase()} + 0 ? { display: 'inline-block', padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 500, background: '#16a34a', color: '#ffffff' } : { display: 'inline-block', padding: '2px 8px', borderRadius: '4px', fontSize: '11px', fontWeight: 500, background: '#d97706', color: '#ffffff' }}> + {status.with_actuals > 0 ? 'Has Actuals' : 'Pending Actuals'} + +
+
+
Snapshots{status.total_snapshots.toLocaleString()}
+
With Actuals{status.with_actuals.toLocaleString()}
+
Perception Dates{status.perception_dates}
+
+
{status.first_perception} to {status.last_perception}
+ +
+ ))} +
+
+ ))} + + ) : ( +
No backtests run yet. Use the controls above to run your first backtest.
+ )} +
+
+ ) +} + +// ============================================ +// ACCURACY METRICS PAGE +// ============================================ + +const AccuracyMetricsPage: React.FC = () => { + const [metric, setMetric] = useState('occupancy') + const [selectedModel, setSelectedModel] = useState(null) + const [groupBy, setGroupBy] = useState('lead_time') + + const { data: bracketData, isLoading: bracketLoading } = useQuery({ + queryKey: ['accuracy-by-bracket', metric, selectedModel], + queryFn: async () => { + const params: Record = { metric_code: metric } + if (selectedModel) params.model = selectedModel + const res = await api.get('/backtest/accuracy-by-bracket', { params }) + return res.data + }, + enabled: groupBy === 'lead_time', + }) + + const { data: dowData, isLoading: dowLoading } = useQuery({ + queryKey: ['accuracy-by-dow', metric, selectedModel], + queryFn: async () => { + const params: Record = { metric_code: metric } + if (selectedModel) params.model = selectedModel + const res = await api.get('/backtest/accuracy-by-day-of-week', { params }) + return res.data + }, + enabled: groupBy === 'day_of_week', + }) + + const { data: monthData, isLoading: monthLoading } = useQuery({ + queryKey: ['accuracy-by-month', metric, selectedModel], + queryFn: async () => { + const params: Record = { metric_code: metric } + if (selectedModel) params.model = selectedModel + const res = await api.get('/backtest/accuracy-by-month', { params }) + return res.data + }, + enabled: groupBy === 'month', + }) + + const isLoading = groupBy === 'lead_time' ? bracketLoading : groupBy === 'day_of_week' ? dowLoading : monthLoading + + const getColorForMape = (mape: number | null): string => { + if (mape === null) return '#94a3b8' + if (mape < 5) return '#16a34a' + if (mape < 10) return '#22c55e' + if (mape < 15) return '#d97706' + if (mape < 25) return '#f59e0b' + return '#dc2626' + } + + const getTitle = () => { + switch (groupBy) { + case 'lead_time': return 'Accuracy by Lead Time' + case 'day_of_week': return 'Accuracy by Day of Week' + case 'month': return 'Accuracy by Month' + } + } + + const getHint = () => { + switch (groupBy) { + case 'lead_time': return 'Compare model accuracy (MAPE) across different forecast horizons.' + case 'day_of_week': return 'Compare model accuracy across different days of the week. Useful for identifying weekday vs weekend patterns.' + case 'month': return 'Compare model accuracy across different months. Useful for identifying seasonal patterns in forecast accuracy.' + } + } + + const bracketOrder = ['0-7', '8-14', '15-30', '31-60', '61-90', '90+'] + const bracketModels = [...new Set(bracketData?.map(d => d.model) || [])] + const groupedByBracket = bracketOrder.map(bracket => ({ + bracket, + models: bracketModels.map(model => { + const data = bracketData?.find(d => d.lead_bracket === bracket && d.model === model) + return { model, ...data } + }) + })) + + const dayOrder = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + const dowModels = [...new Set(dowData?.map(d => d.model) || [])] + const groupedByDay = dayOrder.map(day => ({ + day, + models: dowModels.map(model => { + const data = dowData?.find(d => d.day_name === day && d.model === model) + return { model, ...data } + }) + })) + + const monthOrder = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + const monthModels = [...new Set(monthData?.map(d => d.model) || [])] + const groupedByMonth = monthOrder.map(month => ({ + month, + models: monthModels.map(model => { + const data = monthData?.find(d => d.month_name === month && d.model === model) + return { model, ...data } + }) + })) + + const modelColor = (model: string) => model === 'xgboost' ? '#c9a84c' : model === 'pickup' ? '#1a1a2e' : model === 'pickup_avg' ? '#17becf' : model === 'catboost' ? '#9467bd' : '#2563eb' + + return ( +
+
+
+

{getTitle()}

+

{getHint()}

+
+
+ + + +
+
+ + + + {isLoading ? ( +
Loading accuracy data...
+ ) : ( + <> + {/* LEAD TIME VIEW */} + {groupBy === 'lead_time' && bracketData && bracketData.length > 0 && ( + <> +
+ + + + + {bracketModels.map(model => ())} + + + + {bracketModels.map(model => ())} + + + + {groupedByBracket.map(({ bracket, models: modelData }) => ( + + + {modelData.map(({ model, mape, mae, n }) => ( + + + + + ))} + + ))} + +
Lead Time{model.toUpperCase()}
Days OutMAPE %MAE
{bracket} days + {mape !== undefined && mape !== null ? `${mape.toFixed(1)}%` : '-'} + {n && n={n}} + {mae !== undefined && mae !== null ? mae.toFixed(2) : '-'}
+
+ +
+

MAPE Comparison

+
+ {groupedByBracket.map(({ bracket, models: modelData }) => ( +
+
{bracket} days
+
+ {modelData.map(({ model, mape }) => { + const width = mape ? Math.min(mape * 2, 100) : 0 + return ( +
+
{model}
+
+
+
+
{mape !== undefined && mape !== null ? `${mape.toFixed(1)}%` : '-'}
+
+ ) + })} +
+
+ ))} +
+
+ + )} + + {/* DAY OF WEEK VIEW */} + {groupBy === 'day_of_week' && dowData && dowData.length > 0 && ( + <> +
+ + + + + {dowModels.map(model => ())} + + + + {dowModels.map(model => ())} + + + + {groupedByDay.map(({ day, models: modelData }) => ( + + + {modelData.map(({ model, mape, mae, n }) => ( + + + + + ))} + + ))} + +
Day{model.toUpperCase()}
of WeekMAPE %MAE
{day} + {mape !== undefined && mape !== null ? `${mape.toFixed(1)}%` : '-'} + {n && n={n}} + {mae !== undefined && mae !== null ? mae.toFixed(2) : '-'}
+
+ +
+

MAPE Comparison by Day

+
+ {groupedByDay.map(({ day, models: modelData }) => ( +
+
{day}
+
+ {modelData.map(({ model, mape }) => { + const width = mape ? Math.min(mape * 2, 100) : 0 + return ( +
+
{model}
+
+
+
+
{mape !== undefined && mape !== null ? `${mape.toFixed(1)}%` : '-'}
+
+ ) + })} +
+
+ ))} +
+
+ +
+

Weekday vs Weekend Pattern

+
+ {dowModels.map(model => { + const weekdayData = dowData?.filter(d => d.model === model && ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'].includes(d.day_name)) || [] + const weekendData = dowData?.filter(d => d.model === model && ['Saturday', 'Sunday'].includes(d.day_name)) || [] + const weekdayMape = weekdayData.length > 0 ? weekdayData.reduce((sum, d) => sum + (d.mape || 0), 0) / weekdayData.length : null + const weekendMape = weekendData.length > 0 ? weekendData.reduce((sum, d) => sum + (d.mape || 0), 0) / weekendData.length : null + return ( +
+ {model.toUpperCase()} + Weekday: {weekdayMape ? `${weekdayMape.toFixed(1)}%` : '-'} + Weekend: {weekendMape ? `${weekendMape.toFixed(1)}%` : '-'} +
+ ) + })} +
+
+ + )} + + {/* MONTH VIEW */} + {groupBy === 'month' && monthData && monthData.length > 0 && ( + <> +
+ + + + + {monthModels.map(model => ())} + + + + {monthModels.map(model => ())} + + + + {groupedByMonth.map(({ month, models: modelData }) => ( + + + {modelData.map(({ model, mape, mae, n }) => ( + + + + + ))} + + ))} + +
Month{model.toUpperCase()}
MAPE %MAE
{month} + {mape !== undefined && mape !== null ? `${mape.toFixed(1)}%` : '-'} + {n && n={n}} + {mae !== undefined && mae !== null ? mae.toFixed(2) : '-'}
+
+ +
+

MAPE Comparison by Month

+
+ {groupedByMonth.map(({ month, models: modelData }) => ( +
+
{month.slice(0, 3)}
+
+ {modelData.map(({ model, mape }) => { + const width = mape ? Math.min(mape * 2, 100) : 0 + return ( +
+
{model}
+
+
+
+
{mape !== undefined && mape !== null ? `${mape.toFixed(1)}%` : '-'}
+
+ ) + })} +
+
+ ))} +
+
+ +
+

Best & Worst Months by Model

+
+ {monthModels.map(model => { + const modelMonths = monthData?.filter(d => d.model === model && d.mape !== null) || [] + const bestMonth = modelMonths.reduce((best, curr) => (best === null || (curr.mape !== null && curr.mape < (best.mape || Infinity))) ? curr : best, null as MonthAccuracy | null) + const worstMonth = modelMonths.reduce((worst, curr) => (worst === null || (curr.mape !== null && curr.mape > (worst.mape || 0))) ? curr : worst, null as MonthAccuracy | null) + return ( +
+ {model.toUpperCase()} + Best: {bestMonth?.month_name || '-'} ({bestMonth?.mape?.toFixed(1) || '-'}%) + Worst: {worstMonth?.month_name || '-'} ({worstMonth?.mape?.toFixed(1) || '-'}%) +
+ ) + })} +
+
+ + )} + + {groupBy === 'lead_time' && (!bracketData || bracketData.length === 0) && ( +
No accuracy data available. Run backtests and backfill actuals first.
+ )} + {groupBy === 'day_of_week' && (!dowData || dowData.length === 0) && ( +
No accuracy data available. Run backtests and backfill actuals first.
+ )} + {groupBy === 'month' && (!monthData || monthData.length === 0) && ( +
No accuracy data available. Run backtests and backfill actuals first.
+ )} + + )} +
+ ) +} + +// ============================================ +// MODEL WEIGHTS PAGE +// ============================================ + +const ModelWeightsPage: React.FC = () => { + const [metric, setMetric] = useState('occupancy') + + const { data: productionWeights, isLoading: productionLoading } = useQuery({ + queryKey: ['production-model-weights', metric], + queryFn: async () => { + const res = await api.get('/accuracy/model-weights', { params: { metric_code: metric } }) + return res.data + }, + }) + + const { data: weightsData, isLoading } = useQuery({ + queryKey: ['model-weights', metric], + queryFn: async () => { + const res = await api.get('/backtest/model-weights', { params: { metric_code: metric } }) + return res.data + }, + }) + + const bracketOrder = ['0-7', '8-14', '15-30', '31-60', '61-90', '90+'] + const models = [...new Set(weightsData?.map(d => d.model) || [])].filter(m => !m.includes('lower') && !m.includes('upper')) + + const groupedByBracket = bracketOrder.map(bracket => { + const bracketData = weightsData?.filter(d => d.lead_bracket === bracket && !d.model.includes('lower') && !d.model.includes('upper')) || [] + return { bracket, data: bracketData } + }) + + const modelColor = (model: string) => model === 'xgboost' ? '#c9a84c' : model === 'pickup' ? '#1a1a2e' : model === 'catboost' ? '#9467bd' : '#2563eb' + + return ( +
+
+
+

Model Weights

+

Weights derived from inverse MAPE. Lower error = higher weight. Use these for ensemble forecasting.

+
+ +
+ + + + {productionLoading ? ( +
Loading production weights...
+ ) : productionWeights && productionWeights.length > 0 ? ( +
+
+

Current Production Weights

+

+ These are the actual weights used by the blended-weighted forecast model. + Calculated as simple average across all backtest data (not segmented by lead time). +

+
+ + {productionWeights.map((metricData) => { + const modelNames = Object.keys(metricData.models).sort() + return ( +
+
+ + {metricData.is_pace_metric ? `${metric.toUpperCase()} (Pace Metric - includes Pickup)` : metric.toUpperCase()} + + {metricData.total_samples.toLocaleString()} total backtest samples +
+ +
+ {modelNames.map((modelName) => { + const model = metricData.models[modelName] + return ( +
+
+ {modelName.toUpperCase()} + {(model.weight * 100).toFixed(1)}% +
+
+
+
+
+ MAPE: {model.mape !== null ? `${model.mape.toFixed(1)}%` : 'N/A'} + n = {model.sample_count.toLocaleString()} +
+
+ ) + })} +
+ +
+ Note: Lower MAPE = Higher weight. Weights automatically update as more backtest data is collected. + These weights apply to Stage 1 of blended forecasting (before 60/40 budget blend). +
+
+ ) + })} +
+ ) : null} + +
+

Weights by Lead Time Bracket

+

Model performance varies by forecast horizon. Below shows how weights change at different lead times.

+
+ + {isLoading ? ( +
Loading weights...
+ ) : weightsData && weightsData.length > 0 ? ( + <> +
+ + + + + {models.map(model => ())} + + + + {groupedByBracket.map(({ bracket, data }) => ( + + + {models.map(model => { + const modelData = data.find(d => d.model === model) + const weight = modelData?.weight ?? 0 + const isHighest = data.length > 0 && weight === Math.max(...data.map(d => d.weight)) + return ( + + ) + })} + + ))} + +
Lead Time{model.toUpperCase()}
{bracket} days + {(weight * 100).toFixed(1)}% +
+
+ +
+

Weight Distribution by Lead Time

+
+ {groupedByBracket.map(({ bracket, data }) => ( +
+
{bracket} days
+
+ {data.sort((a, b) => b.weight - a.weight).map(({ model, weight }) => ( +
+
{model}
+
+
+
+
{(weight * 100).toFixed(0)}%
+
+ ))} +
+
+ ))} +
+
+ +
+

Best Model by Lead Time

+
+ {groupedByBracket.map(({ bracket, data }) => { + const best = data.reduce((a, b) => a.weight > b.weight ? a : b, data[0]) + return ( +
+ {bracket} days: + {best?.model?.toUpperCase() || '-'} + ({((best?.weight || 0) * 100).toFixed(0)}%) +
+ ) + })} +
+
+ + ) : ( +
No weights available. Run backtests and backfill actuals to calculate model weights.
+ )} +
+ ) +} + +// ============================================ +// FORECAST PROGRESS PAGE (3D Visualization) +// ============================================ + +interface MonthlyProgressData { + metric_code: string + model: string + year: number + month: number + target_dates: string[] + perception_dates: string[] + surface_data: (number | null)[][] + actuals: (number | null)[] +} + +const ForecastProgressPage: React.FC = () => { + const currentDate = new Date() + const [year, setYear] = useState(currentDate.getFullYear()) + const [month, setMonth] = useState(currentDate.getMonth()) + const [metric, setMetric] = useState('occupancy') + const [model, setModel] = useState('blended') + + const metrics = [ + { value: 'occupancy', label: 'Occupancy %' }, + { value: 'rooms', label: 'Room Nights' }, + { value: 'guests', label: 'Guests' }, + { value: 'ave_guest_rate', label: 'Avg Guest Rate' }, + { value: 'arr', label: 'ARR' }, + { value: 'net_accom', label: 'Net Accom' }, + { value: 'net_dry', label: 'Net Dry' }, + { value: 'net_wet', label: 'Net Wet' }, + ] + + const models = [ + { value: 'blended', label: 'Blended' }, + { value: 'prophet', label: 'Prophet' }, + { value: 'xgboost', label: 'XGBoost' }, + { value: 'catboost', label: 'CatBoost' }, + { value: 'pickup', label: 'Pickup' }, + ] + + const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] + const yearOptions = Array.from({ length: 3 }, (_, i) => currentDate.getFullYear() - 1 + i) + + const { data: progressData, isLoading, error } = useQuery({ + queryKey: ['forecast-progress', year, month, metric, model], + queryFn: async () => { + const res = await api.get('/backtest/3d-monthly-progress', { params: { year: year.toString(), month: (month + 1).toString(), metric_code: metric, model: model } }) + return res.data + }, + }) + + const plotData = React.useMemo(() => { + if (!progressData || !progressData.surface_data?.length) return null + const { target_dates, perception_dates, surface_data, actuals } = progressData + const xLabels = target_dates.map(d => { const date = new Date(d); return date.getDate().toString() }) + const yLabels = perception_dates.map(d => { const date = new Date(d); return `${date.getMonth() + 1}/${date.getDate()}` }) + const z = surface_data + const lastPerceptionIndex = perception_dates.length - 1 + const lastPerceptionLabel = yLabels[lastPerceptionIndex] + return { z, x: xLabels, y: yLabels, actualValues: actuals, lastPerceptionLabel } + }, [progressData]) + + const metricLabel = metrics.find(m => m.value === metric)?.label || metric + + return ( +
+
+

Forecast Progress

+

See how forecasts evolved over time as the target month approached

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {isLoading ? ( +
Loading forecast data...
+ ) : error ? ( +
Error loading data. Make sure backtests have been run.
+ ) : !plotData ? ( +
+ No forecast data available for {months[month]} {year}.
+ Run backtests for this period to generate data. +
+ ) : ( + Forecast from: %{y}
' + `${metricLabel}: %{z:.1f}`, + } as Partial, + ...(plotData.actualValues.some(v => v !== null) ? [{ + type: 'scatter3d' as const, + mode: 'lines+markers' as const, + x: plotData.x, + y: plotData.x.map(() => plotData.lastPerceptionLabel), + z: plotData.actualValues, + line: { color: 'rgba(255, 99, 132, 1)', width: 6 }, + marker: { size: 4, color: 'rgba(255, 99, 132, 1)' }, + name: 'Actual', + hovertemplate: 'Day %{x}
' + `Actual ${metricLabel}: %{z:.1f}`, + } as Partial] : []), + ]} + layout={{ + title: { text: `${metricLabel} Forecast Evolution - ${months[month]} ${year}`, font: { size: 16 } }, + scene: { + xaxis: { title: { text: 'Day of Month' }, tickfont: { size: 10 } }, + yaxis: { title: { text: 'Forecast Date' }, tickfont: { size: 10 } }, + zaxis: { title: { text: metricLabel }, tickfont: { size: 10 } }, + camera: { eye: { x: 1.5, y: 1.5, z: 1.2 } }, + }, + margin: { l: 0, r: 0, t: 40, b: 0 }, + paper_bgcolor: 'transparent', + font: { family: 'Inter, system-ui, sans-serif' }, + }} + style={{ width: '100%', height: '600px' }} + config={{ displayModeBar: true, displaylogo: false, modeBarButtonsToRemove: ['toImage', 'sendDataToCloud'] }} + /> + )} +
+ +
+

How to Read This Chart

+
    +
  • X-axis (Day of Month): Each day in {months[month]} {year}
  • +
  • Y-axis (Forecast Date): When each forecast was generated
  • +
  • Z-axis (Height/Color): The forecasted {metricLabel.toLowerCase()} value
  • +
  • Surface shape: As forecasts get closer to the target date (moving forward on Y), they typically converge toward the actual value
  • + {plotData?.actualValues.some(v => v !== null) && ( +
  • Red line at the end: Final actual values (shown at the last forecast date when actuals became known)
  • + )} +
+
+
+ ) +} + +const progressStyles: Record = { + container: { padding: '12px' }, + header: { marginBottom: '16px' }, + title: { margin: 0, fontSize: '22px', fontWeight: 700, color: '#1e293b' }, + subtitle: { margin: '4px 0 0 0', fontSize: '13px', color: '#64748b' }, + controls: { + display: 'flex', flexWrap: 'wrap', gap: '12px', marginBottom: '16px', + padding: '12px', background: '#ffffff', borderRadius: '10px', border: '1px solid #f1f5f9', + }, + controlGroup: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: '140px' }, + label: { fontSize: '11px', fontWeight: 500, color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.5px' }, + select: { padding: '8px 12px', fontSize: '13px', border: '1px solid #e4e8ee', borderRadius: '8px', background: '#f4f5f7', color: '#1e293b', cursor: 'pointer', outline: 'none' }, + chartContainer: { background: '#ffffff', borderRadius: '10px', border: '1px solid #f1f5f9', padding: '12px', minHeight: '600px' }, + loading: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '400px', color: '#64748b', fontSize: '14px' }, + error: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '400px', color: '#dc2626', fontSize: '14px' }, + empty: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '400px', color: '#94a3b8', fontSize: '14px', textAlign: 'center', lineHeight: 1.6 }, + explanation: { marginTop: '16px', padding: '12px', background: '#f4f5f7', borderRadius: '10px', border: '1px solid #f1f5f9' }, + explanationTitle: { margin: '0 0 8px 0', fontSize: '13px', fontWeight: 600, color: '#1e293b' }, + explanationList: { margin: 0, padding: '0 0 0 16px', fontSize: '13px', color: '#64748b', lineHeight: 1.8 }, +} + +// ============================================ +// STYLES +// ============================================ + +const styles: Record = { + layout: { display: 'flex', gap: '24px', padding: '24px' }, + sidebar: { width: '200px', flexShrink: 0, background: '#ffffff', borderRadius: '14px', padding: '12px', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)', height: 'fit-content', position: 'sticky', top: '12px' }, + sidebarTitle: { margin: '0 0 12px 0', fontSize: '18px', color: '#1e293b', fontWeight: 600 }, + nav: { display: 'flex', flexDirection: 'column', gap: '4px' }, + navItem: { padding: '8px 12px', border: 'none', background: 'transparent', textAlign: 'left', cursor: 'pointer', borderRadius: '8px', fontSize: '13px', color: '#64748b', fontWeight: 400, transition: 'all 0.15s ease' }, + navItemActive: { background: '#c9a84c', color: '#ffffff', fontWeight: 500 }, + content: { flex: 1, minWidth: 0 }, + section: { background: '#ffffff', padding: '16px', borderRadius: '14px', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)' }, + sectionHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }, + sectionTitle: { color: '#1e293b', margin: 0, marginBottom: '4px', fontSize: '22px', fontWeight: 600 }, + subsectionTitle: { color: '#1e293b', margin: '0 0 12px 0', fontSize: '18px', fontWeight: 500 }, + hint: { color: '#64748b', margin: 0, marginBottom: '12px', fontSize: '13px' }, + controlBox: { background: '#f4f5f7', padding: '16px', borderRadius: '10px', marginTop: '12px' }, + controlGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '12px', marginBottom: '12px' }, + formGroup: { display: 'flex', flexDirection: 'column', gap: '4px' }, + label: { fontSize: '13px', color: '#64748b', fontWeight: 500 }, + input: { padding: '8px', borderRadius: '8px', border: '1px solid #e4e8ee', fontSize: '14px', outline: 'none' }, + select: { padding: '8px', borderRadius: '8px', border: '1px solid #e4e8ee', fontSize: '14px', background: '#ffffff', cursor: 'pointer', outline: 'none' }, + buttonRow: { display: 'flex', gap: '8px' }, + checkboxRow: { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '12px', padding: '8px', background: '#ffffff', borderRadius: '8px', border: '1px solid #f1f5f9' }, + checkboxLabel: { display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer', fontSize: '13px', fontWeight: 500, color: '#1e293b' }, + checkbox: { width: '18px', height: '18px', cursor: 'pointer' }, + checkboxHint: { fontSize: '11px', color: '#94a3b8', marginLeft: '26px' }, + runningMessage: { marginTop: '12px', padding: '8px', background: '#eff6ff', color: '#2563eb', borderRadius: '8px', fontSize: '13px' }, + statusGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '12px' }, + statusCard: { background: '#f4f5f7', padding: '12px', borderRadius: '10px', border: '1px solid #f1f5f9' }, + statusCardHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }, + statusCardModel: { fontSize: '18px', fontWeight: 700, color: '#1e293b' }, + statusCardStats: { display: 'flex', gap: '16px', marginBottom: '8px' }, + statItem: { display: 'flex', flexDirection: 'column' }, + statLabel: { fontSize: '11px', color: '#94a3b8', textTransform: 'uppercase' }, + statValue: { fontSize: '18px', fontWeight: 600, color: '#1e293b' }, + statusCardRange: { fontSize: '11px', color: '#94a3b8', borderTop: '1px solid #f1f5f9', paddingTop: '8px' }, + deleteButton: { marginTop: '8px', padding: '4px 8px', fontSize: '11px', color: '#dc2626', background: 'transparent', border: '1px solid #dc2626', borderRadius: '8px', cursor: 'pointer', transition: 'all 0.2s' }, + metricGroupTitle: { fontSize: '14px', fontWeight: 600, color: '#64748b', marginBottom: '8px', textTransform: 'uppercase' as const, letterSpacing: '0.05em' }, + loading: { color: '#64748b', padding: '16px', textAlign: 'center' }, + emptyState: { padding: '24px', textAlign: 'center', color: '#64748b', background: '#f4f5f7', borderRadius: '10px' }, + filterRow: { display: 'flex', gap: '8px' }, + tableContainer: { overflowX: 'auto' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '13px' }, + th: { padding: '8px', textAlign: 'left', borderBottom: '2px solid #e4e8ee', color: '#1e293b', fontWeight: 600, background: '#f4f5f7' }, + thSub: { padding: '4px', textAlign: 'left', borderBottom: '1px solid #e4e8ee', color: '#94a3b8', fontWeight: 400, fontSize: '11px', background: '#f4f5f7' }, + td: { padding: '8px', borderBottom: '1px solid #f1f5f9', color: '#1e293b' }, + sampleSize: { fontSize: '11px', color: '#94a3b8', marginLeft: '4px' }, + barChartContainer: { display: 'flex', flexDirection: 'column', gap: '12px' }, + barRow: { display: 'flex', alignItems: 'flex-start', gap: '12px' }, + barLabel: { width: '80px', fontSize: '13px', fontWeight: 500, color: '#1e293b' }, + barGroup: { flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }, + barWrapper: { display: 'flex', alignItems: 'center', gap: '8px' }, + barModelLabel: { width: '60px', fontSize: '11px', color: '#94a3b8' }, + barTrack: { flex: 1, height: '12px', background: '#f1f5f9', borderRadius: '4px', overflow: 'hidden' }, + bar: { height: '100%', borderRadius: '4px', transition: 'width 0.3s ease' }, + barValue: { width: '50px', fontSize: '11px', color: '#64748b', textAlign: 'right' }, + weightGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }, + weightCard: { background: '#f4f5f7', borderRadius: '10px', padding: '12px', border: '1px solid #f1f5f9' }, + weightCardHeader: { fontSize: '13px', fontWeight: 600, color: '#1e293b', marginBottom: '8px', paddingBottom: '4px', borderBottom: '1px solid #f1f5f9' }, + weightCardBody: { display: 'flex', flexDirection: 'column', gap: '4px' }, + weightRow: { display: 'flex', alignItems: 'center', gap: '8px' }, + weightModelName: { width: '60px', fontSize: '11px', color: '#64748b', textTransform: 'capitalize' }, + weightBarTrack: { flex: 1, height: '8px', background: '#f1f5f9', borderRadius: '4px', overflow: 'hidden' }, + weightBarFill: { height: '100%', borderRadius: '4px', transition: 'width 0.3s ease' }, + weightPct: { width: '36px', fontSize: '11px', fontWeight: 500, color: '#1e293b', textAlign: 'right' }, + summaryBox: { marginTop: '24px', padding: '16px', background: '#f4f5f7', borderRadius: '10px' }, + summaryTitle: { margin: '0 0 12px 0', fontSize: '14px', fontWeight: 600, color: '#1e293b' }, + summaryGrid: { display: 'flex', flexWrap: 'wrap', gap: '12px' }, + summaryItem: { display: 'flex', alignItems: 'center', gap: '4px', padding: '4px 12px', background: '#ffffff', borderRadius: '8px', border: '1px solid #f1f5f9' }, + summaryBracket: { fontSize: '13px', color: '#64748b' }, + summaryModel: { fontSize: '13px', fontWeight: 700 }, + summaryWeight: { fontSize: '11px', color: '#94a3b8' }, + productionWeightsHeader: { marginBottom: '12px' }, + productionWeightsHint: { color: '#64748b', margin: '4px 0 0 0', fontSize: '13px', lineHeight: 1.5 }, + productionWeightsCard: { background: '#f4f5f7', borderRadius: '10px', padding: '16px', border: '2px solid #c9a84c', marginTop: '12px' }, + productionWeightsCardHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid #f1f5f9' }, + productionWeightsMetric: { fontSize: '18px', fontWeight: 700, color: '#1e293b' }, + productionWeightsSamples: { fontSize: '11px', color: '#94a3b8', textTransform: 'uppercase', letterSpacing: '0.5px' }, + productionWeightsGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px', marginBottom: '12px' }, + productionWeightItem: { background: '#ffffff', borderRadius: '8px', padding: '12px', border: '1px solid #f1f5f9' }, + productionWeightModelHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }, + productionWeightModelName: { fontSize: '14px', fontWeight: 700 }, + productionWeightValue: { fontSize: '18px', fontWeight: 700, color: '#1e293b' }, + productionWeightBarTrack: { height: '12px', background: '#f1f5f9', borderRadius: '4px', overflow: 'hidden', marginBottom: '8px' }, + productionWeightBarFill: { height: '100%', borderRadius: '4px', transition: 'width 0.3s ease' }, + productionWeightStats: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, + productionWeightMape: { fontSize: '11px', color: '#64748b' }, + productionWeightSamples: { fontSize: '11px', color: '#94a3b8' }, + productionWeightsNote: { padding: '8px', background: '#eff6ff', borderRadius: '8px', fontSize: '11px', color: '#1e293b', lineHeight: 1.5, borderLeft: '3px solid #2563eb' }, + leadTimeSection: { marginTop: '24px', paddingTop: '16px', borderTop: '2px solid #f1f5f9' }, +} + +export default Accuracy diff --git a/frontend/src/pages/Bookability.tsx b/frontend/src/pages/Bookability.tsx new file mode 100644 index 0000000..2f45e12 --- /dev/null +++ b/frontend/src/pages/Bookability.tsx @@ -0,0 +1,1074 @@ +import React, { useState, useMemo } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import api from '../api' + +// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) +const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + +// Types +interface CategoryInfo { + category_id: string + category_name: string + room_count: number +} + +interface TariffInfo { + name: string + description?: string + rate: number | null + average_nightly?: number + available: boolean + message: string + sort_order?: number + min_stay?: number | null + available_for_min_stay?: boolean | null // True if available when queried with min_stay nights +} + +interface OccupancyInfo { + occupied: number + available: number + maintenance: number +} + +interface DateRateInfo { + rate_gross: number | null + rate_net: number | null + tariffs: TariffInfo[] + tariff_count: number + occupancy?: OccupancyInfo +} + +interface RateMatrixData { + categories: CategoryInfo[] + dates: string[] + matrix: Record> +} + +// Helper functions +const formatDateShort = (dateStr: string): string => { + const date = new Date(dateStr + 'T00:00:00') + return date.toLocaleDateString('en-GB', { day: 'numeric' }) +} + +const formatDayOfWeek = (dateStr: string): string => { + const date = new Date(dateStr + 'T00:00:00') + return date.toLocaleDateString('en-GB', { weekday: 'short' }) +} + +const isWeekend = (dateStr: string): boolean => { + const date = new Date(dateStr + 'T00:00:00') + const day = date.getDay() + return day === 0 || day === 6 +} + +const formatCurrency = (value: number | null): string => { + if (value === null || value === undefined) return '-' + return new Intl.NumberFormat('en-GB', { + style: 'currency', + currency: 'GBP', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value) +} + +// Inline style helpers (replacing theme utilities) +const mergeStyles = (...styles: React.CSSProperties[]): React.CSSProperties => + Object.assign({}, ...styles) + +const buttonStyle = (variant: 'primary' | 'secondary' | 'outline', size?: 'small'): React.CSSProperties => { + const base: React.CSSProperties = { + border: 'none', + borderRadius: '6px', + cursor: 'pointer', + fontWeight: 500, + padding: size === 'small' ? '4px 10px' : '8px 16px', + fontSize: size === 'small' ? '13px' : '14px', + lineHeight: 1.4, + transition: 'all 0.15s', + } + if (variant === 'primary') return { ...base, background: 'var(--gold)', color: '#fff' } + if (variant === 'secondary') return { ...base, background: 'var(--navy)', color: '#fff' } + // outline + return { ...base, background: 'transparent', color: 'var(--text-dark)', border: '1px solid var(--card-border)' } +} + +const badgeStyle = (variant: 'success' | 'error' | 'warning' | 'info'): React.CSSProperties => { + const map: Record = { + success: { background: '#dcfce7', color: '#16a34a', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + error: { background: '#fee2e2', color: '#dc2626', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + warning: { background: '#fef3c7', color: '#d97706', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + info: { background: '#dbeafe', color: '#2563eb', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + } + return map[variant] || map.info +} + +// Components +const MonthSelector: React.FC<{ + value: string + onChange: (value: string) => void +}> = ({ value, onChange }) => { + const handlePrevMonth = () => { + const [year, month] = value.split('-').map(Number) + const date = new Date(year, month - 2, 1) + onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) + } + + const handleNextMonth = () => { + const [year, month] = value.split('-').map(Number) + const date = new Date(year, month, 1) + onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) + } + + // Generate month options (current month + next 12 months) + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + for (let i = 0; i < 13; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1) + const monthValue = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const label = date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + options.push({ value: monthValue, label }) + } + return options + }, []) + + return ( +
+ + + +
+ ) +} + +// Helper to collect unique tariff names across all dates for a category, preserving Newbook order +const getAllTariffNames = (rateData: Record, dates: string[]): string[] => { + const tariffMap = new Map() + for (const dateStr of dates) { + const data = rateData[dateStr] + if (data?.tariffs) { + for (const tariff of data.tariffs) { + if (!tariffMap.has(tariff.name)) { + tariffMap.set(tariff.name, tariff.sort_order ?? 999) + } + } + } + } + return Array.from(tariffMap.entries()) + .sort((a, b) => a[1] - b[1]) + .map(([name]) => name) +} + +// Helper to format scrape age +const formatScrapeAge = (isoStr: string | null): string => { + if (!isoStr) return '' + const diff = Date.now() - new Date(isoStr).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +interface BookingAvailabilityData { + has_own_hotel: boolean + dates_checked: number + dates_available: number + dates_sold_out: number + dates_no_data: number + latest_scrape: string | null + dates: Record +} + +const LoadingSpinner: React.FC = () => ( +
+
+ Loading rate data... +
+) + +const ErrorMessage: React.FC<{ message: string }> = ({ message }) => ( +
+ ! + {message} +
+) + +// Main Component +const Bookability: React.FC = () => { + const queryClient = useQueryClient() + const [selectedMonth, setSelectedMonth] = useState(() => { + const today = new Date() + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` + }) + const [refreshingDate, setRefreshingDate] = useState(null) + + // Calculate date range from selected month + const { fromDate, toDate } = useMemo(() => { + const [year, month] = selectedMonth.split('-').map(Number) + const start = new Date(year, month - 1, 1) + const end = new Date(year, month, 0) // Last day of month + return { + fromDate: fmtDate(start), + toDate: fmtDate(end), + } + }, [selectedMonth]) + + // Single-date refresh mutation + const dateRefreshM = useMutation({ + mutationFn: async (d: string) => { + setRefreshingDate(d) + const res = await api.post(`/bookability/refresh-date/${d}`) + return res.data + }, + onSuccess: () => { + // Refetch after a short delay to allow background task to complete + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['rate-matrix'] }) + setRefreshingDate(null) + }, 3000) + }, + onError: () => setRefreshingDate(null), + }) + + // Fetch Booking.com availability data + const { data: bookingData } = useQuery({ + queryKey: ['booking-availability', fromDate, toDate], + queryFn: async () => { + const params = new URLSearchParams({ from_date: fromDate, to_date: toDate }) + const res = await api.get(`/competitor-rates/booking-availability?${params}`) + return res.data + }, + staleTime: 5 * 60 * 1000, + }) + + // Fetch rate matrix data + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ['rate-matrix', fromDate, toDate], + queryFn: async () => { + const params = new URLSearchParams({ from_date: fromDate, to_date: toDate }) + const res = await api.get(`/bookability/rate-matrix?${params}`) + return res.data + }, + }) + + // Calculate summary stats - focus on unbookable dates (rooms available but no rates) + const summary = useMemo(() => { + if (!data) return null + + let totalDateCategories = 0 + let unbookableDateCategories = 0 + const unbookableIssues: { category: string; date: string; roomsLeft: number }[] = [] + + for (const cat of data.categories) { + const catData = data.matrix[cat.category_id] + if (!catData) continue + + for (const dateStr of data.dates) { + const dayData = catData[dateStr] + if (!dayData) continue + + // Check if rooms are available (bookable = available - maintenance - occupied) + const occ = dayData.occupancy + const bookableRooms = occ ? occ.available - occ.maintenance : 0 + const roomsLeft = occ ? bookableRooms - occ.occupied : 0 + const hasRoomsAvailable = roomsLeft > 0 + + // Only count dates where rooms are available + if (hasRoomsAvailable) { + totalDateCategories++ + + // Check if ANY tariff is available for booking + // A tariff is "bookable" if: + // - available: true (single-night available), OR + // - has min_stay > 1 AND available_for_min_stay: true (verified via multi-night query) + const hasAnyAvailableRate = dayData.tariffs?.some(t => { + if (t.available) return true + // If has min_stay requirement and verified available for that stay length + if (t.min_stay && t.min_stay > 1 && t.available_for_min_stay === true) return true + return false + }) ?? false + + if (!hasAnyAvailableRate && dayData.tariffs && dayData.tariffs.length > 0) { + // Rooms available but no rates bookable - this is a problem! + unbookableDateCategories++ + unbookableIssues.push({ + category: cat.category_name, + date: dateStr, + roomsLeft: roomsLeft, + }) + } + } + } + } + + return { + totalDateCategories, + unbookableDateCategories, + unbookablePercent: totalDateCategories > 0 + ? ((unbookableDateCategories / totalDateCategories) * 100).toFixed(1) + : '0', + issues: unbookableIssues.slice(0, 10), + hasMoreIssues: unbookableIssues.length > 10, + totalIssues: unbookableIssues.length, + } + }, [data]) + + return ( +
+ {/* Header */} +
+
+
+

Rate Availability

+

+ View tariff availability across all room categories +

+
+
+ + +
+
+ + {/* Summary Stats */} + {summary && ( +
+
+ {data?.categories.length || 0} + Room Types +
+
+ {data?.dates.length || 0} + Days +
+
+ 0 ? { color: 'var(--danger)' } : { color: 'var(--success)' } + )}> + {summary.unbookableDateCategories} + + Unbookable +
+
+ 0 ? 'warning' : 'success')}> + {summary.unbookablePercent}% blocked + +
+
+ )} +
+ + {/* Content */} +
+ {isLoading && } + {error && } + {data && data.categories.length === 0 && ( +
+ No room categories configured. Please set up room categories in Settings. +
+ )} + {data && data.categories.length > 0 && ( +
+
+ + + + + {data.dates.map(dateStr => ( + + ))} + + + + {data.categories.map(category => { + const rateData = data.matrix[category.category_id] || {} + const tariffNames = getAllTariffNames(rateData, data.dates) + return ( + + {/* Category header row */} + + + {data.dates.map(dateStr => ( + + {/* Occupancy row */} + + + {data.dates.map(dateStr => { + const dayData = rateData[dateStr] + const occ = dayData?.occupancy + if (!occ) { + return ( + + ) + } + const bookableRooms = occ.available - occ.maintenance + const roomsLeft = bookableRooms - occ.occupied + const isFull = roomsLeft <= 0 + const occPercent = bookableRooms > 0 + ? Math.round((occ.occupied / bookableRooms) * 100) + : 100 + const isHighOcc = occPercent >= 80 && !isFull + const hasOffline = occ.maintenance > 0 + const getOccStyle = () => { + if (isFull) return styles.occupancyFull + if (isHighOcc) return styles.occupancyHigh + return styles.occupancyAvailable + } + return ( + + ) + })} + + {/* Tariff rows */} + {tariffNames.length === 0 ? ( + + + + ) : ( + tariffNames.map(tariffName => ( + + + {data.dates.map(dateStr => { + const dayData = rateData[dateStr] + const tariff = dayData?.tariffs?.find(t => t.name === tariffName) + const occupancy = dayData?.occupancy + const noRoomsAvailable = occupancy && + (occupancy.available - occupancy.maintenance - occupancy.occupied) <= 0 + + if (!tariff) { + return ( + + ) + } + + const isEffectivelyAvailable = tariff.available || + (tariff.min_stay && tariff.min_stay > 1 && tariff.available_for_min_stay === true) + const minStayBadge = isEffectivelyAvailable && !noRoomsAvailable && tariff.min_stay && tariff.min_stay > 1 ? ( + + {tariff.min_stay} + + ) : null + const getCellStyle = () => { + if (noRoomsAvailable) return styles.cellNoRooms + if (isEffectivelyAvailable) return styles.cellAvailable + return styles.cellUnavailable + } + const getTooltip = () => { + if (noRoomsAvailable) return `${tariffName}: No rooms available` + if (isEffectivelyAvailable) { + const minStayNote = tariff.min_stay && tariff.min_stay > 1 ? ` (Min ${tariff.min_stay} nights)` : '' + return `${tariffName}: ${formatCurrency(tariff.rate)}${minStayNote}` + } + return `${tariffName}: ${tariff.message || 'Not available'}` + } + + return ( + + ) + })} + + )) + )} + + ) + })} + {/* Booking.com section */} + {bookingData && bookingData.has_own_hotel && ( + + + + {data.dates.map(dateStr => ( + + + + {data.dates.map(dateStr => { + const entry = bookingData.dates[dateStr] + const isAvailable = entry?.status === 'available' + const isSoldOut = entry?.status === 'sold_out' + const getCellStyle = () => { + if (!entry) return styles.cellNoData + if (isAvailable && entry.rate) return styles.cellAvailable + if (isSoldOut) return styles.bookingCellSoldOut + return styles.cellNoData + } + return ( + + ) + })} + + + )} + +
Tariff +
+ {formatDayOfWeek(dateStr)} + {formatDateShort(dateStr)} + +
+
+ {category.category_name} + ({category.room_count} rooms) + + ))} +
+ Occupancy + + - + + + {occ.occupied}/{occ.available} + {hasOffline && ({occ.maintenance})} + +
+ No rate data available for this period +
+ {tariffName} + + - + + + {tariff.rate !== null ? formatCurrency(tariff.rate) : (isEffectivelyAvailable ? 'Y' : 'N')} + {minStayBadge} + +
+ Booking.com + + {' '}{bookingData.latest_scrape ? `Scraped ${formatScrapeAge(bookingData.latest_scrape)}` : 'No scrape data'} + {' · '} + + View details + + + + ))} +
+ Best Available + + {!entry ? '-' + : isAvailable && entry.rate ? formatCurrency(entry.rate) + : isSoldOut ? 'Sold' + : '-'} +
+
+
+ )} +
+ + {/* Issues Panel */} + {summary && summary.unbookableDateCategories > 0 && ( +
+

+ Unbookable Dates ({summary.totalIssues}) +

+

+ Dates with rooms available but no rates bookable +

+
+ {summary.issues.map((issue, idx) => ( +
+ {issue.category} + {issue.date} + {issue.roomsLeft} room{issue.roomsLeft !== 1 ? 's' : ''} available, no rates +
+ ))} + {summary.hasMoreIssues && ( +
+ +{summary.totalIssues - 10} more issues +
+ )} +
+
+ )} + + {/* Legend */} +
+ Legend: + Available + Unavailable + No Rooms + No Data +
+
+ ) +} + +// Styles +const styles: Record = { + container: { + padding: '24px', + maxWidth: '100%', + margin: '0 auto', + }, + header: { + marginBottom: '24px', + }, + headerTop: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: '16px', + flexWrap: 'wrap', + gap: '16px', + }, + title: { + fontSize: '24px', + fontWeight: 700, + color: 'var(--text-dark)', + margin: 0, + }, + subtitle: { + fontSize: '13px', + color: 'var(--text-mid)', + margin: '4px 0 0', + }, + headerActions: { + display: 'flex', + alignItems: 'center', + gap: '16px', + }, + monthSelector: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + monthDropdown: { + fontSize: '14px', + fontWeight: 500, + color: 'var(--text-dark)', + padding: '4px 8px', + borderRadius: '6px', + border: '1px solid var(--card-border)', + background: 'var(--card-bg)', + cursor: 'pointer', + minWidth: '160px', + }, + summaryBar: { + display: 'flex', + gap: '24px', + padding: '16px', + background: 'var(--card-bg)', + borderRadius: '10px', + boxShadow: 'var(--shadow-sm)', + flexWrap: 'wrap', + }, + summaryItem: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '4px', + }, + summaryValue: { + fontSize: '20px', + fontWeight: 700, + color: 'var(--text-dark)', + }, + summaryLabel: { + fontSize: '11px', + color: 'var(--text-mid)', + textTransform: 'uppercase', + }, + content: { + display: 'flex', + flexDirection: 'column', + gap: '24px', + }, + unifiedCard: { + background: 'var(--card-bg)', + borderRadius: '10px', + padding: '16px', + boxShadow: 'var(--shadow-md)', + }, + categoryHeaderRow: { + fontWeight: 600, + fontSize: '14px', + color: 'var(--text-dark)', + background: 'var(--body-bg)', + padding: '8px 16px', + borderTop: '2px solid var(--card-border)', + textAlign: 'left' as const, + whiteSpace: 'nowrap' as const, + }, + categoryHeaderFill: { + background: 'var(--body-bg)', + borderTop: '2px solid var(--card-border)', + padding: 0, + }, + stickyHeader: { + position: 'sticky' as const, + top: 0, + zIndex: 20, + background: 'var(--card-bg)', + }, + roomCount: { + fontSize: '13px', + fontWeight: 400, + color: 'var(--text-mid)', + }, + tableContainer: { + overflowX: 'auto', + maxWidth: '100%', + }, + table: { + width: '100%', + borderCollapse: 'collapse', + fontSize: '13px', + minWidth: '800px', + tableLayout: 'fixed' as const, + }, + th: { + padding: '8px', + borderBottom: '2px solid var(--card-border)', + textAlign: 'center', + fontWeight: 600, + color: 'var(--text-dark)', + whiteSpace: 'nowrap', + background: 'var(--card-bg)', + }, + td: { + padding: '8px', + borderBottom: '1px solid var(--card-border)', + textAlign: 'center', + whiteSpace: 'nowrap', + }, + stickyCol: { + position: 'sticky', + left: 0, + background: 'var(--card-bg)', + zIndex: 10, + textAlign: 'left', + width: '160px', + borderRight: '1px solid var(--card-border)', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + dateHeader: { + width: '56px', + padding: '4px', + }, + dateHeaderContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + }, + dayOfWeek: { + fontSize: '11px', + color: 'var(--text-mid)', + }, + dayNum: { + fontSize: '13px', + fontWeight: 600, + }, + weekendHeader: { + background: 'var(--body-bg)', + }, + weekendCell: { + borderLeft: '2px solid var(--card-border)', + }, + tariffNameCell: { + fontWeight: 500, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + tariffCell: { + cursor: 'pointer', + position: 'relative', + transition: 'background 0.1s', + fontSize: '11px', + }, + cellContent: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '2px', + }, + minStayBadge: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '14px', + height: '14px', + borderRadius: '50%', + background: '#d97706', + color: '#fff', + fontSize: '9px', + fontWeight: 700, + marginLeft: '2px', + flexShrink: 0, + }, + cellAvailable: { + background: '#dcfce7', + color: 'var(--success)', + }, + cellUnavailable: { + background: '#fee2e2', + color: 'var(--danger)', + textDecoration: 'line-through', + }, + cellNoRooms: { + background: '#e0e0e0', + color: 'var(--text-mid)', + }, + cellNoData: { + background: 'var(--body-bg)', + color: 'var(--text-mid)', + }, + bookingCellSoldOut: { + background: '#fee2e2', + color: 'var(--danger)', + fontWeight: 500, + }, + occupancyLabel: { + fontWeight: 600, + color: 'var(--navy)', + background: '#f0f4ff', + }, + occupancyCell: { + fontSize: '11px', + color: 'var(--text-mid)', + background: 'var(--body-bg)', + }, + occupancyText: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '2px', + }, + maintenanceBadge: { + color: '#d97706', + marginLeft: '2px', + }, + occupancyAvailable: { + background: '#dcfce7', + color: 'var(--success)', + fontWeight: 500, + }, + occupancyHigh: { + background: '#fef3c7', + color: '#d97706', + fontWeight: 500, + }, + occupancyFull: { + background: '#fee2e2', + color: 'var(--danger)', + fontWeight: 500, + }, + loading: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '48px', + gap: '16px', + color: 'var(--text-mid)', + }, + spinner: { + width: '40px', + height: '40px', + border: '3px solid var(--card-border)', + borderTop: '3px solid var(--navy)', + borderRadius: '50%', + animation: 'spin 1s linear infinite', + }, + error: { + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '24px', + background: '#fee2e2', + color: 'var(--danger)', + borderRadius: '10px', + }, + errorIcon: { + width: '24px', + height: '24px', + borderRadius: '50%', + background: 'var(--danger)', + color: '#fff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 700, + }, + noData: { + textAlign: 'center', + padding: '32px', + color: 'var(--text-mid)', + }, + issuesPanel: { + marginTop: '24px', + background: '#fef3c7', + borderRadius: '10px', + padding: '24px', + }, + issuesTitle: { + fontSize: '14px', + fontWeight: 600, + color: '#d97706', + marginBottom: '4px', + }, + issuesSubtitle: { + fontSize: '13px', + color: 'var(--text-mid)', + marginBottom: '16px', + }, + issuesList: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + issueItem: { + display: 'flex', + gap: '8px', + fontSize: '13px', + flexWrap: 'wrap', + }, + issueCategory: { + fontWeight: 600, + color: 'var(--text-dark)', + }, + issueDate: { + color: 'var(--text-mid)', + }, + issueMessage: { + color: 'var(--danger)', + fontStyle: 'italic', + }, + moreIssues: { + color: 'var(--text-mid)', + fontStyle: 'italic', + marginTop: '8px', + }, + legend: { + display: 'flex', + alignItems: 'center', + gap: '16px', + marginTop: '24px', + padding: '16px', + background: 'var(--card-bg)', + borderRadius: '10px', + fontSize: '13px', + }, + legendTitle: { + fontWeight: 600, + color: 'var(--text-dark)', + }, + legendItem: { + padding: '4px 8px', + borderRadius: '4px', + fontSize: '11px', + }, + scrapeBtn: { + background: 'none', + border: '1px solid var(--card-border)', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '10px', + lineHeight: 1, + padding: '2px 4px', + color: 'var(--text-mid)', + opacity: 0.6, + transition: 'opacity 0.15s', + }, + scrapeBtnActive: { + opacity: 1, + color: 'var(--navy)', + borderColor: 'var(--navy)', + }, +} + +export default Bookability diff --git a/frontend/src/pages/CompetitorRates.tsx b/frontend/src/pages/CompetitorRates.tsx new file mode 100644 index 0000000..3b3cf3e --- /dev/null +++ b/frontend/src/pages/CompetitorRates.tsx @@ -0,0 +1,1827 @@ +import React, { useState, useMemo, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import api from '../api' + +// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString) +const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + +// ============================================ +// TYPES +// ============================================ + +interface ScraperStatus { + enabled: boolean + paused: boolean + pause_until: string | null + backend: string + location_configured: boolean + location_name: string | null + last_scrape: { + batch_id: string + scrape_type: string + started_at: string | null + completed_at: string | null + status: string + hotels_found: number | null + rates_scraped: number | null + error_message: string | null + } | null +} + +interface Hotel { + id: number + booking_com_id: string + name: string + booking_com_url: string | null + star_rating: number | null + review_score: number | null + review_count: number | null + tier: 'own' | 'competitor' | 'market' + display_order: number + notes: string | null + first_seen_at: string | null + last_seen_at: string | null +} + +interface RateMatrixResponse { + from_date: string + to_date: string + dates: string[] + hotels: { + id: number + name: string + tier: string + display_order: number + star_rating: number | null + review_score: number | null + booking_com_url: string | null + }[] + rates: Record> +} + +interface ScheduleInfo { + daily_time: string + today: string + weekday: string + tiers: { + high: { description: string; dates_today: number; range: string | null } + medium: { description: string; dates_today: number; range: string | null } + low: { description: string; dates_today: number; range: string | null } + } + total_dates_today: number +} + +interface QueueStatus { + statuses: Record + retries_pending: number + total_pending: number + total_completed: number + total_failed: number +} + +interface CoverageEntry { + date: string + tier: 'high' | 'medium' | 'low' | 'none' + last_scraped: string | null + next_expected: string | null +} + +interface CoverageResponse { + today: string + coverage: CoverageEntry[] +} + +interface ScrapeHistoryEntry { + batch_id: string + scrape_type: string + started_at: string | null + completed_at: string | null + status: string + dates_queued: number | null + dates_completed: number | null + dates_failed: number | null + hotels_found: number | null + rates_scraped: number | null + error_message: string | null + blocked_at: string | null + resume_after: string | null +} + +// ============================================ +// HELPERS +// ============================================ + +const formatCurrency = (value: number | null): string => { + if (value === null || value === undefined) return '-' + return new Intl.NumberFormat('en-GB', { + style: 'currency', + currency: 'GBP', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value) +} + +const formatDateShort = (dateStr: string): string => { + const date = new Date(dateStr + 'T00:00:00') + return date.toLocaleDateString('en-GB', { day: 'numeric' }) +} + +const formatDayOfWeek = (dateStr: string): string => { + const date = new Date(dateStr + 'T00:00:00') + return date.toLocaleDateString('en-GB', { weekday: 'short' }) +} + +const isWeekend = (dateStr: string): boolean => { + const date = new Date(dateStr + 'T00:00:00') + const day = date.getDay() + return day === 0 || day === 6 +} + +const formatDateTime = (iso: string | null): string => { + if (!iso) return '-' + const d = new Date(iso) + return d.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) +} + +const formatScrapeAge = (iso: string | null): string => { + if (!iso) return '' + const scraped = new Date(iso) + const now = new Date() + const diffMs = now.getTime() - scraped.getTime() + const diffMins = Math.floor(diffMs / 60000) + if (diffMins < 60) return `${diffMins}m ago` + const diffHours = Math.floor(diffMins / 60) + if (diffHours < 24) return `${diffHours}h ago` + const diffDays = Math.floor(diffHours / 24) + return `${diffDays}d ago` +} + +const tierColor = (tier: string) => { + switch (tier) { + case 'own': return '#2563eb' + case 'competitor': return '#d97706' + case 'market': return '#64748b' + default: return '#64748b' + } +} + +// ============================================ +// INLINE STYLE HELPERS (replacing theme utilities) +// ============================================ + +const mergeStyles = (...s: React.CSSProperties[]): React.CSSProperties => + Object.assign({}, ...s) + +const buttonStyle = (variant: 'primary' | 'secondary' | 'outline', size?: 'small'): React.CSSProperties => { + const base: React.CSSProperties = { + border: 'none', + borderRadius: '6px', + cursor: 'pointer', + fontWeight: 500, + padding: size === 'small' ? '4px 10px' : '8px 16px', + fontSize: size === 'small' ? '13px' : '14px', + lineHeight: 1.4, + transition: 'all 0.15s', + } + if (variant === 'primary') return { ...base, background: 'var(--gold)', color: '#fff' } + if (variant === 'secondary') return { ...base, background: 'var(--navy)', color: '#fff' } + return { ...base, background: 'transparent', color: 'var(--text-dark)', border: '1px solid var(--card-border)' } +} + +const badgeStyle = (variant: 'success' | 'error' | 'warning' | 'info'): React.CSSProperties => { + const map: Record = { + success: { background: '#dcfce7', color: '#16a34a', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + error: { background: '#fee2e2', color: '#dc2626', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + warning: { background: '#fef3c7', color: '#d97706', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + info: { background: '#dbeafe', color: '#2563eb', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 }, + } + return map[variant] || map.info +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '8px 10px', + borderRadius: '6px', + border: '1px solid var(--card-border)', + fontSize: '14px', + color: 'var(--text-dark)', + background: 'var(--card-bg)', + boxSizing: 'border-box', +} + +const inputLabelStyle: React.CSSProperties = { + display: 'block', + fontSize: '12px', + fontWeight: 600, + color: 'var(--text-mid)', + marginBottom: '4px', + textTransform: 'uppercase', + letterSpacing: '0.04em', +} + +// ============================================ +// TABS +// ============================================ + +type TabId = 'matrix' | 'hotels' | 'settings' + +// ============================================ +// STATUS PANEL +// ============================================ + +const StatusPanel: React.FC<{ status: ScraperStatus | undefined, isLoading: boolean }> = ({ status, isLoading }) => { + if (isLoading) return
Loading status...
+ if (!status) return null + + return ( +
+
+ Scraper + + {status.enabled ? 'Enabled' : 'Disabled'} + +
+ {status.paused && ( +
+ Status + + Paused{status.pause_until ? ` until ${formatDateTime(status.pause_until)}` : ''} + +
+ )} +
+ Location + + {status.location_name || 'Not configured'} + +
+
+ Backend + {status.backend} +
+ {status.last_scrape && ( +
+ Last Scrape + + {status.last_scrape.status} + + + {formatDateTime(status.last_scrape.completed_at || status.last_scrape.started_at)} + {status.last_scrape.hotels_found ? ` | ${status.last_scrape.hotels_found} hotels, ${status.last_scrape.rates_scraped} rates` : ''} + +
+ )} +
+ ) +} + +// ============================================ +// SETTINGS TAB +// ============================================ + +const SettingsTab: React.FC = () => { + const queryClient = useQueryClient() + const [locationName, setLocationName] = useState('') + const [pages, setPages] = useState(2) + const [adults, setAdults] = useState(2) + const [scrapeFrom, setScrapeFrom] = useState(() => fmtDate(new Date())) + const [scrapeTo, setScrapeTo] = useState(() => { + const d = new Date() + d.setDate(d.getDate() + 7) + return fmtDate(d) + }) + + const { data: status } = useQuery({ + queryKey: ['scraper-status'], + queryFn: async () => (await api.get('/competitor-rates/status')).data, + }) + + const { data: history } = useQuery({ + queryKey: ['scrape-history'], + queryFn: async () => (await api.get('/competitor-rates/scrape-history?limit=10')).data, + }) + + const { data: scheduleInfo } = useQuery({ + queryKey: ['schedule-info'], + queryFn: async () => (await api.get('/competitor-rates/schedule-info')).data, + }) + + const { data: queueStatus } = useQuery({ + queryKey: ['queue-status'], + queryFn: async () => (await api.get('/competitor-rates/queue-status')).data, + refetchInterval: 30000, + }) + + const { data: coverage } = useQuery({ + queryKey: ['scrape-coverage'], + queryFn: async () => (await api.get('/competitor-rates/scrape-coverage')).data, + staleTime: 60000, + }) + + const setLocationMutation = useMutation({ + mutationFn: async () => { + return (await api.post('/competitor-rates/config/location', { + location_name: locationName, + pages_to_scrape: pages, + adults: adults, + })).data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scraper-status'] }) + setLocationName('') + }, + }) + + const enableMutation = useMutation({ + mutationFn: async (enabled: boolean) => { + return (await api.post(`/competitor-rates/config/enable?enabled=${enabled}`)).data + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scraper-status'] }), + }) + + const unpauseMutation = useMutation({ + mutationFn: async () => (await api.post('/competitor-rates/config/unpause')).data, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scraper-status'] }), + }) + + const scrapeMutation = useMutation({ + mutationFn: async () => { + return (await api.post('/competitor-rates/scrape', { + from_date: scrapeFrom, + to_date: scrapeTo, + })).data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scraper-status'] }) + queryClient.invalidateQueries({ queryKey: ['scrape-history'] }) + }, + }) + + return ( +
+ {/* Location Configuration */} +
+

Location Configuration

+

+ Set the location for competitor rate scraping. + {status?.location_name && ( + <> Currently: {status.location_name} + )} +

+
+
+ + setLocationName(e.target.value)} + placeholder="e.g. Bowness-on-Windermere" + style={inputStyle} + /> +
+
+ + setPages(parseInt(e.target.value) || 2)} + min={1} + max={5} + style={inputStyle} + /> +
+
+ + setAdults(parseInt(e.target.value) || 2)} + min={1} + max={4} + style={inputStyle} + /> +
+
+ + {setLocationMutation.isError && ( +

+ {(setLocationMutation.error as any)?.response?.data?.detail || 'Failed to set location'} +

+ )} +
+ + {/* Scraper Controls */} +
+

Scraper Controls

+
+ + {status?.paused && ( + + )} +
+
+ + {/* Schedule Info */} +
+

Automatic Schedule

+ {scheduleInfo ? ( +
+

+ Runs daily at {scheduleInfo.daily_time} ({scheduleInfo.weekday}) +

+
+ {Object.entries(scheduleInfo.tiers).map(([key, tier]) => ( +
+
+ {key} + 0 ? 'info' : 'warning')}> + {tier.dates_today} dates + +
+

{tier.description}

+ {tier.range && ( +

{tier.range}

+ )} +
+ ))} +
+

+ Total today: {scheduleInfo.total_dates_today} dates +

+
+ ) : ( +

Loading schedule...

+ )} + + {/* Queue Status */} + {queueStatus && (queueStatus.total_pending > 0 || queueStatus.total_failed > 0) && ( +
+

Queue

+
+ {queueStatus.total_pending > 0 && ( + {queueStatus.total_pending} pending + )} + {queueStatus.retries_pending > 0 && ( + {queueStatus.retries_pending} retries + )} + {queueStatus.total_completed > 0 && ( + {queueStatus.total_completed} done + )} + {queueStatus.total_failed > 0 && ( + {queueStatus.total_failed} failed + )} +
+
+ )} +
+ + {/* Manual Scrape */} +
+

Manual Scrape

+

+ Trigger a one-off scrape for a date range. Runs in background. +

+
+
+ + setScrapeFrom(e.target.value)} + style={inputStyle} + /> +
+
+ + setScrapeTo(e.target.value)} + style={inputStyle} + /> +
+
+ + {!status?.location_configured && ( +

Configure a location first

+ )} + {scrapeMutation.isSuccess && ( +

+ Scrape started! Check status for progress. +

+ )} + {scrapeMutation.isError && ( +

+ {(scrapeMutation.error as any)?.response?.data?.detail || 'Failed to start scrape'} +

+ )} +
+ + {/* Scrape History */} +
+

Scrape History

+ {history && history.length > 0 ? ( +
+ + + + + + + + + + + + + {history.map(entry => ( + + + + + + + + + ))} + +
TypeStartedStatusHotelsRatesError
{entry.scrape_type}{formatDateTime(entry.started_at)} + + {entry.status} + + {entry.hotels_found ?? '-'}{entry.rates_scraped ?? '-'} + {entry.error_message || '-'} +
+
+ ) : ( +

No scrape history yet

+ )} +
+ + {/* Scrape Coverage - 365 day view */} +
+

Scrape Coverage (365 days)

+

+ Each cell is a date. Color shows freshness of data; letter shows priority (H=high, M=medium, L=low). +

+ {coverage ? :

Loading coverage...

} +
+
+ ) +} + +// ============================================ +// COVERAGE GRID +// ============================================ + +const freshnessColor = (lastScraped: string | null): React.CSSProperties => { + if (!lastScraped) return { background: '#e8e8e8', color: '#64748b' } + const hours = (Date.now() - new Date(lastScraped).getTime()) / 3600000 + if (hours < 24) return { background: '#c6efce', color: '#1a7a2e' } // green - fresh + if (hours < 72) return { background: '#fff3cd', color: '#856404' } // yellow - 1-3 days + if (hours < 168) return { background: '#ffe0b2', color: '#e65100' } // orange - 3-7 days + if (hours < 336) return { background: '#f8d7da', color: '#721c24' } // red - 7-14 days + return { background: '#c62828', color: '#ffffff' } // dark red - >14 days +} + +const tierLabel = (tier: string) => { + switch (tier) { + case 'high': return 'H' + case 'medium': return 'M' + case 'low': return 'L' + default: return '-' + } +} + +const CoverageGrid: React.FC<{ coverage: CoverageResponse }> = ({ coverage }) => { + // Group by month + const months = useMemo(() => { + const grouped: Record = {} + for (const entry of coverage.coverage) { + const monthKey = entry.date.substring(0, 7) // YYYY-MM + if (!grouped[monthKey]) grouped[monthKey] = [] + grouped[monthKey].push(entry) + } + return Object.entries(grouped) + }, [coverage.coverage]) + + const formatMonthLabel = (monthKey: string) => { + const [y, m] = monthKey.split('-') + const d = new Date(parseInt(y), parseInt(m) - 1, 1) + return d.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) + } + + const formatDateLabel = (dateStr: string) => { + const d = new Date(dateStr + 'T00:00:00') + return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' }) + } + + const formatAge = (iso: string | null): string => { + if (!iso) return 'Never scraped' + const hours = (Date.now() - new Date(iso).getTime()) / 3600000 + if (hours < 1) return `${Math.floor(hours * 60)}m ago` + if (hours < 24) return `${Math.floor(hours)}h ago` + return `${Math.floor(hours / 24)}d ago` + } + + return ( +
+ {/* Legend */} +
+ {'<'}24h + 1-3d + 3-7d + 7-14d + {'>'} 14d + Never + + H=High M=Medium L=Low priority + +
+ {months.map(([monthKey, entries]) => ( +
+
{formatMonthLabel(monthKey)}
+
+ {entries.map(entry => ( +
+ + {new Date(entry.date + 'T00:00:00').getDate()} + + + {tierLabel(entry.tier)} + +
+ ))} +
+
+ ))} +
+ ) +} + +// ============================================ +// HOTELS TAB +// ============================================ + +const HotelsTab: React.FC = () => { + const queryClient = useQueryClient() + const [tierFilter, setTierFilter] = useState('') + + const { data: hotels, isLoading } = useQuery({ + queryKey: ['competitor-hotels', tierFilter], + queryFn: async () => { + const params = tierFilter ? `?tier=${tierFilter}` : '' + return (await api.get(`/competitor-rates/hotels${params}`)).data + }, + }) + + const tierMutation = useMutation({ + mutationFn: async ({ hotelId, tier }: { hotelId: number, tier: string }) => { + return (await api.put(`/competitor-rates/hotels/${hotelId}/tier`, { tier })).data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['competitor-hotels'] }) + }, + }) + + const grouped = useMemo(() => { + if (!hotels) return { own: [], competitor: [], market: [] } + return { + own: hotels.filter(h => h.tier === 'own'), + competitor: hotels.filter(h => h.tier === 'competitor'), + market: hotels.filter(h => h.tier === 'market'), + } + }, [hotels]) + + const HotelCard: React.FC<{ hotel: Hotel }> = ({ hotel }) => ( +
+
+
+ {hotel.name} +
+ {hotel.star_rating && {hotel.star_rating} stars} + {hotel.review_score && Score: {hotel.review_score}} + {hotel.review_count && ({hotel.review_count} reviews)} +
+
+
+ +
+
+
+ + ID: {hotel.booking_com_id} + + {hotel.last_seen_at && ( + + Last seen: {formatDateTime(hotel.last_seen_at)} + + )} +
+
+ ) + + if (isLoading) { + return ( +
+
+ Loading hotels... +
+ ) + } + + return ( +
+ {/* Filter */} +
+ Filter: + {['', 'own', 'competitor', 'market'].map(t => ( + + ))} +
+ + {/* Hotels */} + {!hotels || hotels.length === 0 ? ( +
+

No Hotels Discovered

+

+ Run a scrape to discover hotels in your configured location. +

+
+ ) : ( +
+ {/* Own Hotel */} + {grouped.own.length > 0 && ( +
+

+ Your Hotel ({grouped.own.length}) +

+ {grouped.own.map(h => )} +
+ )} + + {/* Competitors */} + {grouped.competitor.length > 0 && ( +
+

+ Competitors ({grouped.competitor.length}) +

+ {grouped.competitor.map(h => )} +
+ )} + + {/* Market */} + {grouped.market.length > 0 && ( +
+

+ Market ({grouped.market.length}) +

+ {grouped.market.map(h => )} +
+ )} +
+ )} +
+ ) +} + +// ============================================ +// RATE MATRIX TAB +// ============================================ + +const MonthSelector: React.FC<{ + value: string + onChange: (value: string) => void +}> = ({ value, onChange }) => { + const handlePrevMonth = () => { + const [year, month] = value.split('-').map(Number) + const date = new Date(year, month - 2, 1) + onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) + } + + const handleNextMonth = () => { + const [year, month] = value.split('-').map(Number) + const date = new Date(year, month, 1) + onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`) + } + + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + for (let i = 0; i < 13; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1) + const monthValue = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const label = date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + options.push({ value: monthValue, label }) + } + return options + }, []) + + return ( +
+ + + +
+ ) +} + +const RateMatrixTab: React.FC = () => { + const [selectedMonth, setSelectedMonth] = useState(() => { + const today = new Date() + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` + }) + const [includeMarket, setIncludeMarket] = useState(false) + const [hoveredCell, setHoveredCell] = useState<{ row: number; col: number } | null>(null) + const [scrapingDate, setScrapingDate] = useState(null) + const queryClient = useQueryClient() + + const dateScrapeM = useMutation({ + mutationFn: async (d: string) => { + setScrapingDate(d) + return (await api.post('/competitor-rates/scrape', { from_date: d, to_date: d })).data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] }) + queryClient.invalidateQueries({ queryKey: ['scraper-status'] }) + setScrapingDate(null) + }, + onError: () => setScrapingDate(null), + }) + + const onCellEnter = useCallback((row: number, col: number) => { + setHoveredCell({ row, col }) + }, []) + const onCellLeave = useCallback(() => setHoveredCell(null), []) + + const { fromDate, toDate } = useMemo(() => { + const [year, month] = selectedMonth.split('-').map(Number) + return { + fromDate: fmtDate(new Date(year, month - 1, 1)), + toDate: fmtDate(new Date(year, month, 0)), + } + }, [selectedMonth]) + + const { data, isLoading, error } = useQuery({ + queryKey: ['competitor-matrix', fromDate, toDate, includeMarket], + queryFn: async () => { + const params = new URLSearchParams({ + from_date: fromDate, + to_date: toDate, + include_market: includeMarket.toString(), + }) + return (await api.get(`/competitor-rates/matrix?${params}`)).data + }, + }) + + const dates = data?.dates || [] + const rates = data?.rates || {} + + // Sort hotels: own first, then competitor, then market + const hotels = useMemo(() => { + const tierPriority: Record = { own: 0, competitor: 1, market: 2 } + return [...(data?.hotels || [])].sort((a, b) => { + const ta = tierPriority[a.tier] ?? 9 + const tb = tierPriority[b.tier] ?? 9 + if (ta !== tb) return ta - tb + return (a.display_order ?? 999) - (b.display_order ?? 999) + }) + }, [data?.hotels]) + + // Compute latest scraped_at per date column across all hotels + const scrapedAtByDate = useMemo(() => { + const result: Record = {} + for (const d of dates) { + let latest: string | null = null + for (const hotel of hotels) { + const rate = (rates[hotel.id] || {})[d] + if (rate?.scraped_at) { + if (!latest || rate.scraped_at > latest) { + latest = rate.scraped_at + } + } + } + result[d] = latest + } + return result + }, [dates, hotels, rates]) + + if (isLoading) { + return ( +
+
+ Loading rate matrix... +
+ ) + } + + if (error) { + return ( +
+ {(error as any)?.response?.data?.detail || 'Failed to load rate matrix'} +
+ ) + } + + return ( +
+ {/* Controls */} +
+ + +
+ + {hotels.length === 0 ? ( +
+

No Rate Data

+

+ Run a scrape and categorize hotels as competitors to see rate comparisons. +

+
+ ) : ( +
+ + + + + {dates.map((d, colIdx) => { + const scrapeAge = formatScrapeAge(scrapedAtByDate[d]) + const isColHovered = hoveredCell?.col === colIdx + return ( + + ) + })} + + + + {hotels.map((hotel, rowIdx) => { + const hotelRates = rates[hotel.id] || {} + const isRowHovered = hoveredCell?.row === rowIdx + return ( + + + {dates.map((d, colIdx) => { + const rate = hotelRates[d] + const isAvailable = rate?.availability_status === 'available' + const isSoldOut = rate?.availability_status === 'sold_out' + + let cellStyle: React.CSSProperties = styles.matrixCellEmpty + if (rate) { + if (isAvailable && rate.rate_gross) { + cellStyle = styles.matrixCellAvailable + } else if (isSoldOut) { + cellStyle = styles.matrixCellSoldOut + } else { + cellStyle = styles.matrixCellNoRate + } + } + + const tooltip = rate ? [ + rate.room_type, + rate.breakfast_included ? 'Breakfast incl.' : null, + rate.free_cancellation ? 'Free cancel' : null, + rate.rooms_left ? `${rate.rooms_left} left` : null, + ].filter(Boolean).join(' | ') : '' + + // Build booking.com link: strip existing date/guest params, add ours + let bookingUrl: string | null = null + if (hotel.booking_com_url) { + const checkin = d + const co = new Date(d + 'T00:00:00') + co.setDate(co.getDate() + 1) + const checkout = fmtDate(co) + try { + const url = new URL(hotel.booking_com_url) + const stripParams = ['checkin', 'checkout', 'group_adults', 'group_children', 'req_adults', 'req_children', 'no_rooms'] + stripParams.forEach(p => url.searchParams.delete(p)) + url.searchParams.set('checkin', checkin) + url.searchParams.set('checkout', checkout) + url.searchParams.set('group_adults', '2') + bookingUrl = url.toString() + } catch { + // Fallback if URL parsing fails + bookingUrl = hotel.booking_com_url + } + } + + const cellContent = rate ? ( + isAvailable && rate.rate_gross + ? formatCurrency(rate.rate_gross) + : isSoldOut + ? 'Sold' + : '-' + ) : '' + + const isRowH = hoveredCell?.row === rowIdx + const isColH = hoveredCell?.col === colIdx + const isCellH = isRowH && isColH + + return ( + + ) + })} + + ) + })} + +
Hotel +
+ {formatDayOfWeek(d)} + {formatDateShort(d)} + {scrapeAge ? ( + {scrapeAge} + ) : null} + +
+
+
+ + {hotel.name} + {hotel.star_rating && ( + {hotel.star_rating}* + )} +
+
onCellEnter(rowIdx, colIdx)} + onMouseLeave={onCellLeave} + > + {bookingUrl ? ( + + {cellContent} + + ) : cellContent} +
+
+ )} + + {/* Legend */} +
+ Legend: + Available + Sold Out + No Rate + No Data + + Own + Competitor + Market + +
+
+ ) +} + +// ============================================ +// MAIN COMPONENT +// ============================================ + +const CompetitorRates: React.FC = () => { + const [activeTab, setActiveTab] = useState('matrix') + + const { data: status, isLoading: statusLoading } = useQuery({ + queryKey: ['scraper-status'], + queryFn: async () => (await api.get('/competitor-rates/status')).data, + refetchInterval: 30000, + }) + + const tabs: { id: TabId; label: string }[] = [ + { id: 'matrix', label: 'Rate Matrix' }, + { id: 'hotels', label: 'Hotels' }, + { id: 'settings', label: 'Scraper Settings' }, + ] + + return ( +
+ {/* Header */} +
+
+

Competitor Rates

+

+ Compare rates across competitor hotels from Booking.com +

+
+
+ + {/* Status Bar */} + + + {/* Tabs */} +
+ {tabs.map(tab => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === 'matrix' && } + {activeTab === 'hotels' && } + {activeTab === 'settings' && } +
+
+ ) +} + +// ============================================ +// STYLES +// ============================================ + +const styles: Record = { + container: { + padding: '24px', + maxWidth: '100%', + margin: '0 auto', + }, + pageHeader: { + marginBottom: '16px', + }, + title: { + fontSize: '24px', + fontWeight: 700, + color: 'var(--text-dark)', + margin: 0, + }, + subtitle: { + fontSize: '13px', + color: 'var(--text-mid)', + margin: '4px 0 0', + }, + + // Status bar + statusBar: { + display: 'flex', + gap: '24px', + padding: '16px', + background: 'var(--card-bg)', + borderRadius: '10px', + boxShadow: 'var(--shadow-sm)', + marginBottom: '16px', + flexWrap: 'wrap', + alignItems: 'center', + }, + statusItem: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + statusLabel: { + fontSize: '11px', + color: 'var(--text-mid)', + textTransform: 'uppercase', + fontWeight: 500, + }, + + // Tabs + tabBar: { + display: 'flex', + gap: '4px', + borderBottom: '2px solid var(--card-border)', + marginBottom: '24px', + }, + tab: { + padding: '8px 24px', + background: 'transparent', + border: 'none', + borderBottom: '2px solid transparent', + cursor: 'pointer', + fontSize: '13px', + fontWeight: 500, + color: 'var(--text-mid)', + marginBottom: '-2px', + transition: 'all 0.2s', + }, + tabActive: { + color: 'var(--navy)', + borderBottomColor: 'var(--navy)', + fontWeight: 600, + }, + tabContent: { + minHeight: '300px', + }, + + // Cards + card: { + background: 'var(--card-bg)', + borderRadius: '10px', + padding: '24px', + boxShadow: 'var(--shadow-md)', + }, + cardTitle: { + fontSize: '16px', + fontWeight: 600, + color: 'var(--text-dark)', + margin: '0 0 4px', + }, + cardDescription: { + fontSize: '13px', + color: 'var(--text-mid)', + margin: '0 0 16px', + }, + + // Settings + settingsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', + gap: '24px', + }, + formRow: { + display: 'flex', + gap: '16px', + flexWrap: 'wrap', + }, + formGroup: { + flex: 1, + minWidth: '200px', + }, + formGroupSmall: { + width: '80px', + }, + controlRow: { + display: 'flex', + gap: '16px', + flexWrap: 'wrap', + }, + errorText: { + color: 'var(--danger)', + fontSize: '13px', + marginTop: '8px', + }, + hintText: { + color: 'var(--text-mid)', + fontSize: '11px', + marginTop: '8px', + fontStyle: 'italic', + }, + + // Hotels + filterRow: { + display: 'flex', + alignItems: 'center', + gap: '8px', + marginBottom: '24px', + flexWrap: 'wrap', + }, + filterLabel: { + fontSize: '13px', + fontWeight: 500, + color: 'var(--text-mid)', + }, + tierSection: { + marginBottom: '24px', + }, + tierHeader: { + fontSize: '14px', + fontWeight: 600, + margin: '0 0 8px', + }, + hotelCard: { + background: 'var(--card-bg)', + borderRadius: '10px', + padding: '16px', + boxShadow: 'var(--shadow-sm)', + marginBottom: '8px', + }, + hotelHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: '16px', + }, + hotelInfo: { + flex: 1, + }, + hotelName: { + fontSize: '13px', + fontWeight: 600, + color: 'var(--text-dark)', + }, + hotelMeta: { + display: 'flex', + gap: '16px', + fontSize: '11px', + color: 'var(--text-mid)', + marginTop: '4px', + }, + hotelActions: { + display: 'flex', + gap: '8px', + }, + tierSelect: { + padding: '4px 8px', + borderRadius: '6px', + border: '1px solid var(--card-border)', + fontSize: '11px', + cursor: 'pointer', + background: 'var(--card-bg)', + }, + hotelFooter: { + display: 'flex', + justifyContent: 'space-between', + marginTop: '8px', + paddingTop: '8px', + borderTop: '1px solid var(--card-border)', + }, + emptyState: { + textAlign: 'center', + padding: '48px', + background: 'var(--card-bg)', + borderRadius: '10px', + boxShadow: 'var(--shadow-sm)', + }, + + // Rate Matrix + matrixControls: { + display: 'flex', + alignItems: 'center', + gap: '24px', + marginBottom: '24px', + flexWrap: 'wrap', + }, + monthSelector: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + monthDropdown: { + fontSize: '14px', + fontWeight: 500, + color: 'var(--text-dark)', + padding: '4px 8px', + borderRadius: '6px', + border: '1px solid var(--card-border)', + background: 'var(--card-bg)', + cursor: 'pointer', + minWidth: '160px', + }, + checkboxLabel: { + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '13px', + color: 'var(--text-mid)', + cursor: 'pointer', + }, + matrixContainer: { + overflowX: 'auto', + background: 'var(--card-bg)', + borderRadius: '10px', + boxShadow: 'var(--shadow-md)', + }, + matrixTable: { + width: '100%', + borderCollapse: 'collapse', + fontSize: '11px', + minWidth: '800px', + }, + matrixTh: { + padding: '8px', + borderBottom: '2px solid var(--card-border)', + textAlign: 'center', + fontWeight: 600, + color: 'var(--text-dark)', + whiteSpace: 'nowrap', + background: 'var(--card-bg)', + fontSize: '11px', + }, + matrixTd: { + padding: '4px 8px', + borderBottom: '1px solid var(--card-border)', + textAlign: 'center', + whiteSpace: 'nowrap', + fontSize: '11px', + }, + stickyCol: { + position: 'sticky', + left: 0, + background: 'var(--card-bg)', + zIndex: 10, + textAlign: 'left', + minWidth: '180px', + maxWidth: '220px', + borderRight: '1px solid var(--card-border)', + }, + hotelNameCell: { + fontWeight: 500, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + matrixHotelInfo: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + tierDot: { + width: '8px', + height: '8px', + borderRadius: '50%', + flexShrink: 0, + display: 'inline-block', + }, + matrixHotelName: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + matrixStars: { + color: '#d97706', + fontSize: '11px', + flexShrink: 0, + }, + dateHeader: { + minWidth: '50px', + padding: '4px', + }, + dateHeaderContent: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '2px', + }, + dayOfWeek: { + fontSize: '11px', + color: 'var(--text-mid)', + }, + dayNum: { + fontSize: '13px', + fontWeight: 600, + }, + scrapeAge: { + fontSize: '9px', + color: 'var(--success)', + fontWeight: 400, + opacity: 0.8, + lineHeight: 1, + }, + weekendHeader: { + background: 'var(--body-bg)', + }, + weekendCell: { + borderLeft: '2px solid var(--card-border)', + }, + matrixCellAvailable: { + background: '#dcfce7', + color: 'var(--success)', + fontWeight: 600, + }, + matrixCellSoldOut: { + background: '#fee2e2', + color: 'var(--danger)', + }, + matrixCellNoRate: { + background: '#fef3c7', + color: '#d97706', + }, + matrixCellEmpty: { + background: 'var(--body-bg)', + color: 'var(--text-mid)', + }, + matrixCellLink: { + color: 'inherit', + textDecoration: 'none', + display: 'block', + width: '100%', + height: '100%', + } as React.CSSProperties, + crosshairHighlight: { + boxShadow: 'inset 0 0 0 1px #1a1a2e33', + background: '#1a1a2e08', + }, + crosshairCell: { + boxShadow: 'inset 0 0 0 2px var(--navy)', + }, + crosshairRow: { + boxShadow: 'inset 0 0 0 1px #1a1a2e33', + background: '#1a1a2e08', + }, + crosshairCol: { + boxShadow: 'inset 0 0 0 1px #1a1a2e33', + background: '#1a1a2e08', + }, + scrapeBtn: { + background: 'none', + border: '1px solid var(--card-border)', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '10px', + lineHeight: 1, + padding: '2px 4px', + color: 'var(--text-mid)', + opacity: 0.6, + transition: 'opacity 0.15s', + }, + scrapeBtnActive: { + opacity: 1, + color: 'var(--navy)', + borderColor: 'var(--navy)', + }, + + // Shared + table: { + width: '100%', + borderCollapse: 'collapse', + fontSize: '13px', + }, + th: { + padding: '8px', + borderBottom: '2px solid var(--card-border)', + textAlign: 'left', + fontWeight: 600, + color: 'var(--text-dark)', + whiteSpace: 'nowrap', + fontSize: '11px', + }, + td: { + padding: '8px', + borderBottom: '1px solid var(--card-border)', + fontSize: '13px', + }, + historyTable: { + overflowX: 'auto', + }, + noData: { + textAlign: 'center', + padding: '24px', + color: 'var(--text-mid)', + fontSize: '13px', + }, + legend: { + display: 'flex', + alignItems: 'center', + gap: '16px', + marginTop: '24px', + padding: '16px', + background: 'var(--card-bg)', + borderRadius: '10px', + fontSize: '13px', + flexWrap: 'wrap', + }, + legendTitle: { + fontWeight: 600, + color: 'var(--text-dark)', + }, + legendItem: { + padding: '4px 8px', + borderRadius: '4px', + fontSize: '11px', + }, + loading: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '48px', + gap: '16px', + color: 'var(--text-mid)', + }, + spinner: { + width: '40px', + height: '40px', + border: '3px solid var(--card-border)', + borderTop: '3px solid var(--navy)', + borderRadius: '50%', + animation: 'spin 1s linear infinite', + }, + errorBox: { + padding: '24px', + background: '#fee2e2', + color: 'var(--danger)', + borderRadius: '10px', + }, + + // Schedule + scheduleGrid: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + scheduleTier: { + padding: '8px', + background: 'var(--body-bg)', + borderRadius: '6px', + }, + scheduleTierHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + scheduleTierName: { + fontSize: '13px', + fontWeight: 600, + color: 'var(--text-dark)', + textTransform: 'capitalize', + }, + scheduleTierDesc: { + fontSize: '11px', + color: 'var(--text-mid)', + margin: '4px 0 0', + }, + scheduleTierRange: { + fontSize: '11px', + color: 'var(--text-mid)', + margin: '2px 0 0', + fontFamily: 'monospace', + }, + queuePanel: { + padding: '8px', + background: 'var(--body-bg)', + borderRadius: '6px', + }, + queueStats: { + display: 'flex', + gap: '8px', + flexWrap: 'wrap', + }, + + // Coverage grid + coverageContainer: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + coverageLegend: { + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '11px', + flexWrap: 'wrap', + }, + coverageLegendItem: { + padding: '2px 8px', + borderRadius: '4px', + fontSize: '11px', + }, + coverageMonth: { + display: 'flex', + alignItems: 'flex-start', + gap: '8px', + }, + coverageMonthLabel: { + fontSize: '11px', + fontWeight: 600, + color: 'var(--text-dark)', + minWidth: '70px', + paddingTop: '3px', + flexShrink: 0, + }, + coverageCells: { + display: 'flex', + flexWrap: 'wrap', + gap: '3px', + }, + coverageCell: { + width: '32px', + height: '28px', + borderRadius: '3px', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + cursor: 'default', + lineHeight: 1, + border: '1px solid rgba(0,0,0,0.06)', + }, + coverageCellDay: { + fontSize: '9px', + fontWeight: 600, + }, + coverageCellTier: { + fontSize: '7px', + opacity: 0.7, + }, +} + +export default CompetitorRates diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..dae402b --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { RefreshCw, Bot, Clock } from 'lucide-react' +import api from '../api' + +interface AIInsight { + id: number + generated_at: string + content: string + model: string + input_tokens: number + output_tokens: number + triggered_by: string +} + +function formatAge(iso: string): string { + const ms = Date.now() - new Date(iso).getTime() + const mins = Math.floor(ms / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + return `${Math.floor(hours / 24)}d ago` +} + +function renderContent(text: string) { + return text.split('\n').map((line, i) => { + const processed = line.replace(/\*\*(.+?)\*\*/g, '$1') + if (line.startsWith('- ') || line.startsWith('* ')) { + return ( +
+ + +
+ ) + } + if (line.startsWith('## ') || line.startsWith('# ')) { + const txt = line.replace(/^#+\s*/, '') + return

{txt}

+ } + if (line.trim() === '') return
+ return

+ }) +} + +export default function Dashboard() { + const qc = useQueryClient() + const [genError, setGenError] = useState(null) + + const { data: insight, isLoading } = useQuery({ + queryKey: ['ai-insights-latest'], + queryFn: () => api.get('/ai-insights/latest').then(r => r.data), + refetchInterval: 5 * 60_000, + }) + + const generate = useMutation({ + mutationFn: () => api.post('/ai-insights/generate').then(r => r.data), + onSuccess: () => { + setGenError(null) + qc.invalidateQueries({ queryKey: ['ai-insights-latest'] }) + }, + onError: (err: any) => { + setGenError(err.response?.data?.detail || 'Failed to generate insight') + }, + }) + + return ( +

+
+
+
Dashboard
+
Daily AI-generated forecast summary
+
+ +
+ + {genError && ( +
+ {genError} +
+ )} + +
+
+ + + AI Insight + + {insight && ( + + + {formatAge(insight.generated_at)} + {insight.model && {insight.model}} + + )} +
+
+ {isLoading && ( +
+
+ Loading insight… +
+ )} + {!isLoading && !insight && ( +
+ +

No insight generated yet.

+

Click Generate Now to produce a daily summary.

+
+ )} + {insight && ( + <> +
{renderContent(insight.content)}
+ {(insight.input_tokens || insight.output_tokens) && ( +
+ {insight.input_tokens}↑ / {insight.output_tokens}↓ tokens + · {insight.triggered_by} +
+ )} + + )} +
+
+
+ ) +} diff --git a/frontend/src/pages/Forecasts.tsx b/frontend/src/pages/Forecasts.tsx new file mode 100644 index 0000000..e482a42 --- /dev/null +++ b/frontend/src/pages/Forecasts.tsx @@ -0,0 +1,9973 @@ +import React, { useState, useMemo, useRef } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import api from '../api' +import { useQuery } from '@tanstack/react-query' +import Plot from 'react-plotly.js' + +type ForecastPage = 'accom' | 'dry' | 'wet' | 'total' | 'occupancy' | 'rooms' | 'preview' | 'prophet' | 'xgboost' | 'catboost' | 'blended' | 'pickup_v2' | 'compare' | 'accom_day' | 'accom_week' | 'accom_month' | 'bookings_day' | 'bookings_week' | 'bookings_month' | 'covers_day' | 'covers_week' | 'covers_month' | 'resos_dry_day' | 'resos_dry_week' | 'resos_dry_month' | 'resos_wet_day' | 'resos_wet_week' | 'resos_wet_month' | 'total_rev_day' | 'total_rev_week' | 'total_rev_month' | 'hotel_rev_day' | 'hotel_rev_week' | 'hotel_rev_month' +type MetricType = 'occupancy' | 'rooms' | 'guests' | 'ave_guest_rate' | 'arr' | 'net_accom' | 'net_dry' | 'net_wet' | 'total_rev' + +// Consistent forecast model colors +const CHART_COLORS = { + currentOtb: '#10b981', // Green - confirmed/safe bookings + pickup: '#ef4444', // Red - pickup forecast + prophet: '#3b82f6', // Blue - prophet forecast + prophetConfidence: 'rgba(59, 130, 246, 0.15)', // Light blue fill + xgboost: '#fdba74', // Light orange - xgboost forecast + catboost: '#9467bd', // Purple - catboost forecast + blended: '#ea580c', // Dark orange - blended forecast + priorOtb: '#9ca3af', // Gray dashed + priorFinal: '#6b7280', // Darker gray dotted + priorFinalFill: 'rgba(107, 114, 128, 0.1)', + budget: '#8b5cf6', // Purple - budget target line + futureOtb: '#06b6d4', // Cyan - future OTB from bookings +} + +// Revenue metrics that support budget comparison +const REVENUE_METRICS = ['net_accom', 'net_dry', 'net_wet', 'total_rev'] + +// Metrics that have OTB (on-the-books) data available +const OTB_METRICS = ['net_accom', 'occupancy', 'rooms'] + +// Helper to fetch and build budget trace for charts +const useBudgetData = (startDate: string, endDate: string, metric: MetricType) => { + const { data: budgetData } = useQuery<{ date: string; budget_type: string; budget_value: number }[]>({ + queryKey: ['daily-budgets-chart', startDate, endDate, metric], + queryFn: async () => { + if (!REVENUE_METRICS.includes(metric)) return [] + const { data } = await api.get('/budget/daily', { + params: { from_date: startDate, to_date: endDate, budget_type: metric } + }) + return data + }, + enabled: !!startDate && !!endDate && REVENUE_METRICS.includes(metric), + }) + return budgetData +} + +// Build budget trace for Plotly chart - returns any to avoid strict type conflicts with existing traces +const buildBudgetTrace = (budgetData: { date: string; budget_value: number }[] | undefined): any => { + if (!budgetData || budgetData.length === 0) return null + return { + x: budgetData.map(d => d.date), + y: budgetData.map(d => d.budget_value) as (number | null)[], + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Budget Target', + line: { color: CHART_COLORS.budget, width: 2, dash: 'dash' as const }, + hovertemplate: 'Budget: £%{y:,.0f}', + } +} + +interface PreviewDataPoint { + date: string + day_of_week: string + lead_days: number + current_otb: number | null + prior_year_date: string + prior_year_dow: string + prior_year_otb: number | null + prior_year_final: number | null + expected_pickup: number | null + forecast: number | null + pace_vs_prior_pct: number | null +} + +interface PreviewSummary { + otb_total: number + forecast_total: number + prior_otb_total: number + prior_final_total: number + pace_pct: number | null + days_count: number +} + +interface PreviewResponse { + data: PreviewDataPoint[] + summary: PreviewSummary +} + +interface PaceCurvePoint { + days_out: number + rooms: number | null +} + +interface PaceCurveResponse { + arrival_date: string + day_of_week: string + current_year: PaceCurvePoint[] + prior_year: PaceCurvePoint[] + final_value: number | null + prior_year_final: number | null +} + +interface ProphetDataPoint { + date: string + day_of_week: string + current_otb: number | null + prior_year_otb: number | null + forecast: number | null + forecast_lower: number | null + forecast_upper: number | null + prior_year_final: number | null +} + +interface ProphetSummary { + otb_total: number + prior_otb_total: number + forecast_total: number + prior_final_total: number + days_count: number + days_forecasting_more: number + days_forecasting_less: number +} + +interface ProphetResponse { + data: ProphetDataPoint[] + summary: ProphetSummary +} + +interface XGBoostDataPoint { + date: string + day_of_week: string + current_otb: number | null + prior_year_otb: number | null + forecast: number | null + prior_year_final: number | null +} + +interface XGBoostSummary { + otb_total: number + prior_otb_total: number + forecast_total: number + prior_final_total: number + days_count: number + days_forecasting_more: number + days_forecasting_less: number +} + +interface XGBoostResponse { + data: XGBoostDataPoint[] + summary: XGBoostSummary +} + +interface CatBoostDataPoint { + date: string + day_of_week: string + current_otb: number | null + prior_year_otb: number | null + forecast: number | null + prior_year_final: number | null +} + +interface CatBoostSummary { + otb_total: number + prior_otb_total: number + forecast_total: number + prior_final_total: number + days_count: number + days_forecasting_more: number + days_forecasting_less: number +} + +interface CatBoostResponse { + data: CatBoostDataPoint[] + summary: CatBoostSummary +} + +interface BlendedPreviewDataPoint { + date: string + day_of_week: string + current_otb: number | null + prior_year_otb: number | null + blended_forecast: number | null + prophet_forecast: number | null + xgboost_forecast: number | null + catboost_forecast: number | null + budget_or_prior: number | null + prior_year_final: number | null +} + +interface BlendedPreviewSummary { + otb_total: number + prior_otb_total: number + forecast_total: number + prior_final_total: number + days_count: number + days_forecasting_more: number + days_forecasting_less: number + prophet_weight: number + xgboost_weight: number + catboost_weight: number +} + +interface BlendedPreviewResponse { + data: BlendedPreviewDataPoint[] + summary: BlendedPreviewSummary +} + +// Pickup-V2 interfaces +interface PickupV2DataPoint { + date: string + day_of_week: string + lead_days: number + prior_year_date: string + current_otb_rev: number | null + prior_year_otb_rev: number | null + prior_year_final_rev: number | null + expected_pickup_rev: number | null + forecast: number + upper_bound: number | null + lower_bound: number | null + ceiling: number | null + // Scenario values + at_prior_adr: number | null + at_current_rate: number | null + at_cheaper_50: number | null + at_expensive_50: number | null + // Pricing opportunity fields + has_pricing_opportunity: boolean | null + lost_potential: number | null + rate_gap: number | null + rate_vs_prior_pct: number | null + pace_vs_prior_pct: number | null + pickup_rooms_total: number | null + // Weighted average rates per room for display (net) + weighted_avg_prior_rate: number | null + weighted_avg_current_rate: number | null + // Gross rates (inc VAT) for UI display + weighted_avg_prior_rate_gross: number | null + weighted_avg_current_rate_gross: number | null + // Listed rate at lead time (earliest bookings) - for rate comparison + weighted_avg_listed_rate: number | null + weighted_avg_listed_rate_gross: number | null + // Effective rate = rate actually used in forecast (min of prior and current) + effective_rate: number | null + effective_rate_gross: number | null + // Room metrics + current_otb: number | null + prior_year_otb: number | null + prior_year_final: number | null + expected_pickup: number | null + floor: number | null + category_breakdown: Record | null +} + +interface PickupV2Summary { + otb_rev_total: number | null + forecast_total: number + upper_total: number | null + lower_total: number | null + prior_final_total: number | null + avg_adr_position: number | null + avg_pace_pct: number | null + days_count: number + // Pricing opportunity summary + lost_potential_total: number | null + opportunity_days_count: number | null +} + +interface PickupV2Response { + data: PickupV2DataPoint[] + summary: PickupV2Summary +} + +const Forecasts: React.FC = () => { + const { forecastId } = useParams<{ forecastId?: string }>() + const navigate = useNavigate() + const activePage = (forecastId as ForecastPage) || 'hotel_rev_day' + + const revenueItems: { id: ForecastPage; label: string }[] = [ + { id: 'accom', label: 'Accommodation' }, + { id: 'dry', label: 'Dry (Food)' }, + { id: 'wet', label: 'Wet (Beverage)' }, + { id: 'total', label: 'Total Revenue' }, + ] + + const occupancyItems: { id: ForecastPage; label: string }[] = [ + { id: 'occupancy', label: 'Occupancy %' }, + { id: 'rooms', label: 'Room Nights' }, + ] + + // Combined Total Revenue (top of sidebar) + const totalRevenueItems: { id: ForecastPage; label: string }[] = [ + { id: 'total_rev_day', label: 'Total by Day' }, + { id: 'total_rev_week', label: 'Total by Week' }, + { id: 'total_rev_month', label: 'Total by Month' }, + ] + + // Hotel Revenue section (accommodation) + const hotelRevenueItems: { id: ForecastPage; label: string }[] = [ + { id: 'hotel_rev_day', label: 'Revenue by Day' }, + { id: 'hotel_rev_week', label: 'Revenue by Week' }, + { id: 'hotel_rev_month', label: 'Revenue by Month' }, + ] + + // Hotel Bookings section (room nights) + const hotelBookingsItems: { id: ForecastPage; label: string }[] = [ + { id: 'bookings_day', label: 'Bookings by Day' }, + { id: 'bookings_week', label: 'Bookings by Week' }, + { id: 'bookings_month', label: 'Bookings by Month' }, + ] + + // Restaurant Revenue section + const restaurantDryItems: { id: ForecastPage; label: string }[] = [ + { id: 'resos_dry_day', label: 'Dry by Day' }, + { id: 'resos_dry_week', label: 'Dry by Week' }, + { id: 'resos_dry_month', label: 'Dry by Month' }, + ] + + const restaurantWetItems: { id: ForecastPage; label: string }[] = [ + { id: 'resos_wet_day', label: 'Wet by Day' }, + { id: 'resos_wet_week', label: 'Wet by Week' }, + { id: 'resos_wet_month', label: 'Wet by Month' }, + ] + + // Restaurant Covers section + const restaurantCoversItems: { id: ForecastPage; label: string }[] = [ + { id: 'covers_day', label: 'Covers by Day' }, + { id: 'covers_week', label: 'Covers by Week' }, + { id: 'covers_month', label: 'Covers by Month' }, + ] + + const previewItems: { id: ForecastPage; label: string }[] = [ + { id: 'preview', label: 'Pickup' }, + { id: 'pickup_v2', label: 'Pickup-V2 (Revenue)' }, + { id: 'prophet', label: 'Prophet' }, + { id: 'xgboost', label: 'XGBoost' }, + { id: 'catboost', label: 'CatBoost' }, + { id: 'blended', label: 'Blended' }, + ] + + // Collapsible state for Testing section (closed by default) + const [testingExpanded, setTestingExpanded] = useState(false) + + const handlePageChange = (id: ForecastPage) => { + navigate(`/forecasts/${id}`) + } + + return ( +
+
+ {/* FORECASTS - Production models */} +

Forecasts

+ + + {/* TESTING FORECASTS - Development/preview models (collapsible) */} +

setTestingExpanded(!testingExpanded)} + > + Testing Forecasts + + {testingExpanded ? '▼' : '▶'} + +

+ {testingExpanded && ( + + )} +
+ +
+ {/* Combined Total Revenue (Accom + Dry + Wet) */} + {activePage === 'total_rev_day' && } + {activePage === 'total_rev_week' && } + {activePage === 'total_rev_month' && } + {/* Hotel Revenue (Accommodation) - Uses existing PickupV2Forecast */} + {activePage === 'hotel_rev_day' && } + {activePage === 'hotel_rev_week' && } + {activePage === 'hotel_rev_month' && } + {/* Legacy accom routes (redirect to hotel_rev) */} + {activePage === 'accom_day' && } + {activePage === 'accom_week' && } + {activePage === 'accom_month' && } + {/* Hotel Bookings (Room Nights) */} + {activePage === 'bookings_day' && } + {activePage === 'bookings_week' && } + {activePage === 'bookings_month' && } + {/* Restaurant Revenue - Dry (Food) */} + {activePage === 'resos_dry_day' && } + {activePage === 'resos_dry_week' && } + {activePage === 'resos_dry_month' && } + {/* Restaurant Revenue - Wet (Drinks) */} + {activePage === 'resos_wet_day' && } + {activePage === 'resos_wet_week' && } + {activePage === 'resos_wet_month' && } + {/* Restaurant Covers */} + {activePage === 'covers_day' && } + {activePage === 'covers_week' && } + {activePage === 'covers_month' && } + {/* Testing Forecasts */} + {activePage === 'accom' && } + {activePage === 'dry' && } + {activePage === 'wet' && } + {activePage === 'total' && } + {activePage === 'occupancy' && } + {activePage === 'rooms' && } + {activePage === 'preview' && } + {activePage === 'prophet' && } + {activePage === 'xgboost' && } + {activePage === 'catboost' && } + {activePage === 'blended' && } + {activePage === 'pickup_v2' && } + {activePage === 'compare' && } +
+
+ ) +} + +// Helper to format date as YYYY-MM-DD without timezone issues +const formatDate = (date: Date): string => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +// Helper to generate next 12 months from current month +const getNext12Months = () => { + const months: { label: string; start: string; end: string }[] = [] + const now = new Date() + + for (let i = 0; i < 12; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1) + const year = date.getFullYear() + const month = date.getMonth() + + // First day of month + const startDate = new Date(year, month, 1) + // Last day of month + const endDate = new Date(year, month + 1, 0) + + const monthName = date.toLocaleString('default', { month: 'short' }) + + months.push({ + label: `${monthName} ${year}`, + start: formatDate(startDate), + end: formatDate(endDate), + }) + } + + return months +} + +// ============================================ +// METRIC FORECAST COMPONENT +// Combines actuals with blended forecast for a specific metric +// ============================================ + +interface MetricForecastProps { + metric: MetricType + title: string +} + +interface ActualsDataPoint { + date: string + day_of_week: string + actual_value: number | null + prior_year_value: number | null + budget_value: number | null +} + +interface ActualsResponse { + data: ActualsDataPoint[] + summary: { + actual_total: number + prior_year_total: number + budget_total: number + days_with_actuals: number + total_days: number + } +} + +const MetricForecast: React.FC = ({ metric, title }) => { + + // Default to current month, 1 month duration + const today = new Date() + const [selectedMonth, setSelectedMonth] = useState(() => `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`) + const [duration, setDuration] = useState<'1' | '3' | '6' | '12'>('1') + const [showTable, setShowTable] = useState(false) + const [consolidation, setConsolidation] = useState<'daily' | 'weekly' | 'monthly'>('daily') + + // Generate month options (24 months back + current + 12 months forward) + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + // Start 24 months ago, end 12 months ahead (37 months total) + for (let i = -24; i <= 12; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1) + const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const label = date.toLocaleString('default', { month: 'short', year: 'numeric' }) + options.push({ value, label }) + } + return options + }, []) + + // Calculate start and end dates based on selected month and duration + const { startDate, endDate } = useMemo(() => { + const [year, month] = selectedMonth.split('-').map(Number) + const start = new Date(year, month - 1, 1) + const durationMonths = parseInt(duration) + const end = new Date(year, month - 1 + durationMonths, 0) // Last day of the final month + return { + startDate: formatDate(start), + endDate: formatDate(end) + } + }, [selectedMonth, duration]) + + // Fetch actuals data + const { data: actualsData, isLoading: actualsLoading } = useQuery({ + queryKey: ['actuals', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/actuals', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch all three model forecasts for blending + const { data: prophetData, isLoading: prophetLoading } = useQuery({ + queryKey: ['prophet-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/prophet-preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + const { data: xgboostData, isLoading: xgboostLoading } = useQuery({ + queryKey: ['xgboost-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/xgboost-preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + const { data: catboostData, isLoading: catboostLoading } = useQuery({ + queryKey: ['catboost-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/catboost-preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch budget data for revenue metrics + const budgetData = useBudgetData(startDate, endDate, metric) + + const isLoading = actualsLoading || prophetLoading || xgboostLoading || catboostLoading + const isRevenueMetric = REVENUE_METRICS.includes(metric) + + const metricLabel = { + occupancy: 'Occupancy %', + rooms: 'Room Nights', + guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', + arr: 'ARR', + net_accom: 'Net Accommodation Revenue', + net_dry: 'Net Dry Revenue', + net_wet: 'Net Wet Revenue', + total_rev: 'Total Net Revenue', + }[metric] || 'Value' + + const unit = { + occupancy: '%', + rooms: ' rooms', + guests: ' guests', + ave_guest_rate: '', + arr: '', + net_accom: '', + net_dry: '', + net_wet: '', + total_rev: '', + }[metric] || '' + + // Build budget map + const budgetMap = useMemo(() => { + const map: Record = {} + if (budgetData) { + for (const b of budgetData) { + map[b.date] = b.budget_value + } + } + return map + }, [budgetData]) + + // Combine actuals + forecast data + const combinedData = useMemo(() => { + if (!actualsData?.data || !prophetData?.data || !xgboostData?.data || !catboostData?.data) return null + + const todayStr = formatDate(new Date()) + + // Create a map of forecast data by date + const forecastMap: Record = {} + prophetData.data.forEach((row, idx) => { + const xgRow = xgboostData.data[idx] + const catRow = catboostData.data[idx] + forecastMap[row.date] = { + prophet: row.forecast ?? 0, + xgboost: xgRow?.forecast ?? 0, + catboost: catRow?.forecast ?? 0, + priorYearFinal: row.prior_year_final, + } + }) + + const combined = actualsData.data.map(row => { + const hasActual = row.actual_value !== null && row.date < todayStr // Exclude today - day not finished + const forecast = forecastMap[row.date] + // Use budget from actuals API response, fallback to budgetMap for non-revenue metrics + const budget = row.budget_value ?? budgetMap[row.date] ?? null + // OTB value for future dates (accommodation revenue from bookings) + const otbValue = (row as { otb_value?: number | null }).otb_value ?? null + + // Calculate blended forecast (60% model avg + 40% budget/prior year) + let blendedForecast: number | null = null + if (forecast) { + const modelAvg = (forecast.prophet + forecast.xgboost + forecast.catboost) / 3 + // Temporarily using 100% model average (no budget/prior year weighting) + blendedForecast = modelAvg + + // Floor cap: forecast can't be below OTB (confirmed bookings) + // This applies to metrics where OTB represents guaranteed revenue/bookings + if (otbValue !== null && otbValue > 0 && blendedForecast < otbValue) { + blendedForecast = otbValue + } + } + + // Use actual if available, otherwise use blended forecast + const displayValue = hasActual ? row.actual_value : blendedForecast + + return { + date: row.date, + day_of_week: row.day_of_week, + actual_value: row.actual_value, + prior_year_value: row.prior_year_value, + budget_value: budget, + otb_value: otbValue, + blended_forecast: blendedForecast, + display_value: displayValue, + is_actual: hasActual, + prior_year_final: forecast?.priorYearFinal ?? null, + } + }) + + // Calculate summary statistics + const actualsToDate = combined.filter(d => d.is_actual) + const forecastRemaining = combined.filter(d => !d.is_actual) + + // For percentage metrics (occupancy), use averages; for others, use sums + const isPctMetric = metric === 'occupancy' + + // Calculate totals (sums for revenue/rooms, will convert to avg for pct metrics) + const actualSum = actualsToDate.reduce((sum, d) => sum + (d.actual_value ?? 0), 0) + const actualBudgetSum = actualsToDate.reduce((sum, d) => sum + (d.budget_value ?? 0), 0) + const actualPriorSum = actualsToDate.reduce((sum, d) => sum + (d.prior_year_value ?? 0), 0) + + const forecastSum = forecastRemaining.reduce((sum, d) => sum + (d.blended_forecast ?? 0), 0) + const forecastBudgetSum = forecastRemaining.reduce((sum, d) => sum + (d.budget_value ?? 0), 0) + const forecastPriorSum = forecastRemaining.reduce((sum, d) => sum + (d.prior_year_value ?? 0), 0) + + // For percentage metrics, convert sums to averages + const actualTotal = isPctMetric && actualsToDate.length > 0 ? actualSum / actualsToDate.length : actualSum + const actualBudgetTotal = isPctMetric && actualsToDate.length > 0 ? actualBudgetSum / actualsToDate.length : actualBudgetSum + const actualPriorTotal = isPctMetric && actualsToDate.length > 0 ? actualPriorSum / actualsToDate.length : actualPriorSum + + const forecastTotal = isPctMetric && forecastRemaining.length > 0 ? forecastSum / forecastRemaining.length : forecastSum + const forecastBudgetTotal = isPctMetric && forecastRemaining.length > 0 ? forecastBudgetSum / forecastRemaining.length : forecastBudgetSum + const forecastPriorTotal = isPctMetric && forecastRemaining.length > 0 ? forecastPriorSum / forecastRemaining.length : forecastPriorSum + + // OTB totals for future dates (accommodation revenue) + const otbSum = forecastRemaining.reduce((sum, d) => sum + (d.otb_value ?? 0), 0) + const daysWithOtb = forecastRemaining.filter(d => d.otb_value !== null).length + // For percentage metrics, average the OTB values + const otbTotal = isPctMetric && daysWithOtb > 0 ? otbSum / daysWithOtb : otbSum + + // For percentage metrics, projected is weighted average of actual and forecast periods + const totalDays = actualsToDate.length + forecastRemaining.length + const projectedTotal = isPctMetric && totalDays > 0 + ? (actualSum + forecastSum) / totalDays + : actualTotal + forecastTotal + const totalBudget = isPctMetric && totalDays > 0 + ? (actualBudgetSum + forecastBudgetSum) / totalDays + : actualBudgetTotal + forecastBudgetTotal + const totalPriorYear = isPctMetric && totalDays > 0 + ? (actualPriorSum + forecastPriorSum) / totalDays + : actualPriorTotal + forecastPriorTotal + + const budgetVariance = projectedTotal - totalBudget + const budgetVariancePct = totalBudget > 0 ? ((projectedTotal / totalBudget) - 1) * 100 : 0 + const priorYearVariance = projectedTotal - totalPriorYear + const priorYearVariancePct = totalPriorYear > 0 ? ((projectedTotal / totalPriorYear) - 1) * 100 : 0 + + return { + data: combined, + summary: { + actual_total: actualTotal, + actual_budget_total: actualBudgetTotal, + actual_prior_total: actualPriorTotal, + actual_variance: actualTotal - actualBudgetTotal, + actual_prior_variance: actualTotal - actualPriorTotal, + forecast_total: forecastTotal, + forecast_budget_total: forecastBudgetTotal, + forecast_prior_total: forecastPriorTotal, + forecast_variance: forecastTotal - forecastBudgetTotal, + forecast_prior_variance: forecastTotal - forecastPriorTotal, + otb_total: otbTotal, + days_with_otb: daysWithOtb, + projected_total: projectedTotal, + total_budget: totalBudget, + total_prior_year: totalPriorYear, + budget_variance: budgetVariance, + budget_variance_pct: budgetVariancePct, + prior_year_variance: priorYearVariance, + prior_year_variance_pct: priorYearVariancePct, + days_actual: actualsToDate.length, + days_forecast: forecastRemaining.length, + is_pct_metric: isPctMetric, + } + } + }, [actualsData, prophetData, xgboostData, catboostData, budgetMap, isRevenueMetric]) + + // Consolidate data by week or month + const consolidatedData = useMemo(() => { + if (!combinedData?.data || consolidation === 'daily') return null + + const groups: Record = {} + + combinedData.data.forEach(d => { + const dateObj = new Date(d.date) + let groupKey: string + let groupLabel: string + + if (consolidation === 'weekly') { + // Get ISO week: year + week number + const jan1 = new Date(dateObj.getFullYear(), 0, 1) + const weekNum = Math.ceil(((dateObj.getTime() - jan1.getTime()) / 86400000 + jan1.getDay() + 1) / 7) + groupKey = `${dateObj.getFullYear()}-W${String(weekNum).padStart(2, '0')}` + // Get Monday of this week for label + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + groupLabel = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })})` + } else { + // Monthly + groupKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}` + groupLabel = dateObj.toLocaleString('default', { month: 'short', year: 'numeric' }) + } + + if (!groups[groupKey]) { + groups[groupKey] = { + label: groupLabel, + actual: 0, + forecast: 0, + otb: 0, + budget: 0, + priorYear: 0, + days: 0, + actualDays: 0, + forecastDays: 0, + otbDays: 0 + } + } + + if (d.is_actual) { + groups[groupKey].actual += d.actual_value ?? 0 + groups[groupKey].actualDays++ + } else { + // For forecast days, separate OTB from pure forecast + const otbVal = d.otb_value ?? 0 + const fcVal = d.blended_forecast ?? 0 + groups[groupKey].otb += otbVal + // Forecast is the remainder above OTB (can't be negative) + groups[groupKey].forecast += Math.max(0, fcVal - otbVal) + groups[groupKey].forecastDays++ + if (otbVal > 0) groups[groupKey].otbDays++ + } + groups[groupKey].budget += d.budget_value ?? 0 + groups[groupKey].priorYear += d.prior_year_value ?? 0 + groups[groupKey].days++ + }) + + // Convert to array sorted by key + return Object.entries(groups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, data]) => ({ + key, + ...data, + total: data.actual + data.otb + data.forecast, + variance: (data.actual + data.otb + data.forecast) - data.budget, + variancePct: data.budget > 0 ? (((data.actual + data.otb + data.forecast) / data.budget) - 1) * 100 : 0 + })) + }, [combinedData, consolidation]) + + // Build chart data - daily view + const dailyChartData = useMemo(() => { + if (!combinedData?.data) return [] + + // Split data into actuals and forecast + const actualDates: string[] = [] + const actualValues: (number | null)[] = [] + const forecastDates: string[] = [] + const forecastValues: (number | null)[] = [] + const priorYearValues: (number | null)[] = [] + const budgetValues: (number | null)[] = [] + const otbDates: string[] = [] + const otbValues: (number | null)[] = [] + const allDates: string[] = [] + + combinedData.data.forEach(d => { + allDates.push(d.date) + priorYearValues.push(d.prior_year_value) + budgetValues.push(d.budget_value) + + if (d.is_actual) { + actualDates.push(d.date) + actualValues.push(d.actual_value) + } else { + forecastDates.push(d.date) + forecastValues.push(d.blended_forecast) + // Collect OTB values for future dates + if (d.otb_value !== null && d.otb_value !== undefined) { + otbDates.push(d.date) + otbValues.push(d.otb_value) + } + } + }) + + // Calculate prior year dates for hover + const priorDates = allDates.map(d => { + const date = new Date(d) + date.setDate(date.getDate() - 364) + return formatDate(date) + }) + + const traces: any[] = [ + // Prior year fill (bottom layer) + { + x: allDates, + y: priorYearValues, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year', + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + customdata: priorDates, + hovertemplate: `Prior Year (%{customdata}): %{y:,.0f}${unit}`, + }, + ] + + // Budget line (for any metric with budget data) + if (budgetValues.some(v => v !== null)) { + traces.push({ + x: allDates, + y: budgetValues, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Budget', + line: { color: CHART_COLORS.budget, width: 2, dash: 'dash' as const }, + hovertemplate: `Budget: %{y:,.0f}${unit}`, + }) + } + + // Actuals line (solid green) + if (actualDates.length > 0) { + traces.push({ + x: actualDates, + y: actualValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Actual', + line: { color: CHART_COLORS.currentOtb, width: 3 }, + marker: { size: 8 }, + hovertemplate: `Actual: %{y:,.0f}${unit}`, + }) + } + + // Forecast line (different style) + if (forecastDates.length > 0) { + // Add connecting point from last actual + const connectDates = actualDates.length > 0 + ? [actualDates[actualDates.length - 1], ...forecastDates] + : forecastDates + const connectValues = actualDates.length > 0 + ? [actualValues[actualValues.length - 1], ...forecastValues] + : forecastValues + + traces.push({ + x: connectDates, + y: connectValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Forecast', + line: { color: CHART_COLORS.blended, width: 2, dash: 'dot' as const }, + marker: { size: 6, symbol: 'circle-open' }, + hovertemplate: `Forecast: %{y:,.0f}${unit}`, + }) + } + + // Future OTB line (accommodation revenue from bookings) - only for net_accom metric + if (otbDates.length > 0 && OTB_METRICS.includes(metric)) { + traces.push({ + x: otbDates, + y: otbValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Future OTB', + line: { color: CHART_COLORS.futureOtb, width: 2 }, + marker: { size: 6, symbol: 'diamond' }, + hovertemplate: `OTB (Booked): £%{y:,.0f}`, + }) + } + + return traces + }, [combinedData, unit, isRevenueMetric, metric]) + + // Build chart data - consolidated (weekly/monthly) view + const consolidatedChartData = useMemo(() => { + if (!consolidatedData) return [] + + const labels = consolidatedData.map(d => d.label) + const actuals = consolidatedData.map(d => d.actual) + const otbs = consolidatedData.map(d => d.otb) + const forecasts = consolidatedData.map(d => d.forecast) + const budgets = consolidatedData.map(d => d.budget) + const priorYears = consolidatedData.map(d => d.priorYear) + + const traces: any[] = [ + // Stacked bar: Actuals (bottom) - green + { + x: labels, + y: actuals, + type: 'bar' as const, + name: 'Actual', + marker: { color: CHART_COLORS.currentOtb }, + hovertemplate: `Actual: £%{y:,.0f}`, + }, + // Stacked bar: OTB (middle) - cyan - only if net_accom metric + { + x: labels, + y: otbs, + type: 'bar' as const, + name: 'OTB (Booked)', + marker: { color: CHART_COLORS.futureOtb }, + hovertemplate: `OTB (Booked): £%{y:,.0f}`, + visible: OTB_METRICS.includes(metric), + }, + // Stacked bar: Forecast (top) - orange + { + x: labels, + y: forecasts, + type: 'bar' as const, + name: 'Forecast', + marker: { color: CHART_COLORS.blended, opacity: 0.6 }, + hovertemplate: `Forecast: £%{y:,.0f}`, + }, + // Budget line + { + x: labels, + y: budgets, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: CHART_COLORS.budget, width: 3 }, + marker: { size: 10, symbol: 'diamond' }, + hovertemplate: `Budget: £%{y:,.0f}`, + }, + // Prior Year line + { + x: labels, + y: priorYears, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: CHART_COLORS.priorFinal, width: 2, dash: 'dot' as const }, + marker: { size: 6 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + return traces + }, [consolidatedData, metric]) + + // Use appropriate chart data based on consolidation + const chartData = consolidation === 'daily' ? dailyChartData : consolidatedChartData + + const formatValue = (val: number | null | undefined, isOccupancy: boolean = false) => { + if (val === null || val === undefined) return '-' + if (isOccupancy) return `${val.toFixed(1)}%` + return val.toLocaleString(undefined, { maximumFractionDigits: 0 }) + } + + const formatCurrency = (val: number) => { + if (isRevenueMetric) { + return `£${val.toLocaleString(undefined, { maximumFractionDigits: 0 })}` + } + return formatValue(val, metric === 'occupancy') + } + + return ( +
+
+
+

{title}

+

+ Actual results to date combined with blended forecast for remaining days +

+
+
+ + {/* Controls */} +
+ {/* From Month Selector */} +
+ + +
+ + {/* Duration Selector */} +
+ + +
+ + {/* Date Range Display (read-only) */} +
+ +
+ {startDate} to {endDate} +
+
+ + {/* Consolidation Selector */} +
+ + +
+
+ + {/* Summary Stats */} + {combinedData?.summary && ( +
+ {/* Actuals to Date */} +
+ ACTUAL TO DATE ({combinedData.summary.days_actual} days) + {(() => { + const isPct = combinedData.summary.is_pct_metric + const formatVal = (v: number) => isPct ? `${v.toFixed(1)}%` : formatCurrency(v) + const actualBudgetPct = combinedData.summary.actual_budget_total > 0 + ? ((combinedData.summary.actual_total / combinedData.summary.actual_budget_total) - 1) * 100 + : 0 + const actualPriorPct = combinedData.summary.actual_prior_total > 0 + ? ((combinedData.summary.actual_total / combinedData.summary.actual_prior_total) - 1) * 100 + : 0 + const hasBudget = combinedData.summary.actual_budget_total > 0 + return ( + <> + = 0 ? '#16a34a' : '#dc2626') : 'var(--text-dark)', + }}> + {formatVal(combinedData.summary.actual_total)} + {hasBudget && ( + + {actualBudgetPct >= 0 ? '+' : ''}{actualBudgetPct.toFixed(1)}% + + )} + + {hasBudget && ( + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Budget: {formatVal(combinedData.summary.actual_budget_total)} + {' '}({combinedData.summary.actual_variance >= 0 ? '+' : ''}{formatVal(combinedData.summary.actual_variance)}) + + )} + {!hasBudget && ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: {formatVal(combinedData.summary.actual_prior_total)} + {' '}({actualPriorPct >= 0 ? '+' : ''}{actualPriorPct.toFixed(1)}%) + + + ) + })()} +
+ + {/* Pace vs Budget - Actual + OTB (only for OTB metrics) */} + {OTB_METRICS.includes(metric) && ( +
+ PACE VS BUDGET + {(() => { + const isPct = combinedData.summary.is_pct_metric + const formatVal = (v: number) => isPct ? `${v.toFixed(1)}%` : formatCurrency(v) + // For percentage metrics, calculate weighted average of actual and OTB + // For count/revenue metrics, sum them + const paceTotal = isPct + ? ((combinedData.summary.actual_total * combinedData.summary.days_actual) + + (combinedData.summary.otb_total * combinedData.summary.days_with_otb)) / + (combinedData.summary.days_actual + combinedData.summary.days_with_otb) + : combinedData.summary.actual_total + combinedData.summary.otb_total + const hasBudget = combinedData.summary.total_budget > 0 + const paceOfBudgetPct = hasBudget + ? (paceTotal / combinedData.summary.total_budget) * 100 + : 0 + const pacePriorPct = combinedData.summary.total_prior_year > 0 + ? ((paceTotal / combinedData.summary.total_prior_year) - 1) * 100 + : 0 + return ( + <> + + {formatVal(paceTotal)} + {hasBudget && ( + + {paceOfBudgetPct.toFixed(1)}% + + )} + + {hasBudget ? ( + + of {formatVal(combinedData.summary.total_budget)} budget + + ) : ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: {formatVal(combinedData.summary.total_prior_year)} + {' '}({pacePriorPct >= 0 ? '+' : ''}{pacePriorPct.toFixed(1)}%) + + + ) + })()} +
+ )} + + {/* Forecast Remaining */} +
+ FORECAST REMAINING ({combinedData.summary.days_forecast} days) + {(() => { + const isPct = combinedData.summary.is_pct_metric + const formatVal = (v: number) => isPct ? `${v.toFixed(1)}%` : formatCurrency(v) + const hasBudget = combinedData.summary.forecast_budget_total > 0 + const forecastBudgetPct = hasBudget + ? ((combinedData.summary.forecast_total / combinedData.summary.forecast_budget_total) - 1) * 100 + : 0 + const forecastPriorPct = combinedData.summary.forecast_prior_total > 0 + ? ((combinedData.summary.forecast_total / combinedData.summary.forecast_prior_total) - 1) * 100 + : 0 + return ( + <> + + {formatVal(combinedData.summary.forecast_total)} + {hasBudget && ( + = 0 ? '#16a34a' : '#dc2626' }}> + {forecastBudgetPct >= 0 ? '+' : ''}{forecastBudgetPct.toFixed(1)}% + + )} + + {hasBudget ? ( + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Budget: {formatVal(combinedData.summary.forecast_budget_total)} + {' '}({combinedData.summary.forecast_variance >= 0 ? '+' : ''}{formatVal(combinedData.summary.forecast_variance)}) + + ) : ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: {formatVal(combinedData.summary.forecast_prior_total)} + {' '}({forecastPriorPct >= 0 ? '+' : ''}{forecastPriorPct.toFixed(1)}%) + + + ) + })()} +
+ + {/* Projected Total */} +
+ PROJECTED TOTAL + {(() => { + const isPct = combinedData.summary.is_pct_metric + const formatVal = (v: number) => isPct ? `${v.toFixed(1)}%` : formatCurrency(v) + const hasBudget = combinedData.summary.total_budget > 0 + return ( + <> + = 0 ? '#16a34a' : '#dc2626') : 'var(--text-dark)', + }}> + {formatVal(combinedData.summary.projected_total)} + {hasBudget && ( + + {combinedData.summary.budget_variance >= 0 ? '+' : ''}{combinedData.summary.budget_variance_pct.toFixed(1)}% + + )} + + {hasBudget ? ( + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Budget: {formatVal(combinedData.summary.total_budget)} + {' '}({combinedData.summary.budget_variance >= 0 ? '+' : ''}{formatVal(combinedData.summary.budget_variance)}) + + ) : ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: {formatVal(combinedData.summary.total_prior_year)} + {' '}({combinedData.summary.prior_year_variance >= 0 ? '+' : ''}{combinedData.summary.prior_year_variance_pct.toFixed(1)}%) + + + ) + })()} +
+
+ )} + + {/* Chart */} + {isLoading ? ( +
Loading forecast data...
+ ) : combinedData?.data && combinedData.data.length > 0 ? ( +
+ { + const todayStr = formatDate(new Date()) + const todayInRange = consolidation === 'daily' && todayStr >= startDate && todayStr <= endDate + return todayInRange ? [{ + type: 'line', + x0: todayStr, + x1: todayStr, + y0: 0, + y1: 1, + yref: 'paper', + line: { color: 'var(--text-mid)', width: 1, dash: 'dot' }, + }] : [] + })(), + annotations: (() => { + const todayStr = formatDate(new Date()) + const todayInRange = consolidation === 'daily' && todayStr >= startDate && todayStr <= endDate + return todayInRange ? [{ + x: todayStr, + y: 1, + yref: 'paper', + text: 'Today', + showarrow: false, + font: { size: 10, color: 'var(--text-mid)' }, + yanchor: 'bottom', + }] : [] + })(), + }} + style={{ width: '100%', height: '100%' }} + config={{ responsive: true, displayModeBar: false }} + /> +
+ ) : null} + + {/* Consolidated Breakdown Table */} + {consolidation !== 'daily' && consolidatedData && consolidatedData.length > 0 && ( +
+ + + + + + {OTB_METRICS.includes(metric) && } + + + + + + + + + + {consolidatedData.map((row) => ( + + + + {OTB_METRICS.includes(metric) && ( + + )} + + + + + + + + ))} + {/* Totals row */} + + + + {OTB_METRICS.includes(metric) && ( + + )} + + + + + + + + +
{consolidation === 'weekly' ? 'Week' : 'Month'}ActualOTBForecastTotalBudgetVariance%Prior Yr
{row.label} + {row.actual > 0 ? formatCurrency(row.actual) : '-'} + + {row.otb > 0 ? formatCurrency(row.otb) : '-'} + + {row.forecast > 0 ? formatCurrency(row.forecast) : '-'} + + {formatCurrency(row.total)} + + {formatCurrency(row.budget)} + = 0 ? '#16a34a' : '#dc2626' + }}> + {row.variance >= 0 ? '+' : ''}{formatCurrency(row.variance)} + = 0 ? '#16a34a' : '#dc2626' + }}> + {row.variancePct >= 0 ? '+' : ''}{row.variancePct.toFixed(1)}% + + {formatCurrency(row.priorYear)} +
TOTAL + {formatCurrency(consolidatedData.reduce((sum, r) => sum + r.actual, 0))} + + {formatCurrency(consolidatedData.reduce((sum, r) => sum + r.otb, 0))} + + {formatCurrency(consolidatedData.reduce((sum, r) => sum + r.forecast, 0))} + + {formatCurrency(consolidatedData.reduce((sum, r) => sum + r.total, 0))} + + {formatCurrency(consolidatedData.reduce((sum, r) => sum + r.budget, 0))} + sum + r.variance, 0) >= 0 ? '#16a34a' : '#dc2626' + }}> + {consolidatedData.reduce((sum, r) => sum + r.variance, 0) >= 0 ? '+' : ''} + {formatCurrency(consolidatedData.reduce((sum, r) => sum + r.variance, 0))} + = 0 ? '#16a34a' : '#dc2626' + }}> + {combinedData?.summary?.budget_variance_pct && combinedData.summary.budget_variance_pct >= 0 ? '+' : ''} + {combinedData?.summary?.budget_variance_pct?.toFixed(1)}% + + {formatCurrency(consolidatedData.reduce((sum, r) => sum + r.priorYear, 0))} +
+
+ )} + + {/* Data Table Toggle */} + {combinedData?.data && combinedData.data.length > 0 && ( + <> + + + {showTable && ( +
+ + + + + + + {OTB_METRICS.includes(metric) && } + + + + + + + {combinedData.data.map((row) => ( + + + + + {OTB_METRICS.includes(metric) && ( + + )} + + + + + ))} + +
DateDOWActual/FCOTBBudgetPrior YearType
{row.date}{row.day_of_week} + {formatValue(row.display_value, metric === 'occupancy')} + + {row.otb_value !== null && row.otb_value !== undefined + ? formatValue(row.otb_value, false) + : '-'} + + {formatValue(row.budget_value, metric === 'occupancy')} + + {formatValue(row.prior_year_value, metric === 'occupancy')} + + {row.is_actual ? 'Actual' : 'Forecast'} +
+
+ )} + + )} +
+ ) +} + +const ForecastPreview: React.FC = () => { + const paceCurveRef = useRef(null) + + // Default to next 30 days + const today = new Date() + const defaultStart = new Date(today) + defaultStart.setDate(today.getDate() + 1) + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() + 30) + + const [startDate, setStartDate] = useState(defaultStart.toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(defaultEnd.toISOString().split('T')[0]) + const [metric, setMetric] = useState('rooms') + const [selectedDate, setSelectedDate] = useState(null) + const [showTable, setShowTable] = useState(true) + + // Generate month options + const monthOptions = useMemo(() => getNext12Months(), []) + + // Quick select handlers + const handleQuickSelect = (days: number) => { + const start = new Date() + start.setDate(start.getDate() + 1) + const end = new Date() + end.setDate(end.getDate() + days) + setStartDate(start.toISOString().split('T')[0]) + setEndDate(end.toISOString().split('T')[0]) + } + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Fetch preview data + const { data: previewData, isLoading: previewLoading } = useQuery({ + queryKey: ['forecast-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/preview', { + params: { + start_date: startDate, + end_date: endDate, + metric: metric, + } + }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch pace curve when a date is selected + const { data: paceCurveData, isLoading: paceCurveLoading } = useQuery({ + queryKey: ['pace-curve', selectedDate], + queryFn: async () => { + const response = await api.get('/forecast/pace-curve', { params: { arrival_date: selectedDate! } }) + return response.data + }, + enabled: !!selectedDate, + }) + + // Fetch budget data for revenue metrics + const budgetData = useBudgetData(startDate, endDate, metric) + + // Build forecast chart data + const forecastChartData = useMemo(() => { + if (!previewData?.data) return [] + + const dates = previewData.data.map((d) => d.date) + const currentOtb = previewData.data.map((d) => d.current_otb) + const priorYearOtb = previewData.data.map((d) => d.prior_year_otb) + const priorYearFinal = previewData.data.map((d) => d.prior_year_final) + const forecast = previewData.data.map((d) => d.forecast) + + // Calculate prior year dates (364 days for DOW alignment) + const priorDates = previewData.data.map((d) => { + const date = new Date(d.date) + date.setDate(date.getDate() - 364) + return date.toISOString().split('T')[0] + }) + + const unit = { + occupancy: '%', + rooms: ' rooms', + guests: ' guests', + ave_guest_rate: '', + arr: '', + net_accom: '', + net_dry: '', + net_wet: '', + total_rev: '', + }[metric] || '' + + return [ + { + x: dates, + y: priorYearFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + customdata: priorDates, + hovertemplate: `Prior Final: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + { + x: dates, + y: priorYearOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: CHART_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: `Prior OTB: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + { + x: dates, + y: currentOtb, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current OTB', + line: { color: CHART_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: `Current OTB: %{y:.1f}${unit}`, + }, + { + x: dates, + y: forecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Pickup Forecast', + line: { color: CHART_COLORS.pickup, width: 3 }, + marker: { size: 8 }, + hovertemplate: `Pickup: %{y:.1f}${unit}`, + }, + ].concat(buildBudgetTrace(budgetData) ? [buildBudgetTrace(budgetData)!] : []) + }, [previewData, metric, budgetData]) + + // Build pace curve chart data with calculated axis ranges + const { paceCurveChartData, paceCurveXRange, paceCurveYMax } = useMemo(() => { + if (!paceCurveData) return { paceCurveChartData: [], paceCurveXRange: null, paceCurveYMax: null } + + // Filter to only show data points with values + const currentFiltered = paceCurveData.current_year.filter((p) => p.rooms !== null && p.rooms > 0) + const priorFiltered = paceCurveData.prior_year.filter((p) => p.rooms !== null && p.rooms > 0) + + // Calculate x-axis range: from earliest data point to 0 (arrival) + // Find max days_out with actual booking data + const currentMaxDaysOut = currentFiltered.length > 0 + ? Math.max(...currentFiltered.map((p) => p.days_out)) + : 0 + const priorMaxDaysOut = priorFiltered.length > 0 + ? Math.max(...priorFiltered.map((p) => p.days_out)) + : 0 + const maxDaysOut = Math.max(currentMaxDaysOut, priorMaxDaysOut, 30) // At least 30 days + + // Calculate y-axis max for proper scaling + const currentMaxRooms = currentFiltered.length > 0 + ? Math.max(...currentFiltered.map((p) => p.rooms as number)) + : 0 + const priorMaxRooms = priorFiltered.length > 0 + ? Math.max(...priorFiltered.map((p) => p.rooms as number)) + : 0 + const maxRooms = Math.max(currentMaxRooms, priorMaxRooms) + + // Include all points (even zeros) for smoother lines, but filter nulls + const currentAllPoints = paceCurveData.current_year.filter((p) => p.rooms !== null && p.days_out <= maxDaysOut) + const priorAllPoints = paceCurveData.prior_year.filter((p) => p.rooms !== null && p.days_out <= maxDaysOut) + + const chartData = [ + { + x: priorAllPoints.map((p) => p.days_out), + y: priorAllPoints.map((p) => p.rooms), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: `Prior Year (${paceCurveData.day_of_week})`, + line: { color: '#9ca3af', width: 2 }, + marker: { size: 4 }, + }, + { + x: currentAllPoints.map((p) => p.days_out), + y: currentAllPoints.map((p) => p.rooms), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: `Current Year (${paceCurveData.day_of_week})`, + line: { color: 'var(--gold)', width: 2 }, + marker: { size: 6 }, + }, + ] + + return { + paceCurveChartData: chartData, + paceCurveXRange: [maxDaysOut + 5, -2] as [number, number], // Add padding, reversed for days-out + paceCurveYMax: maxRooms * 1.1 // 10% padding above max + } + }, [paceCurveData]) + + const metricLabel = { + occupancy: 'Occupancy %', + rooms: 'Room Nights', + guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', + arr: 'ARR', + net_accom: 'Net Accomm Rev', + net_dry: 'Net Dry Rev', + net_wet: 'Net Wet Rev', + total_rev: 'Total Net Rev', + }[metric] || 'Value' + + return ( +
+
+
+

Live Pickup

+

+ Forecast = Current OTB + (Prior Year Final - Prior Year OTB) +

+
+
+ + {/* Controls */} +
+ {/* Date Range */} +
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ + {/* Metric Dropdown - Pickup only supports pace-based metrics */} +
+ + +
+ + {/* Quick Selects */} +
+ +
+ {[7, 14, 30, 60, 90].map((days) => ( + + ))} +
+ +
+
+ + {/* Summary Stats */} + {previewData?.summary && (() => { + // Backend already returns averages for occupancy, totals for rooms + const otbAvg = previewData.summary.otb_total + const priorOtbAvg = previewData.summary.prior_otb_total + const forecastAvg = previewData.summary.forecast_total + const priorFinalAvg = previewData.summary.prior_final_total + const otbDiff = otbAvg - priorOtbAvg + const forecastDiff = forecastAvg - priorFinalAvg + + return ( +
+
+ CURRENT OTB + + {metric === 'occupancy' ? `${otbAvg.toFixed(1)}%` : otbAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior OTB: {metric === 'occupancy' ? `${priorOtbAvg.toFixed(1)}%` : priorOtbAvg.toFixed(0)} + {' '}({otbDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${otbDiff.toFixed(1)}%` : otbDiff.toFixed(0)}) + +
+
+ PICKUP FORECAST + + {metric === 'occupancy' ? `${forecastAvg.toFixed(1)}%` : forecastAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior Final: {metric === 'occupancy' ? `${priorFinalAvg.toFixed(1)}%` : priorFinalAvg.toFixed(0)} + {' '}({forecastDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${forecastDiff.toFixed(1)}%` : forecastDiff.toFixed(0)}) + +
+
+ PACE VS PRIOR + = 0 + ? '#16a34a' + : '#dc2626' + : 'var(--text-mid)', + }} + > + {previewData.summary.pace_pct !== null + ? `${previewData.summary.pace_pct >= 0 ? '+' : ''}${previewData.summary.pace_pct.toFixed(1)}%` + : 'N/A'} + + OTB vs same DOW last year +
+
+ ) + })()} + + {/* Forecast Chart */} + {previewLoading ? ( +
Loading forecast data...
+ ) : previewData?.data && previewData.data.length > 0 ? ( +
+ +
+ ) : ( +
+ No snapshot data available for this date range. Run the pickup snapshot job first. +
+ )} + + {/* Data Table */} + {previewData?.data && previewData.data.length > 0 && ( + <> + + + {showTable && ( +
+ + + + + + + + + + + + + + + + + + {previewData.data.map((row, idx) => ( + + + + + + + + + + + + + + ))} + +
DateDOWLeadCurrent OTBPrior Yr DatePrior Yr OTBPrior Yr FinalPickup + Forecast + Pace
{row.date}{row.day_of_week}{row.lead_days}d + {row.current_otb !== null ? row.current_otb.toFixed(1) : '-'} + + {row.prior_year_date} ({row.prior_year_dow}) + {row.day_of_week !== row.prior_year_dow && ' ⚠️'} + + {row.prior_year_otb !== null ? row.prior_year_otb.toFixed(1) : '-'} + + {row.prior_year_final !== null ? row.prior_year_final.toFixed(1) : '-'} + = 0 + ? '#16a34a' + : '#dc2626' + : 'var(--text-mid)', + }} + > + {row.expected_pickup !== null + ? `${row.expected_pickup >= 0 ? '+' : ''}${row.expected_pickup.toFixed(1)}` + : '-'} + + {row.forecast !== null ? row.forecast.toFixed(1) : '-'} + = 0 + ? '#16a34a' + : '#dc2626' + : 'var(--text-mid)', + }} + > + {row.pace_vs_prior_pct !== null + ? `${row.pace_vs_prior_pct >= 0 ? '+' : ''}${row.pace_vs_prior_pct.toFixed(0)}%` + : '-'} + + +
+
+ )} + + )} + + {/* Pace Curve Chart */} + {selectedDate && ( +
+

+ Booking Pace Curve: {selectedDate} ({paceCurveData?.day_of_week}) +

+

+ Shows how bookings built up over time from 365 days out to arrival +

+ + {paceCurveLoading ? ( +
Loading pace curve...
+ ) : paceCurveData ? ( +
+ +
+ ) : ( +
No pace data available for this date.
+ )} +
+ )} +
+ ) +} + +const ProphetPreview: React.FC = () => { + + // Default to next 30 days, rooms metric (matching pickup page) + const today = new Date() + const defaultStart = new Date(today) + defaultStart.setDate(today.getDate() + 1) + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() + 30) + + const [startDate, setStartDate] = useState(defaultStart.toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(defaultEnd.toISOString().split('T')[0]) + const [metric, setMetric] = useState('rooms') + const [showTable, setShowTable] = useState(false) + + // Generate month options + const monthOptions = useMemo(() => getNext12Months(), []) + + // Quick select handlers + const handleQuickSelect = (days: number) => { + const start = new Date() + start.setDate(start.getDate() + 1) + const end = new Date() + end.setDate(end.getDate() + days) + setStartDate(start.toISOString().split('T')[0]) + setEndDate(end.toISOString().split('T')[0]) + } + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Fetch Prophet data + const { data: prophetData, isLoading: prophetLoading } = useQuery({ + queryKey: ['prophet-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/prophet-preview', { + params: { + start_date: startDate, + end_date: endDate, + metric: metric, + } + }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch budget data for revenue metrics + const budgetData = useBudgetData(startDate, endDate, metric) + + // Build Prophet chart data + const prophetChartData = useMemo(() => { + if (!prophetData?.data) return [] + + const dates = prophetData.data.map((d) => d.date) + const currentOtb = prophetData.data.map((d) => d.current_otb) + const priorYearOtb = prophetData.data.map((d) => d.prior_year_otb) + const forecast = prophetData.data.map((d) => d.forecast) + const forecastLower = prophetData.data.map((d) => d.forecast_lower) + const forecastUpper = prophetData.data.map((d) => d.forecast_upper) + const priorYearFinal = prophetData.data.map((d) => d.prior_year_final) + + // Calculate prior year dates + const priorDates = prophetData.data.map((d) => { + const date = new Date(d.date) + date.setDate(date.getDate() - 364) + return date.toISOString().split('T')[0] + }) + + const unit = { + occupancy: '%', + rooms: ' rooms', + guests: ' guests', + ave_guest_rate: '', + arr: '', + net_accom: '', + net_dry: '', + net_wet: '', + total_rev: '', + }[metric] || '' + + return [ + // Confidence interval fill - light blue + { + x: [...dates, ...dates.slice().reverse()], + y: [...forecastUpper, ...forecastLower.slice().reverse()], + fill: 'toself' as const, + fillcolor: CHART_COLORS.prophetConfidence, + line: { color: 'transparent' }, + type: 'scatter' as const, + mode: 'lines' as const, + name: '80% Confidence', + showlegend: true, + hoverinfo: 'skip' as const, + }, + { + x: dates, + y: priorYearFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + customdata: priorDates, + hovertemplate: `Prior Final: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + { + x: dates, + y: priorYearOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: CHART_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: `Prior OTB: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + { + x: dates, + y: currentOtb, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current OTB', + line: { color: CHART_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: `Current OTB: %{y:.1f}${unit}`, + }, + { + x: dates, + y: forecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prophet Forecast', + line: { color: CHART_COLORS.prophet, width: 3 }, + marker: { size: 8 }, + hovertemplate: `Prophet: %{y:.1f}${unit}`, + }, + ].concat(buildBudgetTrace(budgetData) ? [buildBudgetTrace(budgetData)!] : []) + }, [prophetData, metric, budgetData]) + + const metricLabel = { + occupancy: 'Occupancy %', + rooms: 'Room Nights', + guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', + arr: 'ARR', + net_accom: 'Net Accomm Rev', + net_dry: 'Net Dry Rev', + net_wet: 'Net Wet Rev', + total_rev: 'Total Net Rev', + }[metric] || 'Value' + + return ( +
+
+
+

Live Prophet

+

+ Time series forecast using Facebook Prophet with weekly/yearly seasonality +

+
+
+ + {/* Controls */} +
+ {/* Date Range */} +
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ + {/* Metric Dropdown */} +
+ + +
+ + {/* Quick Selects */} +
+ +
+ {[7, 14, 30, 60, 90].map((days) => ( + + ))} +
+ +
+
+ + {/* Summary Stats */} + {prophetData?.summary && (() => { + // Backend already returns averages for occupancy, totals for rooms + const otbAvg = prophetData.summary.otb_total + const priorOtbAvg = prophetData.summary.prior_otb_total + const forecastAvg = prophetData.summary.forecast_total + const priorFinalAvg = prophetData.summary.prior_final_total + const otbDiff = otbAvg - priorOtbAvg + const forecastDiff = forecastAvg - priorFinalAvg + + return ( +
+
+ CURRENT OTB + + {metric === 'occupancy' ? `${otbAvg.toFixed(1)}%` : otbAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior OTB: {metric === 'occupancy' ? `${priorOtbAvg.toFixed(1)}%` : priorOtbAvg.toFixed(0)} + {' '}({otbDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${otbDiff.toFixed(1)}%` : otbDiff.toFixed(0)}) + +
+
+ PROPHET FORECAST + + {metric === 'occupancy' ? `${forecastAvg.toFixed(1)}%` : forecastAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior Final: {metric === 'occupancy' ? `${priorFinalAvg.toFixed(1)}%` : priorFinalAvg.toFixed(0)} + {' '}({forecastDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${forecastDiff.toFixed(1)}%` : forecastDiff.toFixed(0)}) + +
+
+ VS PRIOR YEAR +
+
+ + {prophetData.summary.days_forecasting_more} + +
days up
+
+
+ + {prophetData.summary.days_forecasting_less} + +
days down
+
+
+
+
+ ) + })()} + + {/* Prophet Chart */} + {prophetLoading ? ( +
Training Prophet model...
+ ) : prophetData?.data && prophetData.data.length > 0 ? ( +
+ +
+ ) : ( +
+ No forecast data available. Ensure sufficient historical data exists. +
+ )} + + {/* Data Table */} + {prophetData?.data && prophetData.data.length > 0 && ( + <> + + + {showTable && ( +
+ + + + + + + + + + + + + + + {prophetData.data.map((row, idx) => ( + + + + + + + + + + + ))} + +
DateDOWCurrent OTBPrior Yr OTBProphetLowerUpperPrior Yr Final
{row.date}{row.day_of_week} + {row.current_otb !== null ? row.current_otb.toFixed(1) : '-'} + + {row.prior_year_otb !== null ? row.prior_year_otb.toFixed(1) : '-'} + + {row.forecast !== null ? row.forecast.toFixed(1) : '-'} + + {row.forecast_lower !== null ? row.forecast_lower.toFixed(1) : '-'} + + {row.forecast_upper !== null ? row.forecast_upper.toFixed(1) : '-'} + + {row.prior_year_final !== null ? row.prior_year_final.toFixed(1) : '-'} +
+
+ )} + + )} +
+ ) +} + +// ============================================ +// XGBOOST PREVIEW COMPONENT +// ============================================ + +const XGBoostPreview: React.FC = () => { + + // Default to next 30 days, rooms metric (matching pickup page) + const today = new Date() + const defaultStart = new Date(today) + defaultStart.setDate(today.getDate() + 1) + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() + 30) + + const [startDate, setStartDate] = useState(defaultStart.toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(defaultEnd.toISOString().split('T')[0]) + const [metric, setMetric] = useState('rooms') + const [showTable, setShowTable] = useState(false) + + // Generate month options + const monthOptions = useMemo(() => getNext12Months(), []) + + // Quick select handlers + const handleQuickSelect = (days: number) => { + const start = new Date() + start.setDate(start.getDate() + 1) + const end = new Date() + end.setDate(end.getDate() + days) + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Fetch XGBoost forecast data + const { data: xgboostData, isLoading: xgboostLoading } = useQuery({ + queryKey: ['xgboost-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/xgboost-preview', { + params: { + start_date: startDate, + end_date: endDate, + metric: metric, + } + }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch budget data for revenue metrics + const budgetData = useBudgetData(startDate, endDate, metric) + + const metricLabel = { + occupancy: 'Occupancy %', + rooms: 'Room Nights', + guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', + arr: 'ARR', + net_accom: 'Net Accomm Rev', + net_dry: 'Net Dry Rev', + net_wet: 'Net Wet Rev', + total_rev: 'Total Net Rev', + }[metric] || 'Value' + const unit = { + occupancy: '%', + rooms: ' rooms', + guests: ' guests', + ave_guest_rate: '', + arr: '', + net_accom: '', + net_dry: '', + net_wet: '', + total_rev: '', + }[metric] || '' + + // Build XGBoost chart data + const xgboostChartData = useMemo(() => { + if (!xgboostData?.data) return [] + + const dates = xgboostData.data.map((d) => d.date) + const currentOtb = xgboostData.data.map((d) => d.current_otb) + const priorYearOtb = xgboostData.data.map((d) => d.prior_year_otb) + const forecast = xgboostData.data.map((d) => d.forecast) + const priorYearFinal = xgboostData.data.map((d) => d.prior_year_final) + + // Calculate prior year dates + const priorDates = xgboostData.data.map((d) => { + const date = new Date(d.date) + date.setDate(date.getDate() - 364) + return formatDate(date) + }) + + return [ + // Prior year final fill first (bottom layer) + { + x: dates, + y: priorYearFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + customdata: priorDates, + hovertemplate: `Prior Final: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + // Prior year OTB + { + x: dates, + y: priorYearOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: CHART_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: `Prior OTB: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + // Current OTB - green + { + x: dates, + y: currentOtb, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current OTB', + line: { color: CHART_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: `Current OTB: %{y:.1f}${unit}`, + }, + // XGBoost forecast - orange + { + x: dates, + y: forecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'XGBoost Forecast', + line: { color: CHART_COLORS.xgboost, width: 3 }, + marker: { size: 8, symbol: 'diamond' }, + hovertemplate: `XGBoost: %{y:.1f}${unit}`, + }, + ].concat(buildBudgetTrace(budgetData) ? [buildBudgetTrace(budgetData)!] : []) + }, [xgboostData, unit, budgetData]) + + return ( +
+
+
+

Live XGBoost

+

+ Gradient boosting model trained on historical patterns with lag features +

+
+
+ + {/* Controls */} +
+ {/* Date Range */} +
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ + {/* Metric Dropdown */} +
+ + +
+ + {/* Quick Selects */} +
+ +
+ {[7, 14, 30, 60, 90].map((days) => ( + + ))} +
+ +
+
+ + {/* Summary Stats */} + {xgboostData?.summary && (() => { + // Backend already returns averages for occupancy, totals for rooms + const otbAvg = xgboostData.summary.otb_total + const priorOtbAvg = xgboostData.summary.prior_otb_total + const forecastAvg = xgboostData.summary.forecast_total + const priorFinalAvg = xgboostData.summary.prior_final_total + const otbDiff = otbAvg - priorOtbAvg + const forecastDiff = forecastAvg - priorFinalAvg + + return ( +
+
+ CURRENT OTB + + {metric === 'occupancy' ? `${otbAvg.toFixed(1)}%` : otbAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior OTB: {metric === 'occupancy' ? `${priorOtbAvg.toFixed(1)}%` : priorOtbAvg.toFixed(0)} + {' '}({otbDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${otbDiff.toFixed(1)}%` : otbDiff.toFixed(0)}) + +
+
+ XGBOOST FORECAST + + {metric === 'occupancy' ? `${forecastAvg.toFixed(1)}%` : forecastAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior Final: {metric === 'occupancy' ? `${priorFinalAvg.toFixed(1)}%` : priorFinalAvg.toFixed(0)} + {' '}({forecastDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${forecastDiff.toFixed(1)}%` : forecastDiff.toFixed(0)}) + +
+
+ VS PRIOR YEAR +
+
+ + {xgboostData.summary.days_forecasting_more} + +
days up
+
+
+ + {xgboostData.summary.days_forecasting_less} + +
days down
+
+
+
+
+ ) + })()} + + {/* XGBoost Chart */} + {xgboostLoading ? ( +
Training XGBoost model...
+ ) : xgboostData?.data && xgboostData.data.length > 0 ? ( +
+ +
+ ) : ( +
+ No forecast data available. Ensure sufficient historical data exists. +
+ )} + + {/* Data Table */} + {xgboostData?.data && xgboostData.data.length > 0 && ( + <> + + + {showTable && ( +
+ + + + + + + + + + + + + {xgboostData.data.map((row, idx) => ( + + + + + + + + + ))} + +
DateDOWCurrent OTBPrior Yr OTBXGBoostPrior Yr Final
{row.date}{row.day_of_week} + {row.current_otb !== null ? row.current_otb.toFixed(1) : '-'} + + {row.prior_year_otb !== null ? row.prior_year_otb.toFixed(1) : '-'} + + {row.forecast !== null ? row.forecast.toFixed(1) : '-'} + + {row.prior_year_final !== null ? row.prior_year_final.toFixed(1) : '-'} +
+
+ )} + + )} +
+ ) +} + +// ============================================ +// CATBOOST PREVIEW COMPONENT +// ============================================ + +const CatBoostPreview: React.FC = () => { + + // Default to next 30 days, rooms metric (matching pickup page) + const today = new Date() + const defaultStart = new Date(today) + defaultStart.setDate(today.getDate() + 1) + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() + 30) + + const [startDate, setStartDate] = useState(defaultStart.toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(defaultEnd.toISOString().split('T')[0]) + const [metric, setMetric] = useState('rooms') + const [showTable, setShowTable] = useState(false) + + // Generate month options + const monthOptions = useMemo(() => getNext12Months(), []) + + // Quick select handlers + const handleQuickSelect = (days: number) => { + const start = new Date() + start.setDate(start.getDate() + 1) + const end = new Date() + end.setDate(end.getDate() + days) + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Fetch CatBoost forecast data + const { data: catboostData, isLoading: catboostLoading } = useQuery({ + queryKey: ['catboost-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/catboost-preview', { + params: { + start_date: startDate, + end_date: endDate, + metric: metric, + } + }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch budget data for revenue metrics + const budgetData = useBudgetData(startDate, endDate, metric) + + const metricLabel = { + occupancy: 'Occupancy %', + rooms: 'Room Nights', + guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', + arr: 'ARR', + net_accom: 'Net Accomm Rev', + net_dry: 'Net Dry Rev', + net_wet: 'Net Wet Rev', + total_rev: 'Total Net Rev', + }[metric] || 'Value' + const unit = { + occupancy: '%', + rooms: ' rooms', + guests: ' guests', + ave_guest_rate: '', + arr: '', + net_accom: '', + net_dry: '', + net_wet: '', + total_rev: '', + }[metric] || '' + + // Build CatBoost chart data + const catboostChartData = useMemo(() => { + if (!catboostData?.data) return [] + + const dates = catboostData.data.map((d) => d.date) + const currentOtb = catboostData.data.map((d) => d.current_otb) + const priorYearOtb = catboostData.data.map((d) => d.prior_year_otb) + const forecast = catboostData.data.map((d) => d.forecast) + const priorYearFinal = catboostData.data.map((d) => d.prior_year_final) + + // Calculate prior year dates + const priorDates = catboostData.data.map((d) => { + const date = new Date(d.date) + date.setDate(date.getDate() - 364) + return formatDate(date) + }) + + return [ + // Prior year final fill first (bottom layer) + { + x: dates, + y: priorYearFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + customdata: priorDates, + hovertemplate: `Prior Final: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + // Prior year OTB + { + x: dates, + y: priorYearOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: CHART_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: `Prior OTB: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + // Current OTB - green + { + x: dates, + y: currentOtb, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current OTB', + line: { color: CHART_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: `Current OTB: %{y:.1f}${unit}`, + }, + // CatBoost forecast - violet + { + x: dates, + y: forecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'CatBoost Forecast', + line: { color: CHART_COLORS.catboost, width: 3 }, + marker: { size: 8, symbol: 'diamond' }, + hovertemplate: `CatBoost: %{y:.1f}${unit}`, + }, + ].concat(buildBudgetTrace(budgetData) ? [buildBudgetTrace(budgetData)!] : []) + }, [catboostData, unit, budgetData]) + + return ( +
+
+
+

Live CatBoost

+

+ Gradient boosting with native categorical feature support - no encoding needed +

+
+
+ + {/* Controls */} +
+ {/* Date Range */} +
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ + {/* Metric Dropdown */} +
+ + +
+ + {/* Quick Selects */} +
+ +
+ {[7, 14, 30, 60, 90].map((days) => ( + + ))} +
+ +
+
+ + {/* Summary Stats */} + {catboostData?.summary && (() => { + const otbAvg = catboostData.summary.otb_total + const priorOtbAvg = catboostData.summary.prior_otb_total + const forecastAvg = catboostData.summary.forecast_total + const priorFinalAvg = catboostData.summary.prior_final_total + const otbDiff = otbAvg - priorOtbAvg + const forecastDiff = forecastAvg - priorFinalAvg + + return ( +
+
+ CURRENT OTB + + {metric === 'occupancy' ? `${otbAvg.toFixed(1)}%` : otbAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior OTB: {metric === 'occupancy' ? `${priorOtbAvg.toFixed(1)}%` : priorOtbAvg.toFixed(0)} + {' '}({otbDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${otbDiff.toFixed(1)}%` : otbDiff.toFixed(0)}) + +
+
+ CATBOOST FORECAST + + {metric === 'occupancy' ? `${forecastAvg.toFixed(1)}%` : forecastAvg.toFixed(0)} + + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Prior Final: {metric === 'occupancy' ? `${priorFinalAvg.toFixed(1)}%` : priorFinalAvg.toFixed(0)} + {' '}({forecastDiff >= 0 ? '+' : ''}{metric === 'occupancy' ? `${forecastDiff.toFixed(1)}%` : forecastDiff.toFixed(0)}) + +
+
+ VS PRIOR YEAR +
+
+ + {catboostData.summary.days_forecasting_more} + +
days up
+
+
+ + {catboostData.summary.days_forecasting_less} + +
days down
+
+
+
+
+ ) + })()} + + {/* CatBoost Chart */} + {catboostLoading ? ( +
Training CatBoost model...
+ ) : catboostData?.data && catboostData.data.length > 0 ? ( +
+ +
+ ) : ( +
+ No forecast data available. Ensure sufficient historical data exists. +
+ )} + + {/* Data Table */} + {catboostData?.data && catboostData.data.length > 0 && ( + <> + + + {showTable && ( +
+ + + + + + + + + + + + + {catboostData.data.map((row, idx) => ( + + + + + + + + + ))} + +
DateDOWCurrent OTBPrior Yr OTBCatBoostPrior Yr Final
{row.date}{row.day_of_week} + {row.current_otb !== null ? row.current_otb.toFixed(1) : '-'} + + {row.prior_year_otb !== null ? row.prior_year_otb.toFixed(1) : '-'} + + {row.forecast !== null ? row.forecast.toFixed(1) : '-'} + + {row.prior_year_final !== null ? row.prior_year_final.toFixed(1) : '-'} +
+
+ )} + + )} +
+ ) +} + +// ============================================ +// BLENDED FORECAST COMPONENT +// ============================================ + +const BlendedPreview: React.FC = () => { + + // Default to next 30 days + const today = new Date() + const defaultStart = new Date(today) + defaultStart.setDate(today.getDate() + 1) + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() + 30) + + const [startDate, setStartDate] = useState(defaultStart.toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(defaultEnd.toISOString().split('T')[0]) + const [metric, setMetric] = useState('rooms') + const [showTable, setShowTable] = useState(false) + + // Generate month options + const monthOptions = useMemo(() => getNext12Months(), []) + + // Quick select handlers + const handleQuickSelect = (days: number) => { + const start = new Date() + start.setDate(start.getDate() + 1) + const end = new Date() + end.setDate(end.getDate() + days) + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Fetch blended forecast from backend (uses MAPE-weighted + 60/40 blend) + const { data: blendedData, isLoading } = useQuery({ + queryKey: ['blended-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/blended-preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + const isRevenueMetric = REVENUE_METRICS.includes(metric) + + const metricLabel = { + occupancy: 'Occupancy %', + rooms: 'Room Nights', + guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', + arr: 'ARR', + net_accom: 'Net Accomm Rev', + net_dry: 'Net Dry Rev', + net_wet: 'Net Wet Rev', + total_rev: 'Total Net Rev', + }[metric] || 'Value' + + const unit = { + occupancy: '%', + rooms: ' rooms', + guests: ' guests', + ave_guest_rate: '', + arr: '', + net_accom: '', + net_dry: '', + net_wet: '', + total_rev: '', + }[metric] || '' + + // Use backend blended forecast data (already MAPE-weighted + 60/40 blend) + const blendedResults = useMemo(() => { + if (!blendedData) return null + + return { + data: blendedData.data, + summary: blendedData.summary + } + }, [blendedData]) + + // Build blended chart data + const blendedChartData = useMemo(() => { + if (!blendedResults?.data) return [] + + const dates = blendedResults.data.map((d) => d.date) + const currentOtb = blendedResults.data.map((d) => d.current_otb) + const priorYearOtb = blendedResults.data.map((d) => d.prior_year_otb) + const blendedForecast = blendedResults.data.map((d) => d.blended_forecast) + const priorYearFinal = blendedResults.data.map((d) => d.prior_year_final) + const budgetOrPrior = blendedResults.data.map((d) => d.budget_or_prior) + + // Calculate prior year dates for hover + const priorDates = blendedResults.data.map((d) => { + const date = new Date(d.date) + date.setDate(date.getDate() - 364) + return formatDate(date) + }) + + return [ + // Prior year final fill (bottom layer) + { + x: dates, + y: priorYearFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + customdata: priorDates, + hovertemplate: `Prior Final (%{customdata}): %{y:.1f}${unit}`, + }, + // Prior year OTB + { + x: dates, + y: priorYearOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: CHART_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: `Prior OTB (%{customdata}): %{y:.1f}${unit}`, + }, + // Current OTB + { + x: dates, + y: currentOtb, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current OTB', + line: { color: CHART_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: `Current OTB: %{y:.1f}${unit}`, + }, + // Blended forecast + { + x: dates, + y: blendedForecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Blended Forecast', + line: { color: CHART_COLORS.blended, width: 3 }, + marker: { size: 8 }, + hovertemplate: `Blended: %{y:.1f}${unit}`, + }, + // Budget or Prior Year (what contributes to blended) + { + x: dates, + y: budgetOrPrior, + type: 'scatter' as const, + mode: 'lines' as const, + name: isRevenueMetric ? 'Budget Target (40%)' : 'Prior Year (40%)', + line: { color: CHART_COLORS.budget, width: 2, dash: 'dash' as const }, + hovertemplate: `${isRevenueMetric ? 'Budget' : 'Prior Year'}: %{y:.1f}${unit}`, + }, + ] + }, [blendedResults, unit, isRevenueMetric]) + + return ( +
+
+
+

Live Blended

+

+ Equal-weighted ensemble combining Prophet, XGBoost, and CatBoost (60%) with{' '} + {isRevenueMetric ? 'budget targets' : 'prior year patterns'} (40%) +

+
+
+ + {/* Controls */} +
+ {/* Date Range */} +
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ + {/* Metric Dropdown */} +
+ + +
+ + {/* Quick Selects */} +
+ +
+ {[7, 14, 30, 60, 90].map((days) => ( + + ))} +
+ +
+
+ + {/* Summary Stats */} + {blendedResults?.summary && (() => { + const otbVal = blendedResults.summary.otb_total + const priorOtbVal = blendedResults.summary.prior_otb_total + const forecastVal = blendedResults.summary.forecast_total + const priorFinalVal = blendedResults.summary.prior_final_total + const otbDiff = otbVal - priorOtbVal + const forecastDiff = forecastVal - priorFinalVal + + return ( +
+
+ CURRENT OTB + + {metric === 'occupancy' ? `${otbVal.toFixed(1)}%` : otbVal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + = 0 ? '#16a34a' : '#dc2626' + }}> + vs Prior OTB: {otbDiff >= 0 ? '+' : ''}{otbDiff.toLocaleString(undefined, { maximumFractionDigits: 0 })} + +
+
+ BLENDED FORECAST + + {metric === 'occupancy' ? `${forecastVal.toFixed(1)}%` : forecastVal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + = 0 ? '#16a34a' : '#dc2626' + }}> + vs Prior Final: {forecastDiff >= 0 ? '+' : ''}{forecastDiff.toLocaleString(undefined, { maximumFractionDigits: 0 })} + +
+
+ VS PRIOR YEAR +
+
+ + {blendedResults.summary.days_forecasting_more} + + days up +
+
+ + {blendedResults.summary.days_forecasting_less} + + days down +
+
+
+
+ ) + })()} + + {/* Model Weights Info */} + {blendedResults?.summary && ( +
+ MODEL BLEND (MAPE-weighted) +
+ + Prophet: {(blendedResults.summary.prophet_weight * 100).toFixed(1)}% + + + XGBoost: {(blendedResults.summary.xgboost_weight * 100).toFixed(1)}% + + + CatBoost: {(blendedResults.summary.catboost_weight * 100).toFixed(1)}% + +
+
+ 60% MAPE-Weighted Models + 40% {isRevenueMetric ? 'Budget' : 'Prior Year'} +
+
+ )} + + {/* Chart */} + {isLoading ? ( +
Calculating blended forecast...
+ ) : blendedResults?.data && blendedResults.data.length > 0 ? ( +
+ +
+ ) : null} + + {/* Data Table Toggle */} + {blendedResults?.data && blendedResults.data.length > 0 && ( + <> + + + {showTable && ( +
+ + + + + + + + + + + + + + {blendedResults.data.map((row) => ( + + + + + + + + + + ))} + +
DateDOWCurrent OTBPrior OTBBlended FC{isRevenueMetric ? 'Budget' : 'Prior Yr'}Prior Final
{row.date}{row.day_of_week}{row.current_otb?.toLocaleString(undefined, { maximumFractionDigits: 1 }) ?? '-'}{row.prior_year_otb?.toLocaleString(undefined, { maximumFractionDigits: 1 }) ?? '-'} + {row.blended_forecast?.toLocaleString(undefined, { maximumFractionDigits: 1 }) ?? '-'} + + {row.budget_or_prior?.toLocaleString(undefined, { maximumFractionDigits: 1 }) ?? '-'} + {row.prior_year_final?.toLocaleString(undefined, { maximumFractionDigits: 1 }) ?? '-'}
+
+ )} + + )} +
+ ) +} + +// ============================================ +// PICKUP-V2 FORECAST COMPONENT (Production) +// Clean revenue forecast using Pickup-V2 model +// ============================================ + +interface PickupV2ForecastProps { + consolidation: 'daily' | 'weekly' | 'monthly' +} + +const PickupV2Forecast: React.FC = ({ consolidation }) => { + const pickupTableRef = useRef(null) + + // Helper: get Monday of the week containing a date + const getMondayOfWeek = (date: Date): Date => { + const d = new Date(date) + const day = d.getDay() + const diff = day === 0 ? -6 : 1 - day // Adjust to Monday (day 0 = Sunday) + d.setDate(d.getDate() + diff) + d.setHours(0, 0, 0, 0) + return d + } + + // Helper: get ISO week number + const getISOWeekNumber = (date: Date): number => { + const d = new Date(date) + d.setHours(0, 0, 0, 0) + d.setDate(d.getDate() + 4 - (d.getDay() || 7)) + const yearStart = new Date(d.getFullYear(), 0, 1) + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + } + + // Helper: get financial year start (August of the current FY) + // Financial year runs Aug-Jul, so if we're in Jan-Jul, FY started previous August + const getFinancialYearStart = (date: Date): string => { + const year = date.getFullYear() + const month = date.getMonth() // 0-indexed (0=Jan, 7=Aug) + // If we're in Aug-Dec (months 7-11), FY started this year's August + // If we're in Jan-Jul (months 0-6), FY started last year's August + const fyStartYear = month >= 7 ? year : year - 1 + return `${fyStartYear}-08` // August + } + + // Default values depend on consolidation type + const today = new Date() + const currentMonday = getMondayOfWeek(today) + + // For monthly: default to financial year (Aug-Jul); for daily: current month; for weekly: week-based + const [selectedMonth, setSelectedMonth] = useState(() => { + if (consolidation === 'monthly') { + return getFinancialYearStart(today) // Start of financial year (August) + } + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` + }) + const [selectedWeek, setSelectedWeek] = useState(() => formatDate(currentMonday)) + // Monthly defaults to 12 months (full FY), daily to 1 month, weekly handled separately + const [duration, setDuration] = useState<'1' | '3' | '6' | '12'>( + consolidation === 'monthly' ? '12' : consolidation === 'daily' ? '1' : '3' + ) + const [weekDuration, setWeekDuration] = useState<'4' | '8' | '13' | '26'>('13') // Default 13 weeks (~3 months) + const [showTable, setShowTable] = useState(true) // Default to showing budget/forecast table + const [showPickupTable, setShowPickupTable] = useState(false) // Pickup/rate data table + const [useCustomDates, setUseCustomDates] = useState(false) + const [customStartDate, setCustomStartDate] = useState('') + const [customEndDate, setCustomEndDate] = useState('') + + // Generate month options (24 months back + current + 12 months forward) + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + for (let i = -24; i <= 12; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1) + const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const label = date.toLocaleString('default', { month: 'short', year: 'numeric' }) + options.push({ value, label }) + } + return options + }, []) + + // Generate week options (52 weeks back + current + 26 weeks forward) - Mon-Sun weeks + const weekOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + const currentMon = getMondayOfWeek(now) + + for (let i = -52; i <= 26; i++) { + const monday = new Date(currentMon) + monday.setDate(currentMon.getDate() + (i * 7)) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + + const weekNum = getISOWeekNumber(monday) + const value = formatDate(monday) + const label = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })})` + options.push({ value, label }) + } + return options + }, []) + + // Calculate start and end dates based on consolidation type and selection + const { startDate, endDate } = useMemo(() => { + if (useCustomDates && customStartDate && customEndDate) { + return { startDate: customStartDate, endDate: customEndDate } + } + + if (consolidation === 'weekly') { + // Week-based: start from selected Monday, duration in weeks + const start = new Date(selectedWeek) + const durationWeeks = parseInt(weekDuration) + const end = new Date(start) + end.setDate(start.getDate() + (durationWeeks * 7) - 1) // End on Sunday of last week + return { + startDate: formatDate(start), + endDate: formatDate(end) + } + } else { + // Month-based for daily/monthly + const [year, month] = selectedMonth.split('-').map(Number) + const start = new Date(year, month - 1, 1) + const durationMonths = parseInt(duration) + const end = new Date(year, month - 1 + durationMonths, 0) + return { + startDate: formatDate(start), + endDate: formatDate(end) + } + } + }, [selectedMonth, selectedWeek, duration, weekDuration, consolidation, useCustomDates, customStartDate, customEndDate]) + + // Fetch Pickup-V2 forecast from backend + const { data: forecastData, isLoading: forecastLoading } = useQuery({ + queryKey: ['pickup-v2-forecast', startDate, endDate], + queryFn: async () => { + const response = await api.get('/forecast/pickup-v2-preview', { params: { start_date: startDate, end_date: endDate, metric: 'net_accom' } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch actuals data from newbook_net_revenue_data for past days + const { data: actualsData, isLoading: actualsLoading } = useQuery({ + queryKey: ['actuals-for-pickup-v2', startDate, endDate], + queryFn: async () => { + const response = await api.get('/forecast/actuals', { params: { start_date: startDate, end_date: endDate, metric: 'net_accom' } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch room categories for category name lookup + const { data: roomCategories } = useQuery<{ site_id: string; site_name: string }[]>({ + queryKey: ['room-categories'], + queryFn: async () => { + try { + const { data } = await api.get('/config/room-categories') + return data + } catch { + return [] + } + }, + enabled: true, + staleTime: 5 * 60 * 1000, // Cache for 5 minutes + }) + + // Build category name map + const categoryNameMap = useMemo(() => { + const map: Record = {} + if (roomCategories) { + for (const cat of roomCategories) { + map[cat.site_id] = cat.site_name + } + } + return map + }, [roomCategories]) + + const isLoading = forecastLoading || actualsLoading + + // Fetch budget data + const budgetData = useBudgetData(startDate, endDate, 'net_accom') + + // Build budget map + const budgetMap = useMemo(() => { + const map: Record = {} + if (budgetData) { + for (const b of budgetData) { + map[b.date] = b.budget_value + } + } + return map + }, [budgetData]) + + // Build actuals map (actual_value from newbook_net_revenue_data) + const actualsMap = useMemo(() => { + const map: Record = {} + if (actualsData?.data) { + for (const a of actualsData.data) { + if (a.actual_value !== null) { + map[a.date] = a.actual_value + } + } + } + return map + }, [actualsData]) + + // Consolidate data for weekly/monthly views + const consolidatedData = useMemo(() => { + if (!forecastData?.data || consolidation === 'daily') return null + + const todayStr = formatDate(new Date()) + const groups: Record = {} + + forecastData.data.forEach(d => { + const dateObj = new Date(d.date) + let groupKey: string + let groupLabel: string + + if (consolidation === 'weekly') { + // Get Monday of this week (Mon-Sun week) + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + + // Use Monday date as key for consistent sorting + groupKey = formatDate(monday) + + // ISO week number for label + const d = new Date(monday) + d.setDate(d.getDate() + 4 - (d.getDay() || 7)) + const yearStart = new Date(d.getFullYear(), 0, 1) + const weekNum = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + + groupLabel = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })})` + } else { + groupKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}` + groupLabel = dateObj.toLocaleString('default', { month: 'short', year: 'numeric' }) + } + + if (!groups[groupKey]) { + groups[groupKey] = { + label: groupLabel, + startDate: d.date, + otb: 0, + futureOtb: 0, // OTB only for future days (for chart stacking) + forecast: 0, + actual: 0, + forecastRemaining: 0, + priorYear: 0, + budget: 0, + days: 0, + pastDays: 0, + futureDays: 0 + } + } + + const isPast = d.date < todayStr + groups[groupKey].otb += d.current_otb_rev || 0 // Total OTB (for reference) + groups[groupKey].forecast += d.forecast || 0 + groups[groupKey].priorYear += d.prior_year_final_rev || 0 + groups[groupKey].budget += budgetMap[d.date] || 0 + groups[groupKey].days++ + + if (isPast) { + // Past day - use actual revenue (OTB not relevant for past) + groups[groupKey].actual += actualsMap[d.date] ?? 0 + groups[groupKey].pastDays++ + } else { + // Future day - track OTB and forecast separately + groups[groupKey].futureOtb += d.current_otb_rev || 0 // OTB for future days only + groups[groupKey].forecastRemaining += d.forecast || 0 + groups[groupKey].futureDays++ + } + }) + + return Object.entries(groups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, data]) => { + // For display: projected = actual + forecastRemaining + const projected = data.actual + data.forecastRemaining + return { + key, + ...data, + projected, + variance: projected - data.priorYear, + variancePct: data.priorYear > 0 ? ((projected / data.priorYear) - 1) * 100 : 0, + budgetVariance: projected - data.budget, + budgetVariancePct: data.budget > 0 ? ((projected / data.budget) - 1) * 100 : 0 + } + }) + }, [forecastData, consolidation, budgetMap]) + + // Build chart data + const chartData = useMemo(() => { + if (consolidation !== 'daily' && consolidatedData) { + const labels = consolidatedData.map(d => d.label) + const actuals = consolidatedData.map(d => d.actual) + // futureOtb = OTB only for future days (already tracked in consolidation) + const futureOtb = consolidatedData.map(d => d.futureOtb) + // Pickup portion = forecast remaining minus future OTB (the expected additional revenue) + const pickupPortion = consolidatedData.map((d) => { + if (d.futureDays === 0) return 0 + return Math.max(0, d.forecastRemaining - d.futureOtb) // Pickup = Forecast - OTB + }) + const priorYear = consolidatedData.map(d => d.priorYear) + const budget = consolidatedData.map(d => d.budget) + + // Stacked bars: Actual (green, bottom) + OTB (cyan, middle) + Pickup/Forecast (amber, top) + // Total bar height = Actual + OTB + Pickup = Actual + Forecast (since Forecast = OTB + Pickup) + // Lines: Prior Year (dotted) + Budget (dashed) + const traces: any[] = [ + // Stacked bar: Actual (bottom) - green + { + x: labels, + y: actuals, + type: 'bar' as const, + name: 'Actual', + marker: { color: '#16a34a' }, // Green for actuals + hovertemplate: `Actual: £%{y:,.0f}`, + }, + // Stacked bar: Future OTB (middle) - cyan - already booked revenue + { + x: labels, + y: futureOtb, + type: 'bar' as const, + name: 'OTB (Booked)', + marker: { color: CHART_COLORS.futureOtb }, // Cyan for OTB + hovertemplate: `OTB: £%{y:,.0f}`, + }, + // Stacked bar: Pickup/Forecast (top) - amber - expected additional pickup + { + x: labels, + y: pickupPortion, + type: 'bar' as const, + name: 'Forecast', + marker: { color: CHART_COLORS.blended }, // Amber for forecast/pickup + hovertemplate: `Forecast (Pickup): £%{y:,.0f}`, + }, + // Prior Year as line (not stacked) + { + x: labels, + y: priorYear, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: CHART_COLORS.priorFinal, width: 2, dash: 'dot' as const }, + marker: { size: 6 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + if (budget.some(b => b > 0)) { + traces.push({ + x: labels, + y: budget, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: CHART_COLORS.budget, width: 2, dash: 'dash' as const }, + marker: { size: 8 }, + hovertemplate: `Budget: £%{y:,.0f}`, + }) + } + + return traces + } + + if (!forecastData?.data) return [] + + const todayStr = formatDate(new Date()) + const dates = forecastData.data.map(d => d.date) + const priorFinal = forecastData.data.map(d => d.prior_year_final_rev) + const priorOtb = forecastData.data.map(d => d.prior_year_otb_rev) + const priorDates = forecastData.data.map(d => d.prior_year_date) + + // Split data into actuals (past) and future OTB + const actualDates: string[] = [] + const actualValues: (number | null)[] = [] + const futureOtbDates: string[] = [] + const futureOtbValues: (number | null)[] = [] + const forecastDates: string[] = [] + const forecastValues: (number | null)[] = [] + const upperBoundValues: (number | null)[] = [] + const lowerBoundValues: (number | null)[] = [] + + forecastData.data.forEach(d => { + if (d.date < todayStr) { + // Past dates - show actuals from newbook_net_revenue_data (green) + actualDates.push(d.date) + actualValues.push(actualsMap[d.date] ?? null) + } else { + // Future dates - show OTB in cyan and forecast in red + futureOtbDates.push(d.date) + futureOtbValues.push(d.current_otb_rev) + forecastDates.push(d.date) + forecastValues.push(d.forecast) + upperBoundValues.push(d.upper_bound ?? null) + lowerBoundValues.push(d.lower_bound ?? null) + } + }) + + const traces: any[] = [ + // Prior year final fill (bottom layer) + { + x: dates, + y: priorFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + customdata: priorDates, + hovertemplate: 'Prior Final (%{customdata}): £%{y:,.0f}', + }, + // Prior year OTB (dashed gray) + { + x: dates, + y: priorOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: CHART_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: 'Prior OTB (%{customdata}): £%{y:,.0f}', + }, + ] + + // Actuals (past dates) - solid green + if (actualDates.length > 0) { + traces.push({ + x: actualDates, + y: actualValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Actual', + line: { color: CHART_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: 'Actual: £%{y:,.0f}', + }) + } + + // Future OTB - cyan + if (futureOtbDates.length > 0) { + traces.push({ + x: futureOtbDates, + y: futureOtbValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Future OTB', + line: { color: CHART_COLORS.futureOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: 'Future OTB: £%{y:,.0f}', + }) + } + + // Upper/Lower bounds - filled area (add before forecast so forecast line is on top) + if (forecastDates.length > 0 && upperBoundValues.some(v => v !== null)) { + // Lower bound line (invisible, just for fill reference) + traces.push({ + x: forecastDates, + y: lowerBoundValues, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Lower Bound', + line: { color: 'rgba(234, 88, 12, 0.3)', width: 1 }, + showlegend: false, + hovertemplate: 'Lower: £%{y:,.0f}', + }) + // Upper bound with fill to lower bound + traces.push({ + x: forecastDates, + y: upperBoundValues, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Forecast Range', + fill: 'tonexty' as const, + fillcolor: 'rgba(234, 88, 12, 0.15)', + line: { color: 'rgba(234, 88, 12, 0.3)', width: 1 }, + hovertemplate: 'Upper: £%{y:,.0f}', + }) + } + + // Forecast (future dates only) - amber to match summary + if (forecastDates.length > 0) { + traces.push({ + x: forecastDates, + y: forecastValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Forecast', + line: { color: CHART_COLORS.blended, width: 3 }, + marker: { size: 8 }, + hovertemplate: 'Forecast: £%{y:,.0f}', + }) + } + + // Budget trace + const budgetTrace = buildBudgetTrace(budgetData) + if (budgetTrace) traces.push(budgetTrace) + + return traces + }, [forecastData, budgetData, consolidation, consolidatedData]) + + // Today line for chart - only show if today falls within the selected period + const todayLine = useMemo(() => { + const todayStr = formatDate(new Date()) + // Don't show today line if period doesn't include today + if (todayStr < startDate || todayStr > endDate) { + return null + } + return { + type: 'line' as const, + x0: todayStr, + x1: todayStr, + y0: 0, + y1: 1, + yref: 'paper' as const, + line: { color: '#f59e0b', width: 2, dash: 'dash' as const }, + } + }, [startDate, endDate]) + + // Calculate summary stats - split by actual (past) vs forecast (future) + const summary = useMemo(() => { + if (!forecastData?.data || forecastData.data.length === 0) return null + + const todayStr = formatDate(new Date()) + + // Split data into past (actuals) and future (forecast) + const pastDays = forecastData.data.filter(d => d.date < todayStr) + const futureDays = forecastData.data.filter(d => d.date >= todayStr) + + // Actual to date (past days - use actual revenue from newbook_net_revenue_data) + const actualTotal = pastDays.reduce((sum, d) => sum + (actualsMap[d.date] ?? 0), 0) + const actualBudgetTotal = pastDays.reduce((sum, d) => sum + (budgetMap[d.date] || 0), 0) + const actualPriorTotal = pastDays.reduce((sum, d) => sum + (d.prior_year_final_rev || 0), 0) + + // Future OTB (confirmed bookings) + const futureOtbTotal = futureDays.reduce((sum, d) => sum + (d.current_otb_rev || 0), 0) + + // Pace = Actual + Future OTB + const paceTotal = actualTotal + futureOtbTotal + + // Forecast remaining (future days - full forecast including pickup) + const forecastRemainingTotal = futureDays.reduce((sum, d) => sum + (d.forecast || 0), 0) + const forecastBudgetTotal = futureDays.reduce((sum, d) => sum + (budgetMap[d.date] || 0), 0) + const forecastPriorTotal = futureDays.reduce((sum, d) => sum + (d.prior_year_final_rev || 0), 0) + + // Projected total = Actual + Forecast + const projectedTotal = actualTotal + forecastRemainingTotal + const totalBudget = actualBudgetTotal + forecastBudgetTotal + const totalPriorYear = actualPriorTotal + forecastPriorTotal + + return { + // Actual to date + actualTotal, + actualBudgetTotal, + actualPriorTotal, + actualVariance: actualTotal - actualBudgetTotal, + daysActual: pastDays.length, + // Pace (Actual + OTB) + paceTotal, + futureOtbTotal, + // Forecast remaining + forecastRemainingTotal, + forecastBudgetTotal, + forecastPriorTotal, + forecastVariance: forecastRemainingTotal - forecastBudgetTotal, + daysForecast: futureDays.length, + // Projected total + projectedTotal, + totalBudget, + totalPriorYear, + budgetVariance: projectedTotal - totalBudget, + budgetVariancePct: totalBudget > 0 ? ((projectedTotal / totalBudget) - 1) * 100 : 0, + priorYearVariance: projectedTotal - totalPriorYear, + priorYearVariancePct: totalPriorYear > 0 ? ((projectedTotal / totalPriorYear) - 1) * 100 : 0, + daysCount: forecastData.data.length + } + }, [forecastData, budgetMap, actualsMap]) + + const viewLabel = consolidation === 'daily' ? 'Day' : consolidation === 'weekly' ? 'Week' : 'Month' + + return ( +
+
+
+

Accommodation Revenue by {viewLabel}

+

+ Room-based pickup model using prior year patterns with current rate adjustments +

+
+
+ + {/* Controls */} +
+ {/* From Selector - Week-based for weekly, Month-based for daily/monthly */} +
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ + {/* Duration Selector - Weeks for weekly, Months for daily/monthly */} +
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ + {/* Period Display */} +
+ +
+ {startDate} to {endDate} +
+
+ + {/* Custom Date Toggle */} +
+ +
+ { + setUseCustomDates(e.target.checked) + if (e.target.checked && !customStartDate) { + setCustomStartDate(startDate) + setCustomEndDate(endDate) + } + }} + /> + {useCustomDates && ( + <> + setCustomStartDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + to + setCustomEndDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + + )} +
+
+
+ + {/* Summary Stats - 4 blocks matching MetricForecast */} + {summary && ( +
+ {/* Actual to Date */} +
+ ACTUAL TO DATE ({summary.daysActual} days) + {(() => { + const actualBudgetPct = summary.actualBudgetTotal > 0 + ? ((summary.actualTotal / summary.actualBudgetTotal) - 1) * 100 + : 0 + const actualPriorPct = summary.actualPriorTotal > 0 + ? ((summary.actualTotal / summary.actualPriorTotal) - 1) * 100 + : 0 + const hasBudget = summary.actualBudgetTotal > 0 + return ( + <> + = 0 ? '#16a34a' : '#dc2626') : 'var(--text-dark)', + }}> + £{summary.actualTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {hasBudget && ( + + {actualBudgetPct >= 0 ? '+' : ''}{actualBudgetPct.toFixed(1)}% + + )} + + {hasBudget ? ( + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Budget: £{summary.actualBudgetTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({summary.actualVariance >= 0 ? '+' : ''}£{summary.actualVariance.toLocaleString(undefined, { maximumFractionDigits: 0 })}) + + ) : ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: £{summary.actualPriorTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({actualPriorPct >= 0 ? '+' : ''}{actualPriorPct.toFixed(1)}%) + + + ) + })()} +
+ + {/* OTB Pace (Actual + OTB) */} +
+ OTB PACE + {(() => { + const hasBudget = summary.totalBudget > 0 + const paceVariance = summary.paceTotal - summary.totalBudget + const paceVariancePct = hasBudget + ? ((summary.paceTotal / summary.totalBudget) - 1) * 100 + : 0 + const pacePriorPct = summary.totalPriorYear > 0 + ? ((summary.paceTotal / summary.totalPriorYear) - 1) * 100 + : 0 + return ( + <> + + £{summary.paceTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + {hasBudget ? ( + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Budget: £{summary.totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({paceVariancePct >= 0 ? '+' : ''}{paceVariancePct.toFixed(1)}%) + + ) : ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: £{summary.totalPriorYear.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({pacePriorPct >= 0 ? '+' : ''}{pacePriorPct.toFixed(1)}%) + + + ) + })()} +
+ + {/* Forecast Remaining */} +
+ FORECAST REMAINING ({summary.daysForecast} days) + {(() => { + // Remaining budget = total budget - actuals achieved (tracks progress toward meeting budget) + // If actuals are ahead, less remaining budget needed; if behind, more needed + const hasActuals = summary.daysActual > 0 + const remainingBudgetNeeded = hasActuals + ? Math.max(0, summary.totalBudget - summary.actualTotal) + : summary.forecastBudgetTotal + const hasBudget = remainingBudgetNeeded > 0 || summary.totalBudget > 0 + const forecastVariance = summary.forecastRemainingTotal - remainingBudgetNeeded + const forecastVariancePct = remainingBudgetNeeded > 0 + ? ((summary.forecastRemainingTotal / remainingBudgetNeeded) - 1) * 100 + : 0 + const forecastPriorPct = summary.forecastPriorTotal > 0 + ? ((summary.forecastRemainingTotal / summary.forecastPriorTotal) - 1) * 100 + : 0 + return ( + <> + + £{summary.forecastRemainingTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + {hasBudget ? ( + = 0 ? '#16a34a' : '#dc2626', + }}> + {hasActuals ? 'Remaining Budget: ' : 'vs Budget: '} + £{remainingBudgetNeeded.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({forecastVariancePct >= 0 ? '+' : ''}{forecastVariancePct.toFixed(1)}%) + + ) : ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: £{summary.forecastPriorTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({forecastPriorPct >= 0 ? '+' : ''}{forecastPriorPct.toFixed(1)}%) + + + ) + })()} +
+ + {/* Projected Total */} +
+ PROJECTED TOTAL + {(() => { + const hasBudget = summary.totalBudget > 0 + return ( + <> + = 0 ? '#16a34a' : '#dc2626') : 'var(--text-dark)', + }}> + £{summary.projectedTotal.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {hasBudget && ( + + {summary.budgetVariance >= 0 ? '+' : ''}{summary.budgetVariancePct.toFixed(1)}% + + )} + + {hasBudget ? ( + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Budget: £{summary.totalBudget.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({summary.budgetVariance >= 0 ? '+' : ''}£{summary.budgetVariance.toLocaleString(undefined, { maximumFractionDigits: 0 })}) + + ) : ( + vs Budget: N/A + )} + = 0 ? '#16a34a' : '#dc2626', + }}> + vs Last Year: £{summary.totalPriorYear.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {' '}({summary.priorYearVariance >= 0 ? '+' : ''}{summary.priorYearVariancePct.toFixed(1)}%) + + + ) + })()} +
+ + {/* Lost Potential - clickable to open pickup table */} + {consolidation === 'daily' && ( +
0 ? '#fef3c7' : '#d1fae5', + borderColor: (forecastData?.summary?.lost_potential_total || 0) > 0 ? '#f59e0b' : '#10b981', + borderWidth: 2, + cursor: 'pointer', + }} + onClick={() => { + setShowPickupTable(true) + setTimeout(() => { + pickupTableRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 100) + }} + title="Click to view Pickup/Rate data" + > + {(forecastData?.summary?.lost_potential_total || 0) > 0 ? ( + <> + LOST POTENTIAL + + £{(forecastData?.summary?.lost_potential_total || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })} + + + {forecastData?.summary?.opportunity_days_count || 0} days with rate gaps + + + Click to view details → + + + ) : ( + <> + RATE PERFORMANCE + On Track + + No significant rate gaps + + + Click to view details → + + + )} +
+ )} +
+ )} + + {/* Chart */} + {isLoading ? ( +
Loading forecast...
+ ) : chartData.length > 0 ? ( +
+ +
+ ) : null} + + {/* Budget/Forecast Table Toggle */} + + + {/* Daily Budget/Forecast Table */} + {showTable && consolidation === 'daily' && forecastData?.data && forecastData.data.length > 0 && ( +
+ + + + + + + + + + + + + + {(() => { + const todayStr = formatDate(new Date()) + return forecastData.data.map((row) => { + const isPast = row.date < todayStr + const actualValue = actualsMap[row.date] + // For display: past dates use actual, future dates use forecast + const displayValue = isPast ? (actualValue ?? row.forecast) : row.forecast + const diff = (displayValue || 0) - (row.prior_year_final_rev || 0) + const budget = budgetMap[row.date] || 0 + const budgetDiff = (displayValue || 0) - budget + // Short day name from day_of_week (e.g., "Monday" -> "Mon") + const shortDay = row.day_of_week?.substring(0, 3) || '' + return ( + + + + + + + + + + ) + }) + })()} + +
DateOTBForecastPrior Yearvs PriorBudgetvs Budget
{row.date} {shortDay} + £{row.current_otb_rev?.toLocaleString(undefined, { maximumFractionDigits: 0 }) ?? '-'} + + £{displayValue?.toLocaleString(undefined, { maximumFractionDigits: 0 }) ?? '-'} + £{row.prior_year_final_rev?.toLocaleString(undefined, { maximumFractionDigits: 0 }) ?? '-'}= 0 ? '#16a34a' : '#dc2626' }}> + {diff >= 0 ? '+' : ''}£{diff.toLocaleString(undefined, { maximumFractionDigits: 0 })} + {budget > 0 ? `£${budget.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'}= 0 ? '#16a34a' : '#dc2626' }}> + {budget > 0 ? `${budgetDiff >= 0 ? '+' : ''}£${budgetDiff.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'} +
+
+ )} + + {/* Weekly/Monthly Table */} + {showTable && consolidation !== 'daily' && consolidatedData && consolidatedData.length > 0 && ( +
+ + + + + + + + + + + + + + + + + {consolidatedData.map((row) => ( + + + + + + + + + + + + + ))} + +
{viewLabel}DaysActualFuture OTBForecastProjectedPrior Yearvs PriorBudgetvs Budget
{row.label} + {row.pastDays > 0 && row.futureDays > 0 + ? `${row.pastDays}+${row.futureDays}` + : row.days} + 0 ? 600 : 400 }}> + {row.actual > 0 ? `£${row.actual.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'} + + {row.futureDays > 0 ? `£${row.otb.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'} + + {row.forecastRemaining > 0 ? `£${row.forecastRemaining.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'} + + £{row.projected.toLocaleString(undefined, { maximumFractionDigits: 0 })} + £{row.priorYear.toLocaleString(undefined, { maximumFractionDigits: 0 })}= 0 ? '#16a34a' : '#dc2626' }}> + {row.variance >= 0 ? '+' : ''}{row.variancePct.toFixed(1)}% + {row.budget > 0 ? `£${row.budget.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '-'}= 0 ? '#16a34a' : '#dc2626' }}> + {row.budget > 0 ? `${row.budgetVariance >= 0 ? '+' : ''}${row.budgetVariancePct.toFixed(1)}%` : '-'} +
+
+ )} + + {/* Pickup/Rate Data Table Toggle - Daily only */} + {consolidation === 'daily' && ( + + )} + + {/* Pickup/Rate Data Table */} + {showPickupTable && consolidation === 'daily' && forecastData?.data && forecastData.data.length > 0 && ( +
+ + + + + + + + + + + + + + + + + + + + + + {forecastData.data.map((row) => { + const hasOpportunity = row.has_pricing_opportunity === true + const rowStyle = hasOpportunity ? { backgroundColor: '#fef9e7' } : {} + const pickupRooms = row.pickup_rooms_total || 0 + const otbRooms = row.current_otb || 0 + const forecastRooms = row.forecast || (otbRooms + pickupRooms) + const priorOtbRev = row.prior_year_otb_rev || 0 + const priorFinalRev = row.prior_year_final_rev || 0 + const otbPacePct = priorOtbRev > 0 ? ((row.current_otb_rev || 0) - priorOtbRev) / priorOtbRev * 100 : 0 + const finalPacePct = priorFinalRev > 0 ? (row.forecast - priorFinalRev) / priorFinalRev * 100 : 0 + const shortDay = row.day_of_week?.substring(0, 3) || '' + + // Build tooltip for category breakdown with category names + const categoryBreakdown = row.category_breakdown || {} + const pickupTooltip = Object.entries(categoryBreakdown) + .filter(([_, data]: [string, any]) => data.pickup_rooms > 0) + .map(([catId, data]: [string, any]) => { + const catName = categoryNameMap[catId] || `Category ${catId}` + return `${catName}: ${data.pickup_rooms} rooms` + }) + .join('\n') || 'No pickup expected' + + const pickupCalcTooltip = Object.entries(categoryBreakdown) + .filter(([_, data]: [string, any]) => data.pickup_rooms > 0) + .map(([catId, data]: [string, any]) => { + const catName = categoryNameMap[catId] || `Category ${catId}` + const netRate = data.prior_avg_rate?.toFixed(0) || '0' + const grossRate = data.prior_avg_rate_gross?.toFixed(0) || '0' + const pickupRev = data.forecast_pickup_rev?.toFixed(0) || '0' + return `${catName}: ${data.pickup_rooms} × £${netRate} (£${grossRate}) = £${pickupRev}` + }) + .join('\n') || 'No pickup expected for this date' + + // Get effective rate with gross for display + const effectiveNet = row.effective_rate?.toFixed(0) || row.weighted_avg_prior_rate?.toFixed(0) || '0' + const effectiveGross = row.effective_rate_gross?.toFixed(0) || row.weighted_avg_prior_rate_gross?.toFixed(0) || '0' + + return ( + + + + + + + + + + + + + + + + + + ) + })} + +
DateOTB RmsPickup RmsFcst RmsOTB RevPickup CalcForecastLY OTBPaceLY Finalvs FinalCurr RateLY RateLost £Rate Gap
{row.date} {shortDay}{Math.round(otbRooms)}{pickupRooms}{Math.round(forecastRooms)} + £{(row.current_otb_rev || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })} + + {pickupRooms} × £{effectiveNet} (£{effectiveGross}) + {row.effective_rate && row.weighted_avg_prior_rate && row.effective_rate < row.weighted_avg_prior_rate && ( + * + )} + + £{row.forecast.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + £{priorOtbRev.toLocaleString(undefined, { maximumFractionDigits: 0 })} + = 0 ? '#10b981' : '#ef4444' }}> + {otbPacePct >= 0 ? '+' : ''}{otbPacePct.toFixed(0)}% + + £{priorFinalRev.toLocaleString(undefined, { maximumFractionDigits: 0 })} + = 0 ? '#10b981' : '#ef4444' }}> + {finalPacePct >= 0 ? '+' : ''}{finalPacePct.toFixed(0)}% + + {row.weighted_avg_current_rate + ? <>£{row.weighted_avg_current_rate.toFixed(0)} (£{row.weighted_avg_current_rate_gross?.toFixed(0) || '-'}) + : '-'} + + {row.weighted_avg_listed_rate + ? <>£{row.weighted_avg_listed_rate.toFixed(0)} (£{row.weighted_avg_listed_rate_gross?.toFixed(0) || '-'}) + : '-'} + + {(row.lost_potential || 0) > 0 + ? `£${(row.lost_potential || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : '-'} + + {row.rate_vs_prior_pct !== null && row.rate_vs_prior_pct !== undefined + ? `${row.rate_vs_prior_pct >= 0 ? '+' : ''}${row.rate_vs_prior_pct.toFixed(1)}%` + : '-'} +
+
+ )} +
+ ) +} + +// ============================================ +// PICKUP-V2 BOOKINGS FORECAST COMPONENT (Room Nights) +// ============================================ + +interface PickupV2BookingsForecastProps { + consolidation: 'daily' | 'weekly' | 'monthly' +} + +const PickupV2BookingsForecast: React.FC = ({ consolidation }) => { + + // Helper: get Monday of the week containing a date + const getMondayOfWeek = (date: Date): Date => { + const d = new Date(date) + const day = d.getDay() + const diff = day === 0 ? -6 : 1 - day + d.setDate(d.getDate() + diff) + d.setHours(0, 0, 0, 0) + return d + } + + // Helper: get ISO week number + const getISOWeekNumber = (date: Date): number => { + const d = new Date(date) + d.setHours(0, 0, 0, 0) + d.setDate(d.getDate() + 4 - (d.getDay() || 7)) + const yearStart = new Date(d.getFullYear(), 0, 1) + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + } + + // Helper: get financial year start (August) + const getFinancialYearStart = (date: Date): string => { + const year = date.getFullYear() + const month = date.getMonth() + const fyStartYear = month >= 7 ? year : year - 1 + return `${fyStartYear}-08` + } + + const today = new Date() + const currentMonday = getMondayOfWeek(today) + + const [selectedMonth, setSelectedMonth] = useState(() => { + if (consolidation === 'monthly') { + return getFinancialYearStart(today) + } + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` + }) + const [selectedWeek, setSelectedWeek] = useState(() => formatDate(currentMonday)) + const [duration, setDuration] = useState<'1' | '3' | '6' | '12'>( + consolidation === 'monthly' ? '12' : consolidation === 'daily' ? '1' : '3' + ) + const [weekDuration, setWeekDuration] = useState<'4' | '8' | '13' | '26'>('13') + const [showTable, setShowTable] = useState(true) + const [useCustomDates, setUseCustomDates] = useState(false) + const [customStartDate, setCustomStartDate] = useState('') + const [customEndDate, setCustomEndDate] = useState('') + + // Generate month options + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + for (let i = -24; i <= 12; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1) + const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const label = date.toLocaleString('default', { month: 'short', year: 'numeric' }) + options.push({ value, label }) + } + return options + }, []) + + // Generate week options + const weekOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + const currentMon = getMondayOfWeek(now) + + for (let i = -52; i <= 26; i++) { + const monday = new Date(currentMon) + monday.setDate(currentMon.getDate() + (i * 7)) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + + const weekNum = getISOWeekNumber(monday) + const value = formatDate(monday) + const label = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })})` + options.push({ value, label }) + } + return options + }, []) + + // Calculate start and end dates + const { startDate, endDate } = useMemo(() => { + if (useCustomDates && customStartDate && customEndDate) { + return { startDate: customStartDate, endDate: customEndDate } + } + + if (consolidation === 'weekly') { + const start = new Date(selectedWeek) + const durationWeeks = parseInt(weekDuration) + const end = new Date(start) + end.setDate(start.getDate() + (durationWeeks * 7) - 1) + return { startDate: formatDate(start), endDate: formatDate(end) } + } else { + const [year, month] = selectedMonth.split('-').map(Number) + const start = new Date(year, month - 1, 1) + const durationMonths = parseInt(duration) + const end = new Date(year, month - 1 + durationMonths, 0) + return { startDate: formatDate(start), endDate: formatDate(end) } + } + }, [selectedMonth, selectedWeek, duration, weekDuration, consolidation, useCustomDates, customStartDate, customEndDate]) + + // Fetch Pickup-V2 forecast with rooms metric + const { data: forecastData, isLoading } = useQuery({ + queryKey: ['pickup-v2-bookings', startDate, endDate], + queryFn: async () => { + const response = await api.get('/forecast/pickup-v2-preview', { params: { start_date: startDate, end_date: endDate, metric: 'rooms' } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Consolidate data for weekly/monthly views + const consolidatedData = useMemo(() => { + if (!forecastData?.data || consolidation === 'daily') return null + + const todayStr = formatDate(new Date()) + const groups: Record = {} + + forecastData.data.forEach(d => { + const dateObj = new Date(d.date) + let groupKey: string + let groupLabel: string + + if (consolidation === 'weekly') { + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + groupKey = formatDate(monday) + const weekD = new Date(monday) + weekD.setDate(weekD.getDate() + 4 - (weekD.getDay() || 7)) + const yearStart = new Date(weekD.getFullYear(), 0, 1) + const weekNum = Math.ceil((((weekD.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + groupLabel = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })})` + } else { + groupKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}` + groupLabel = dateObj.toLocaleString('default', { month: 'short', year: 'numeric' }) + } + + if (!groups[groupKey]) { + groups[groupKey] = { + label: groupLabel, + startDate: d.date, + otbRooms: 0, + futureOtbRooms: 0, + forecastRooms: 0, + actualRooms: 0, + forecastRemainingRooms: 0, + priorYearFinal: 0, + priorYearOtb: 0, + priorActualRooms: 0, + priorForecastRooms: 0, + days: 0, + pastDays: 0, + futureDays: 0 + } + } + + const isPast = d.date < todayStr + const otb = d.current_otb || 0 + const forecast = d.forecast || ((d.current_otb || 0) + (d.pickup_rooms_total || 0)) // Use capped forecast from API + const priorFinal = d.prior_year_final || 0 + const priorOtb = d.prior_year_otb || 0 + + groups[groupKey].otbRooms += otb + groups[groupKey].forecastRooms += forecast + groups[groupKey].priorYearFinal += priorFinal + groups[groupKey].days++ + + if (isPast) { + // For past days, actual = OTB (what actually happened) + groups[groupKey].actualRooms += otb + groups[groupKey].priorActualRooms += priorFinal // Prior year final for same past days + groups[groupKey].pastDays++ + } else { + groups[groupKey].futureOtbRooms += otb + groups[groupKey].forecastRemainingRooms += forecast + groups[groupKey].priorYearOtb += priorOtb // Prior year OTB for future days + groups[groupKey].priorForecastRooms += priorFinal // Prior year final for future days + groups[groupKey].futureDays++ + } + }) + + return Object.entries(groups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, data]) => { + const projected = data.actualRooms + data.forecastRemainingRooms + return { + key, + ...data, + projected, + variance: projected - data.priorYearFinal, + variancePct: data.priorYearFinal > 0 ? ((projected / data.priorYearFinal) - 1) * 100 : 0 + } + }) + }, [forecastData, consolidation]) + + // Build chart data + const chartData = useMemo(() => { + if (consolidation !== 'daily' && consolidatedData) { + const labels = consolidatedData.map(d => d.label) + const actuals = consolidatedData.map(d => d.actualRooms) + const futureOtb = consolidatedData.map(d => d.futureOtbRooms) + const pickupPortion = consolidatedData.map((d) => { + if (d.futureDays === 0) return 0 + return Math.max(0, d.forecastRemainingRooms - d.futureOtbRooms) + }) + const priorYear = consolidatedData.map(d => d.priorYearFinal) + + const traces: any[] = [ + { + x: labels, + y: actuals, + type: 'bar' as const, + name: 'Actual', + marker: { color: '#16a34a' }, + hovertemplate: `Actual: %{y:,.0f} rooms`, + }, + { + x: labels, + y: futureOtb, + type: 'bar' as const, + name: 'OTB (Booked)', + marker: { color: CHART_COLORS.futureOtb }, + hovertemplate: `OTB: %{y:,.0f} rooms`, + }, + { + x: labels, + y: pickupPortion, + type: 'bar' as const, + name: 'Forecast (Pickup)', + marker: { color: CHART_COLORS.blended }, + hovertemplate: `Forecast: %{y:,.0f} rooms`, + }, + { + x: labels, + y: priorYear, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: CHART_COLORS.priorFinal, width: 2, dash: 'dot' as const }, + marker: { size: 6 }, + hovertemplate: `Prior Year: %{y:,.0f} rooms`, + }, + ] + + return traces + } + + // Daily view + if (!forecastData?.data) return [] + + const todayStr = formatDate(new Date()) + const dates = forecastData.data.map(d => d.date) + const priorFinal = forecastData.data.map(d => d.prior_year_final) + + // Split into actuals and future + const actualDates: string[] = [] + const actualValues: (number | null)[] = [] + const futureOtbDates: string[] = [] + const futureOtbValues: (number | null)[] = [] + const forecastDates: string[] = [] + const forecastValues: (number | null)[] = [] + + forecastData.data.forEach(d => { + const otb = d.current_otb || 0 + const forecast = d.forecast || (otb + (d.pickup_rooms_total || 0)) + + if (d.date < todayStr) { + actualDates.push(d.date) + actualValues.push(otb) // For past days, OTB = actual bookings + } else { + futureOtbDates.push(d.date) + futureOtbValues.push(otb) + forecastDates.push(d.date) + forecastValues.push(forecast) + } + }) + + const traces: any[] = [ + { + x: dates, + y: priorFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + }, + ] + + if (actualDates.length > 0) { + traces.push({ + x: actualDates, + y: actualValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Actual', + line: { color: '#16a34a', width: 3 }, + marker: { size: 6 }, + }) + } + + if (futureOtbDates.length > 0) { + traces.push({ + x: futureOtbDates, + y: futureOtbValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'OTB (Booked)', + line: { color: CHART_COLORS.futureOtb, width: 2 }, + marker: { size: 5 }, + fill: 'tozeroy' as const, + fillcolor: 'rgba(6, 182, 212, 0.2)', + }) + } + + if (forecastDates.length > 0) { + traces.push({ + x: forecastDates, + y: forecastValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Forecast', + line: { color: CHART_COLORS.blended, width: 3 }, + marker: { size: 6 }, + }) + } + + return traces + }, [forecastData, consolidation, consolidatedData]) + + // Summary stats + const summary = useMemo(() => { + if (!forecastData?.data || forecastData.data.length === 0) return null + + const todayStr = formatDate(new Date()) + const pastDays = forecastData.data.filter(d => d.date < todayStr) + const futureDays = forecastData.data.filter(d => d.date >= todayStr) + + // Actual = OTB for past days (what actually happened) + const actualTotal = pastDays.reduce((sum, d) => sum + (d.current_otb || 0), 0) + // Prior year final for same past days + const priorActualTotal = pastDays.reduce((sum, d) => sum + (d.prior_year_final || 0), 0) + + // Future OTB + const futureOtbTotal = futureDays.reduce((sum, d) => sum + (d.current_otb || 0), 0) + // Prior year OTB from today's perspective (for same future days) + const priorOtbTotal = futureDays.reduce((sum, d) => sum + (d.prior_year_otb || 0), 0) + + // Forecast remaining = OTB + pickup for future days + const forecastRemainingTotal = futureDays.reduce((sum, d) => sum + (d.forecast || ((d.current_otb || 0) + (d.pickup_rooms_total || 0))), 0) + // Prior year final for future days + const priorForecastTotal = futureDays.reduce((sum, d) => sum + (d.prior_year_final || 0), 0) + + // Prior year total for full period + const priorYearTotal = forecastData.data.reduce((sum, d) => sum + (d.prior_year_final || 0), 0) + + // OTB Pace = actual past + OTB future + const otbPace = actualTotal + futureOtbTotal + // Prior OTB pace = prior actual for past + prior OTB for future + const priorOtbPace = priorActualTotal + priorOtbTotal + + // Projected total = actual + forecast remaining + const projectedTotal = actualTotal + forecastRemainingTotal + + return { + actualTotal, + priorActualTotal, + futureOtbTotal, + priorOtbTotal, + otbPace, + priorOtbPace, + forecastRemainingTotal, + priorForecastTotal, + projectedTotal, + priorYearTotal, + daysActual: pastDays.length, + daysForecast: futureDays.length, + } + }, [forecastData]) + + const viewLabel = consolidation === 'daily' ? 'Day' : consolidation === 'weekly' ? 'Week' : 'Month' + + // Today line for daily view + const todayLine = useMemo(() => { + const todayStr = formatDate(new Date()) + if (todayStr < startDate || todayStr > endDate) return null + return { + type: 'line' as const, + x0: todayStr, x1: todayStr, y0: 0, y1: 1, + yref: 'paper' as const, + line: { color: '#f59e0b', width: 2, dash: 'dash' as const }, + } + }, [startDate, endDate]) + + return ( +
+
+
+

Room Nights by {viewLabel}

+

+ Room-based pickup model showing booking counts +

+
+
+ + {/* Controls */} +
+
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ +
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ + {/* Custom Date Toggle */} +
+ +
+ { + setUseCustomDates(e.target.checked) + if (e.target.checked && !customStartDate) { + setCustomStartDate(startDate) + setCustomEndDate(endDate) + } + }} + /> + {useCustomDates && ( + <> + setCustomStartDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + to + setCustomEndDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + + )} +
+
+ +
+ +
+ {startDate} to {endDate} +
+
+
+ + {/* Summary Blocks */} + {summary && (() => { + const actualDiff = summary.actualTotal - summary.priorActualTotal + const actualPct = summary.priorActualTotal > 0 ? (actualDiff / summary.priorActualTotal) * 100 : 0 + + const otbPaceDiff = summary.otbPace - summary.priorOtbPace + const otbPacePct = summary.priorOtbPace > 0 ? (otbPaceDiff / summary.priorOtbPace) * 100 : 0 + const otbVsTotalDiff = summary.otbPace - summary.priorYearTotal + const otbVsTotalPct = summary.priorYearTotal > 0 ? (otbVsTotalDiff / summary.priorYearTotal) * 100 : 0 + + const forecastDiff = summary.forecastRemainingTotal - summary.priorForecastTotal + const forecastPct = summary.priorForecastTotal > 0 ? (forecastDiff / summary.priorForecastTotal) * 100 : 0 + + const projectedDiff = summary.projectedTotal - summary.priorYearTotal + const projectedPct = summary.priorYearTotal > 0 ? (projectedDiff / summary.priorYearTotal) * 100 : 0 + + return ( +
+ {/* Actual to Date */} +
+ ACTUAL TO DATE ({summary.daysActual} days) + + {summary.actualTotal.toLocaleString()} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {summary.priorActualTotal.toLocaleString()} ({actualDiff >= 0 ? '+' : ''}{actualDiff.toLocaleString()}, {actualPct >= 0 ? '+' : ''}{actualPct.toFixed(1)}%) + +
+ + {/* OTB Pace */} +
+ OTB PACE + + {summary.otbPace.toLocaleString()} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY OTB: {summary.priorOtbPace.toLocaleString()} ({otbPaceDiff >= 0 ? '+' : ''}{otbPaceDiff.toLocaleString()}, {otbPacePct >= 0 ? '+' : ''}{otbPacePct.toFixed(1)}%) + + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs LY Total: {summary.priorYearTotal.toLocaleString()} ({otbVsTotalDiff >= 0 ? '+' : ''}{otbVsTotalDiff.toLocaleString()}, {otbVsTotalPct >= 0 ? '+' : ''}{otbVsTotalPct.toFixed(1)}%) + +
+ + {/* Forecast Remaining */} +
+ FORECAST ({summary.daysForecast} days) + + {summary.forecastRemainingTotal.toLocaleString()} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {summary.priorForecastTotal.toLocaleString()} ({forecastDiff >= 0 ? '+' : ''}{forecastDiff.toLocaleString()}, {forecastPct >= 0 ? '+' : ''}{forecastPct.toFixed(1)}%) + +
+ + {/* Projected Total */} +
+ PROJECTED TOTAL + = 0 ? '#16a34a' : '#dc2626', + }}> + {summary.projectedTotal.toLocaleString()} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {summary.priorYearTotal.toLocaleString()} ({projectedDiff >= 0 ? '+' : ''}{projectedDiff.toLocaleString()}, {projectedPct >= 0 ? '+' : ''}{projectedPct.toFixed(1)}%) + +
+
+ ) + })()} + + {/* Chart */} + {isLoading ? ( +
Loading forecast...
+ ) : chartData.length > 0 ? ( +
+ +
+ ) : null} + + {/* Table Toggle */} + + + {/* Daily Table */} + {showTable && consolidation === 'daily' && forecastData?.data && forecastData.data.length > 0 && ( +
+ + + + + + + + + + + + + {forecastData.data.map((row) => { + const otb = row.current_otb || 0 + const pickup = row.pickup_rooms_total || 0 + const forecast = row.forecast || (otb + pickup) + const priorFinal = row.prior_year_final || 0 + const diff = forecast - priorFinal + const shortDay = row.day_of_week?.substring(0, 3) || '' + return ( + + + + + + + + + ) + })} + +
DateOTBPickupForecastPrior Yearvs Prior
{row.date} {shortDay}{otb}{pickup}{forecast}{priorFinal}= 0 ? '#16a34a' : '#dc2626' }}> + {diff >= 0 ? '+' : ''}{diff} +
+
+ )} + + {/* Weekly/Monthly Table */} + {showTable && consolidation !== 'daily' && consolidatedData && consolidatedData.length > 0 && ( +
+ + + + + + + + + + + + + + + {consolidatedData.map((row) => ( + + + + + + + + + + + ))} + +
{viewLabel}DaysActualFuture OTBForecastProjectedPrior Yearvs Prior
{row.label} + {row.pastDays > 0 && row.futureDays > 0 ? `${row.pastDays}+${row.futureDays}` : row.days} + 0 ? 600 : 400 }}> + {row.actualRooms > 0 ? row.actualRooms : '-'} + + {row.futureDays > 0 ? row.futureOtbRooms : '-'} + + {row.forecastRemainingRooms > 0 ? row.forecastRemainingRooms : '-'} + {row.projected}{row.priorYearFinal}= 0 ? '#16a34a' : '#dc2626' }}> + {row.variance >= 0 ? '+' : ''}{row.variancePct.toFixed(1)}% +
+
+ )} +
+ ) +} + +// ============================================ +// PICKUP-V2 PREVIEW COMPONENT (Revenue Forecasting) +// ============================================ + +// Pickup-V2 specific colors +const PICKUP_V2_COLORS = { + forecast: '#ef4444', // Red - main forecast line + confidenceFill: 'rgba(239, 68, 68, 0.15)', // Light red - confidence band fill + upperBound: 'rgba(239, 68, 68, 0.5)', // Medium red - upper bound + lowerBound: 'rgba(239, 68, 68, 0.5)', // Medium red - lower bound + currentOtb: '#10b981', // Green - current OTB + priorFinal: '#6b7280', // Gray - prior year final + priorOtb: '#9ca3af', // Light gray - prior year OTB +} + +const PickupV2Preview: React.FC = () => { + + // Default to next 30 days + const today = new Date() + const defaultStart = new Date(today) + defaultStart.setDate(today.getDate() + 1) + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() + 30) + + const [startDate, setStartDate] = useState(defaultStart.toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(defaultEnd.toISOString().split('T')[0]) + const [metric, setMetric] = useState<'net_accom' | 'rooms' | 'occupancy'>('net_accom') + const [showTable, setShowTable] = useState(false) + + // Generate month options + const monthOptions = useMemo(() => getNext12Months(), []) + + // Quick select handlers + const handleQuickSelect = (days: number) => { + const start = new Date() + start.setDate(start.getDate() + 1) + const end = new Date() + end.setDate(end.getDate() + days) + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Fetch Pickup-V2 forecast from backend + const { data: pickupV2Data, isLoading } = useQuery({ + queryKey: ['pickup-v2-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/pickup-v2-preview', { params: { start_date: startDate, end_date: endDate, metric, include_details: 'true' } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch room categories for category name lookup + const { data: roomCategories } = useQuery<{ site_id: string; site_name: string }[]>({ + queryKey: ['room-categories'], + queryFn: async () => { + try { + const { data } = await api.get('/config/room-categories') + return data + } catch { + return [] + } + }, + enabled: true, + staleTime: 5 * 60 * 1000, + }) + + // Build category name map + const categoryNameMap = useMemo(() => { + const map: Record = {} + if (roomCategories) { + for (const cat of roomCategories) { + map[cat.site_id] = cat.site_name + } + } + return map + }, [roomCategories]) + + const isRevenueMetric = metric === 'net_accom' + + const metricLabel = { + net_accom: 'Net Accomm Rev', + rooms: 'Room Nights', + occupancy: 'Occupancy %', + }[metric] || 'Value' + + const unit = { + net_accom: '', + rooms: ' rooms', + occupancy: '%', + }[metric] || '' + + // Build chart data with confidence bands + const chartData = useMemo(() => { + if (!pickupV2Data?.data) return [] + + const dates = pickupV2Data.data.map((d) => d.date) + const forecasts = pickupV2Data.data.map((d) => d.forecast) + const upperBounds = pickupV2Data.data.map((d) => d.upper_bound) + const lowerBounds = pickupV2Data.data.map((d) => d.lower_bound) + const currentOtb = isRevenueMetric + ? pickupV2Data.data.map((d) => d.current_otb_rev) + : pickupV2Data.data.map((d) => d.current_otb) + const priorFinal = isRevenueMetric + ? pickupV2Data.data.map((d) => d.prior_year_final_rev) + : pickupV2Data.data.map((d) => d.prior_year_final) + const priorOtb = isRevenueMetric + ? pickupV2Data.data.map((d) => d.prior_year_otb_rev) + : pickupV2Data.data.map((d) => d.prior_year_otb) + + // Calculate prior year dates for hover + const priorDates = pickupV2Data.data.map((d) => d.prior_year_date) + + const traces: any[] = [] + + // Prior year final (bottom layer, filled) + traces.push({ + x: dates, + y: priorFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + fill: 'tozeroy' as const, + fillcolor: 'rgba(107, 114, 128, 0.1)', + line: { color: PICKUP_V2_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + customdata: priorDates, + hovertemplate: isRevenueMetric + ? `Prior Final (%{customdata}): £%{y:,.0f}` + : `Prior Final (%{customdata}): %{y:.1f}${unit}`, + }) + + // Prior year OTB + traces.push({ + x: dates, + y: priorOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: PICKUP_V2_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: isRevenueMetric + ? `Prior OTB (%{customdata}): £%{y:,.0f}` + : `Prior OTB (%{customdata}): %{y:.1f}${unit}`, + }) + + // Confidence band (only for revenue metrics) + if (isRevenueMetric && upperBounds[0] !== null && lowerBounds[0] !== null) { + // Upper bound (invisible line for fill reference) + traces.push({ + x: dates, + y: upperBounds, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Upper Bound', + line: { color: PICKUP_V2_COLORS.upperBound, width: 1, dash: 'dot' as const }, + hovertemplate: `Upper: £%{y:,.0f}`, + }) + + // Lower bound with fill to upper + traces.push({ + x: dates, + y: lowerBounds, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Lower Bound', + fill: 'tonexty' as const, + fillcolor: PICKUP_V2_COLORS.confidenceFill, + line: { color: PICKUP_V2_COLORS.lowerBound, width: 1, dash: 'dot' as const }, + hovertemplate: `Lower: £%{y:,.0f}`, + }) + } + + // Current OTB + traces.push({ + x: dates, + y: currentOtb, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current OTB', + line: { color: PICKUP_V2_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: isRevenueMetric + ? `Current OTB: £%{y:,.0f}` + : `Current OTB: %{y:.1f}${unit}`, + }) + + // Main forecast line + traces.push({ + x: dates, + y: forecasts, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Pickup-V2 Forecast', + line: { color: PICKUP_V2_COLORS.forecast, width: 3 }, + marker: { size: 8 }, + hovertemplate: isRevenueMetric + ? `Forecast: £%{y:,.0f}` + : `Forecast: %{y:.1f}${unit}`, + }) + + return traces + }, [pickupV2Data, isRevenueMetric, unit]) + + // ADR Position indicator (0-1 scale: 0 = discounting heavily, 1 = premium pricing) + const avgAdrPosition = pickupV2Data?.summary?.avg_adr_position ?? 0.5 + const adrPositionLabel = avgAdrPosition < 0.33 ? 'Discounting' : avgAdrPosition > 0.67 ? 'Premium' : 'Balanced' + const adrPositionColor = avgAdrPosition < 0.33 ? '#ef4444' : avgAdrPosition > 0.67 ? '#10b981' : '#f59e0b' + + return ( +
+
+
+

Pickup-V2 Revenue Forecast

+

+ Additive pickup methodology for revenue forecasting with confidence bands. + {isRevenueMetric && ' Upper/lower bounds based on current vs. minimum historical rates.'} +

+
+
+ + {/* Controls */} +
+ {/* Date Range */} +
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ + {/* Quick Select */} +
+ +
+ + + + +
+
+ + {/* Month Selector */} +
+ + +
+ + {/* Metric Selector */} +
+ + +
+
+ + {/* Summary Cards */} + {pickupV2Data && ( +
+
+
Current OTB
+
+ {isRevenueMetric + ? `£${(pickupV2Data.summary.otb_rev_total || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : pickupV2Data.summary.forecast_total.toFixed(1)} +
+
+
+
Forecast Total
+
+ {isRevenueMetric + ? `£${pickupV2Data.summary.forecast_total.toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : pickupV2Data.summary.forecast_total.toFixed(1)} +
+
+ {isRevenueMetric && pickupV2Data.summary.upper_total && ( +
+
Upper Bound
+
+ £{pickupV2Data.summary.upper_total.toLocaleString(undefined, { maximumFractionDigits: 0 })} +
+
+ )} + {isRevenueMetric && pickupV2Data.summary.lower_total && ( +
+
Lower Bound
+
+ £{pickupV2Data.summary.lower_total.toLocaleString(undefined, { maximumFractionDigits: 0 })} +
+
+ )} +
+
Pace vs Prior
+
= 0 ? '#10b981' : '#ef4444' + }}> + {(pickupV2Data.summary.avg_pace_pct || 0) >= 0 ? '+' : ''} + {(pickupV2Data.summary.avg_pace_pct || 0).toFixed(1)}% +
+
+ {isRevenueMetric && ( +
+
ADR Position
+
+ {(avgAdrPosition * 100).toFixed(0)}% ({adrPositionLabel}) +
+
+ )} + {/* Rate Comparison Summary - Always visible for revenue metrics */} + {isRevenueMetric && ( +
0 ? '#fef3c7' : '#d1fae5', + borderColor: (pickupV2Data.summary.lost_potential_total || 0) > 0 ? '#f59e0b' : '#10b981', + borderWidth: 2, + }}> + {(pickupV2Data.summary.lost_potential_total || 0) > 0 ? ( + <> +
Lost Potential
+
+ £{(pickupV2Data.summary.lost_potential_total || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })} +
+
+ {pickupV2Data.summary.opportunity_days_count} days below prior year rates +
+ + ) : ( + <> +
Rate Position
+
+ On Track +
+
+ Current rates matching or beating prior year +
+ + )} +
+ )} +
+ )} + + {/* Chart */} + {isLoading ? ( +
Loading forecast...
+ ) : chartData.length > 0 ? ( + + ) : ( +
No data available for selected range
+ )} + + {/* Table Toggle */} +
+ +
+ + {/* Data Table */} + {showTable && pickupV2Data?.data && ( +
+ + + + + {isRevenueMetric && } + {isRevenueMetric && } + {isRevenueMetric && } + + {isRevenueMetric && } + + + + + + {isRevenueMetric && } + {isRevenueMetric && } + {isRevenueMetric && } + {isRevenueMetric && } + + + + {pickupV2Data.data.map((row) => { + const hasOpportunity = row.has_pricing_opportunity === true + const rowStyle = hasOpportunity ? { backgroundColor: '#fef9e7' } : {} + const pickupRooms = row.pickup_rooms_total || 0 + const otbRooms = row.current_otb || 0 + const forecastRooms = otbRooms + pickupRooms + const priorOtbRev = row.prior_year_otb_rev || 0 + const priorFinalRev = row.prior_year_final_rev || 0 + const otbPacePct = priorOtbRev > 0 ? ((row.current_otb_rev || 0) - priorOtbRev) / priorOtbRev * 100 : 0 + const finalPacePct = priorFinalRev > 0 ? (row.forecast - priorFinalRev) / priorFinalRev * 100 : 0 + + // Build tooltip for category breakdown with category names + const categoryBreakdown = row.category_breakdown || {} + const pickupTooltip = Object.entries(categoryBreakdown) + .filter(([_, data]: [string, any]) => data.pickup_rooms > 0) + .map(([catId, data]: [string, any]) => { + const catName = categoryNameMap[catId] || `Category ${catId}` + return `${catName}: ${data.pickup_rooms} rooms` + }) + .join('\n') || 'No pickup expected' + + const pickupCalcTooltip = Object.entries(categoryBreakdown) + .filter(([_, data]: [string, any]) => data.pickup_rooms > 0) + .map(([catId, data]: [string, any]) => { + const catName = categoryNameMap[catId] || `Category ${catId}` + const netRate = data.prior_avg_rate?.toFixed(0) || '0' + const grossRate = data.prior_avg_rate_gross?.toFixed(0) || '0' + const pickupRev = data.forecast_pickup_rev?.toFixed(0) || '0' + return `${catName}: ${data.pickup_rooms} × £${netRate} (£${grossRate}) = £${pickupRev}` + }) + .join('\n') || 'No pickup expected for this date' + + return ( + + + {isRevenueMetric && } + {isRevenueMetric && } + {isRevenueMetric && } + + {isRevenueMetric && ( + + )} + + + + + + {isRevenueMetric && ( + + )} + {isRevenueMetric && ( + + )} + {isRevenueMetric && ( + + )} + {isRevenueMetric && ( + + )} + + ) + })} + +
DateOTB RmsPickupFcst RmsOTB RevPickup CalcForecastLY OTBPaceLY Finalvs FinalCurr RateLY RateLost £Rate Gap
{row.date} {row.day_of_week}{Math.round(otbRooms)}{pickupRooms}{Math.round(forecastRooms)} + {isRevenueMetric + ? `£${(row.current_otb_rev || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : (row.current_otb || 0).toFixed(1)} + + {pickupRooms} × £{row.effective_rate?.toFixed(0) || row.weighted_avg_prior_rate?.toFixed(0) || '0'} (£{row.effective_rate_gross?.toFixed(0) || row.weighted_avg_prior_rate_gross?.toFixed(0) || '0'}) + {row.effective_rate && row.weighted_avg_prior_rate && row.effective_rate < row.weighted_avg_prior_rate && ( + * + )} + + {isRevenueMetric + ? `£${row.forecast.toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : row.forecast.toFixed(1)} + + £{priorOtbRev.toLocaleString(undefined, { maximumFractionDigits: 0 })} + = 0 ? '#10b981' : '#ef4444' + }}> + {otbPacePct >= 0 ? '+' : ''}{otbPacePct.toFixed(0)}% + + £{priorFinalRev.toLocaleString(undefined, { maximumFractionDigits: 0 })} + = 0 ? '#10b981' : '#ef4444' + }}> + {finalPacePct >= 0 ? '+' : ''}{finalPacePct.toFixed(0)}% + + {row.weighted_avg_current_rate + ? <>£{row.weighted_avg_current_rate.toFixed(0)} (£{row.weighted_avg_current_rate_gross?.toFixed(0) || '-'}) + : '-'} + + {row.weighted_avg_listed_rate + ? <>£{row.weighted_avg_listed_rate.toFixed(0)} (£{row.weighted_avg_listed_rate_gross?.toFixed(0) || '-'}) + : '-'} + + {(row.lost_potential || 0) > 0 + ? `£${(row.lost_potential || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}` + : '-'} + + {row.rate_vs_prior_pct !== null + ? `${(row.rate_vs_prior_pct || 0) >= 0 ? '+' : ''}${(row.rate_vs_prior_pct || 0).toFixed(1)}%` + : '-'} +
+
+ )} +
+ ) +} + +// ============================================ +// COMPARE FORECASTS COMPONENT +// ============================================ + +const CompareForecasts: React.FC = () => { + + // Default to next 30 days + const today = new Date() + const defaultStart = new Date(today) + defaultStart.setDate(today.getDate() + 1) + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() + 30) + + const [startDate, setStartDate] = useState(defaultStart.toISOString().split('T')[0]) + const [endDate, setEndDate] = useState(defaultEnd.toISOString().split('T')[0]) + const [metric, setMetric] = useState('rooms') + const [showTable, setShowTable] = useState(false) + + // Generate month options + const monthOptions = useMemo(() => getNext12Months(), []) + + // Quick select handlers + const handleQuickSelect = (days: number) => { + const start = new Date() + start.setDate(start.getDate() + 1) + const end = new Date() + end.setDate(end.getDate() + days) + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Fetch all three forecasts in parallel + const { data: pickupData, isLoading: pickupLoading } = useQuery({ + queryKey: ['forecast-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + const { data: prophetData, isLoading: prophetLoading } = useQuery({ + queryKey: ['prophet-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/prophet-preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + const { data: xgboostData, isLoading: xgboostLoading } = useQuery({ + queryKey: ['xgboost-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/xgboost-preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + const { data: catboostData, isLoading: catboostLoading } = useQuery({ + queryKey: ['catboost-preview', startDate, endDate, metric], + queryFn: async () => { + const response = await api.get('/forecast/catboost-preview', { params: { start_date: startDate, end_date: endDate, metric } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch budget data for revenue metrics + const budgetData = useBudgetData(startDate, endDate, metric) + + // Calculate blended forecast client-side (temporarily 100% model average) + const blendedCalc = useMemo(() => { + if (!prophetData?.data || !xgboostData?.data || !catboostData?.data) return null + + return prophetData.data.map((prophetRow, idx) => { + const xgboostRow = xgboostData.data[idx] + const catboostRow = catboostData.data[idx] + + const prophetFc = prophetRow.forecast ?? 0 + const xgboostFc = xgboostRow?.forecast ?? 0 + const catboostFc = catboostRow?.forecast ?? 0 + + // Equal weight average of the three models (1/3 each) + const modelAvg = (prophetFc + xgboostFc + catboostFc) / 3 + + // Temporarily using 100% model average (no budget/prior year weighting) + let blendedForecast = modelAvg + + // Floor cap: forecast can't be below current OTB (confirmed bookings) + const currentOtb = prophetRow.current_otb ?? 0 + if (currentOtb > 0 && blendedForecast < currentOtb) { + blendedForecast = currentOtb + } + + return { + date: prophetRow.date, + blended_forecast: blendedForecast, + } + }) + }, [prophetData, xgboostData, catboostData, budgetData, metric]) + + const isLoading = pickupLoading || prophetLoading || xgboostLoading || catboostLoading + const metricLabel = { + occupancy: 'Occupancy %', + rooms: 'Room Nights', + guests: 'Guests', + ave_guest_rate: 'Ave Guest Rate', + arr: 'ARR', + net_accom: 'Net Accomm Rev', + net_dry: 'Net Dry Rev', + net_wet: 'Net Wet Rev', + total_rev: 'Total Net Rev', + }[metric] || 'Value' + const unit = { + occupancy: '%', + rooms: ' rooms', + guests: ' guests', + ave_guest_rate: '', + arr: '', + net_accom: '', + net_dry: '', + net_wet: '', + total_rev: '', + }[metric] || '' + + // Check if metric supports pace data (pickup, OTB) + const isPaceMetric = metric === 'occupancy' || metric === 'rooms' + + // Merge all data into comparison chart + const compareChartData = useMemo(() => { + // For pace metrics, require pickup data; for others, only need prophet/xgboost/catboost + if (isPaceMetric) { + if (!pickupData?.data || !prophetData?.data || !xgboostData?.data || !catboostData?.data) return [] + } else { + if (!prophetData?.data || !xgboostData?.data || !catboostData?.data) return [] + } + + // Use pickup data for dates if available, otherwise use prophet + const primaryData = isPaceMetric && pickupData?.data ? pickupData.data : prophetData.data + const dates = primaryData.map((d) => d.date) + + const prophetForecast = prophetData.data.map((d) => d.forecast) + const xgboostForecast = xgboostData.data.map((d) => d.forecast) + const catboostForecast = catboostData.data.map((d) => d.forecast) + + // Calculate prior year dates + const priorDates = primaryData.map((d) => { + const date = new Date(d.date) + date.setDate(date.getDate() - 364) + return formatDate(date) + }) + + const traces: any[] = [] + + // Only include pace-related traces for occupancy/rooms + if (isPaceMetric && pickupData?.data) { + const currentOtb = pickupData.data.map((d) => d.current_otb) + const priorYearOtb = pickupData.data.map((d) => d.prior_year_otb) + const priorYearFinal = pickupData.data.map((d) => d.prior_year_final) + const pickupForecast = pickupData.data.map((d) => d.forecast) + + traces.push( + // Prior year final fill (bottom layer) + { + x: dates, + y: priorYearFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + customdata: priorDates, + hovertemplate: `Prior Final: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + // Prior year OTB + { + x: dates, + y: priorYearOtb, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year OTB', + line: { color: CHART_COLORS.priorOtb, width: 2, dash: 'dash' as const }, + customdata: priorDates, + hovertemplate: `Prior OTB: %{y:.1f}${unit}
Comparing: %{customdata}`, + }, + // Current OTB - green + { + x: dates, + y: currentOtb, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current OTB', + line: { color: CHART_COLORS.currentOtb, width: 2 }, + marker: { size: 6 }, + hovertemplate: `Current OTB: %{y:.1f}${unit}`, + }, + // Pickup forecast - red + { + x: dates, + y: pickupForecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Pickup', + line: { color: CHART_COLORS.pickup, width: 2 }, + marker: { size: 6, symbol: 'circle' }, + hovertemplate: `Pickup: %{y:.1f}${unit}`, + } + ) + } else { + // For non-pace metrics, add prior year final from XGBoost data (no OTB/pace available) + const priorYearFinal = xgboostData.data.map((d) => d.prior_year_final) + + traces.push( + // Prior year final fill (bottom layer) + { + x: dates, + y: priorYearFinal, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Prior Year Final', + line: { color: CHART_COLORS.priorFinal, width: 1, dash: 'dot' as const }, + fill: 'tozeroy' as const, + fillcolor: CHART_COLORS.priorFinalFill, + customdata: priorDates, + hovertemplate: `Prior Final: %{y:.1f}${unit}
Comparing: %{customdata}`, + } + ) + } + + // Always include ML model traces + traces.push( + // Prophet forecast - blue + { + x: dates, + y: prophetForecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prophet', + line: { color: CHART_COLORS.prophet, width: 2 }, + marker: { size: 6, symbol: 'square' }, + hovertemplate: `Prophet: %{y:.1f}${unit}`, + }, + // XGBoost forecast - orange + { + x: dates, + y: xgboostForecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'XGBoost', + line: { color: CHART_COLORS.xgboost, width: 2 }, + marker: { size: 6, symbol: 'diamond' }, + hovertemplate: `XGBoost: %{y:.1f}${unit}`, + }, + // CatBoost forecast - purple + { + x: dates, + y: catboostForecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'CatBoost', + line: { color: CHART_COLORS.catboost, width: 2 }, + marker: { size: 6, symbol: 'triangle-up' }, + hovertemplate: `CatBoost: %{y:.1f}${unit}`, + } + ) + + // Add blended forecast trace if available (calculated client-side) + if (blendedCalc) { + const blendedForecast = blendedCalc.map((d) => d.blended_forecast) + traces.push({ + x: dates, + y: blendedForecast, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Blended', + line: { color: CHART_COLORS.blended, width: 3 }, + marker: { size: 8, symbol: 'star' }, + hovertemplate: `Blended: %{y:.1f}${unit}`, + }) + } + + // Add budget trace if available + const budgetTrace = buildBudgetTrace(budgetData) + if (budgetTrace) { + traces.push(budgetTrace) + } + + return traces + }, [pickupData, prophetData, xgboostData, catboostData, blendedCalc, unit, isPaceMetric, budgetData]) + + // Merge data for table + const tableData = useMemo(() => { + // For pace metrics, require pickup; for others, just need ML models + if (isPaceMetric) { + if (!pickupData?.data || !prophetData?.data || !xgboostData?.data || !catboostData?.data) return [] + } else { + if (!prophetData?.data || !xgboostData?.data || !catboostData?.data) return [] + } + + // Use pickup data as primary if available, otherwise use prophet + const primaryData = isPaceMetric && pickupData?.data ? pickupData.data : prophetData.data + + return primaryData.map((row, idx) => { + const pickup = isPaceMetric && pickupData?.data ? pickupData.data[idx] : null + // For non-pace metrics, get prior_year_final from xgboost data + const priorYearFinal = pickup?.prior_year_final ?? xgboostData.data[idx]?.prior_year_final ?? null + return { + date: row.date, + day_of_week: row.day_of_week, + current_otb: pickup?.current_otb ?? null, + prior_year_otb: pickup?.prior_year_otb ?? null, + prior_year_final: priorYearFinal, + pickup_forecast: pickup?.forecast ?? null, + prophet_forecast: prophetData.data[idx]?.forecast ?? null, + xgboost_forecast: xgboostData.data[idx]?.forecast ?? null, + catboost_forecast: catboostData.data[idx]?.forecast ?? null, + blended_forecast: blendedCalc?.[idx]?.blended_forecast ?? null, + } + }) + }, [pickupData, prophetData, xgboostData, catboostData, blendedCalc, isPaceMetric]) + + return ( +
+
+
+

Compare Forecast Models

+

+ {isPaceMetric + ? 'Side-by-side comparison of Pickup, Prophet, XGBoost, CatBoost, and Blended forecasts' + : 'Side-by-side comparison of Prophet, XGBoost, CatBoost, and Blended forecasts (Pickup not available for this metric)'} +

+
+
+ + {/* Controls */} +
+ {/* Date Range */} +
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ + {/* Metric Dropdown */} +
+ + +
+ + {/* Quick Selects */} +
+ +
+ {[7, 14, 30, 60, 90].map((days) => ( + + ))} +
+ +
+
+ + {/* Summary Stats - show averages for each model */} + {prophetData?.summary && xgboostData?.summary && catboostData?.summary && (() => { + // Use pickup for days_count if available, otherwise prophet + const days = isPaceMetric && pickupData?.summary ? pickupData.summary.days_count : prophetData.summary.days_count + const prophetAvg = metric === 'occupancy' ? prophetData.summary.forecast_total / days : prophetData.summary.forecast_total + const xgboostAvg = metric === 'occupancy' ? xgboostData.summary.forecast_total / days : xgboostData.summary.forecast_total + const catboostAvg = metric === 'occupancy' ? catboostData.summary.forecast_total / days : catboostData.summary.forecast_total + + // Only calculate pickup/prior stats for pace metrics + const pickupAvg = isPaceMetric && pickupData?.summary + ? (metric === 'occupancy' ? pickupData.summary.forecast_total / days : pickupData.summary.forecast_total) + : null + const priorFinalAvg = isPaceMetric && pickupData?.summary + ? (metric === 'occupancy' ? pickupData.summary.prior_final_total / days : pickupData.summary.prior_final_total) + : null + + return ( +
+ {/* Only show Pickup card for pace metrics */} + {isPaceMetric && pickupAvg !== null && priorFinalAvg !== null && ( +
+ PICKUP + + {metric === 'occupancy' ? `${pickupAvg.toFixed(1)}%` : pickupAvg.toFixed(0)} + + = priorFinalAvg ? '#16a34a' : '#dc2626', + }}> + vs Prior: {pickupAvg >= priorFinalAvg ? '+' : ''}{metric === 'occupancy' ? `${(pickupAvg - priorFinalAvg).toFixed(1)}%` : (pickupAvg - priorFinalAvg).toFixed(0)} + +
+ )} +
+ PROPHET + + {metric === 'occupancy' ? `${prophetAvg.toFixed(1)}%` : prophetAvg.toFixed(0)} + + {isPaceMetric && priorFinalAvg !== null ? ( + = priorFinalAvg ? '#16a34a' : '#dc2626', + }}> + vs Prior: {prophetAvg >= priorFinalAvg ? '+' : ''}{metric === 'occupancy' ? `${(prophetAvg - priorFinalAvg).toFixed(1)}%` : (prophetAvg - priorFinalAvg).toFixed(0)} + + ) : ( + {days} days total + )} +
+
+ XGBOOST + + {metric === 'occupancy' ? `${xgboostAvg.toFixed(1)}%` : xgboostAvg.toFixed(0)} + + {isPaceMetric && priorFinalAvg !== null ? ( + = priorFinalAvg ? '#16a34a' : '#dc2626', + }}> + vs Prior: {xgboostAvg >= priorFinalAvg ? '+' : ''}{metric === 'occupancy' ? `${(xgboostAvg - priorFinalAvg).toFixed(1)}%` : (xgboostAvg - priorFinalAvg).toFixed(0)} + + ) : ( + {days} days total + )} +
+
+ CATBOOST + + {metric === 'occupancy' ? `${catboostAvg.toFixed(1)}%` : catboostAvg.toFixed(0)} + + {isPaceMetric && priorFinalAvg !== null ? ( + = priorFinalAvg ? '#16a34a' : '#dc2626', + }}> + vs Prior: {catboostAvg >= priorFinalAvg ? '+' : ''}{metric === 'occupancy' ? `${(catboostAvg - priorFinalAvg).toFixed(1)}%` : (catboostAvg - priorFinalAvg).toFixed(0)} + + ) : ( + {days} days total + )} +
+ {/* Only show Prior Year card for pace metrics */} + {isPaceMetric && priorFinalAvg !== null && ( +
+ PRIOR YR FINAL + + {metric === 'occupancy' ? `${priorFinalAvg.toFixed(1)}%` : priorFinalAvg.toFixed(0)} + + baseline comparison +
+ )} +
+ ) + })()} + + {/* Comparison Chart */} + {isLoading ? ( +
Loading all forecast models...
+ ) : compareChartData.length > 0 ? ( +
+ +
+ ) : ( +
+ No forecast data available for comparison. +
+ )} + + {/* Data Table */} + {tableData.length > 0 && ( + <> + + + {showTable && ( +
+ + + + + + {isPaceMetric && } + {isPaceMetric && } + + {isPaceMetric && } + + + + + + + + {tableData.map((row, idx) => ( + + + + {isPaceMetric && ( + + )} + {isPaceMetric && ( + + )} + + {isPaceMetric && ( + + )} + + + + + + ))} + +
DateDOWOTBPrior OTBPrior FinalPickupProphetXGBoostCatBoostBlended
{row.date}{row.day_of_week} + {row.current_otb !== null ? row.current_otb.toFixed(1) : '-'} + + {row.prior_year_otb !== null ? row.prior_year_otb.toFixed(1) : '-'} + + {row.prior_year_final !== null ? row.prior_year_final.toFixed(1) : '-'} + + {row.pickup_forecast !== null ? row.pickup_forecast.toFixed(1) : '-'} + + {row.prophet_forecast !== null ? row.prophet_forecast.toFixed(1) : '-'} + + {row.xgboost_forecast !== null ? row.xgboost_forecast.toFixed(1) : '-'} + + {row.catboost_forecast !== null ? row.catboost_forecast.toFixed(1) : '-'} + + {row.blended_forecast !== null ? row.blended_forecast.toFixed(1) : '-'} +
+
+ )} + + )} +
+ ) +} + +// ============================================ +// RESTAURANT COVERS FORECAST COMPONENT +// ============================================ + +interface CoversDataPoint { + date: string + day_of_week: string + lead_days: number + prior_year_date: string + breakfast_otb: number + breakfast_pickup: number + breakfast_forecast: number + breakfast_prior: number + breakfast_hotel_guests_otb: number + breakfast_hotel_guests_prior: number + breakfast_calc: { + night_before: string + hotel_rooms_otb?: number + hotel_guests_otb?: number + pickup_rooms?: number + guests_per_room?: number + hotel_guests_prior?: number + source: string + } | null + lunch_otb: number + lunch_pickup: number + lunch_forecast: number + lunch_prior: number + lunch_calc: { + day_of_week: string + lead_days: number + pace_column: string + lookback_weeks: number + median_pickup: number + source: string + } | null + dinner_otb: number + dinner_resident_otb: number + dinner_non_resident_otb: number + dinner_resident_pickup: number + dinner_non_resident_pickup: number + dinner_forecast: number + dinner_prior: number + dinner_resident_calc: { + hotel_guests_otb: number + pickup_rooms: number + guests_per_room: number + pickup_guests: number + forecasted_guests: number + dining_rate: number + forecasted_resident_covers: number + resident_otb: number + source: string + } | null + dinner_non_resident_calc: { + day_of_week: string + lead_days: number + pace_column: string + lookback_weeks: number + median_pickup: number + source: string + } | null + total_otb: number + total_forecast: number + total_prior: number + pace_vs_prior_pct: number | null + hotel_occupancy_pct: number + hotel_rooms: number +} + +interface CoversSummary { + breakfast_otb: number + breakfast_forecast: number + breakfast_prior: number + lunch_otb: number + lunch_forecast: number + lunch_prior: number + dinner_otb: number + dinner_forecast: number + dinner_prior: number + total_otb: number + total_forecast: number + total_prior: number + days_count: number +} + +interface CoversResponse { + data: CoversDataPoint[] + summary: CoversSummary +} + +// Colors for stacked segments +const COVERS_COLORS = { + breakfast: '#f59e0b', // Amber - breakfast + lunchResident: '#10b981', // Green - lunch resident + lunchNonResident: '#06b6d4', // Cyan - lunch non-resident + lunchPickup: '#a5f3fc', // Light cyan - lunch pickup + dinnerResident: '#8b5cf6', // Purple - dinner resident + dinnerNonResident: '#ef4444', // Red - dinner non-resident + dinnerResidentPickup: '#c4b5fd',// Light purple - dinner resident pickup + dinnerNonResidentPickup: '#fca5a5', // Light red - dinner non-resident pickup + priorYear: '#9ca3af', // Gray - prior year line +} + +interface RestaurantCoversForecastProps { + consolidation: 'daily' | 'weekly' | 'monthly' +} + +const RestaurantCoversForecast: React.FC = ({ consolidation }) => { + + // Helper: get Monday of the week containing a date + const getMondayOfWeek = (date: Date): Date => { + const d = new Date(date) + const day = d.getDay() + const diff = day === 0 ? -6 : 1 - day + d.setDate(d.getDate() + diff) + d.setHours(0, 0, 0, 0) + return d + } + + // Helper: get ISO week number + const getISOWeekNumber = (date: Date): number => { + const d = new Date(date) + d.setHours(0, 0, 0, 0) + d.setDate(d.getDate() + 4 - (d.getDay() || 7)) + const yearStart = new Date(d.getFullYear(), 0, 1) + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + } + + // Helper: get financial year start (August) + const getFinancialYearStart = (date: Date): string => { + const year = date.getFullYear() + const month = date.getMonth() + const fyStartYear = month >= 7 ? year : year - 1 + return `${fyStartYear}-08` + } + + const today = new Date() + const currentMonday = getMondayOfWeek(today) + + const [selectedMonth, setSelectedMonth] = useState(() => { + if (consolidation === 'monthly') { + return getFinancialYearStart(today) + } + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` + }) + const [selectedWeek, setSelectedWeek] = useState(() => formatDate(currentMonday)) + const [duration, setDuration] = useState<'1' | '3' | '6' | '12'>( + consolidation === 'monthly' ? '12' : consolidation === 'daily' ? '1' : '3' + ) + const [weekDuration, setWeekDuration] = useState<'4' | '8' | '13' | '26'>('13') + const [showTable, setShowTable] = useState(true) + const [useCustomDates, setUseCustomDates] = useState(false) + const [customStartDate, setCustomStartDate] = useState('') + const [customEndDate, setCustomEndDate] = useState('') + + // Generate month options + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + for (let i = -24; i <= 12; i++) { + const date = new Date(now.getFullYear(), now.getMonth() + i, 1) + const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + const label = date.toLocaleString('default', { month: 'short', year: 'numeric' }) + options.push({ value, label }) + } + return options + }, []) + + // Generate week options + const weekOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const now = new Date() + const currentMon = getMondayOfWeek(now) + + for (let i = -52; i <= 26; i++) { + const monday = new Date(currentMon) + monday.setDate(currentMon.getDate() + (i * 7)) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + + const weekNum = getISOWeekNumber(monday) + const value = formatDate(monday) + const label = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })})` + options.push({ value, label }) + } + return options + }, []) + + // Calculate start and end dates + const { startDate, endDate } = useMemo(() => { + if (useCustomDates && customStartDate && customEndDate) { + return { startDate: customStartDate, endDate: customEndDate } + } + if (consolidation === 'weekly') { + const start = new Date(selectedWeek) + const durationWeeks = parseInt(weekDuration) + const end = new Date(start) + end.setDate(start.getDate() + (durationWeeks * 7) - 1) + return { startDate: formatDate(start), endDate: formatDate(end) } + } else { + const [year, month] = selectedMonth.split('-').map(Number) + const start = new Date(year, month - 1, 1) + const durationMonths = parseInt(duration) + const end = new Date(year, month - 1 + durationMonths, 0) + return { startDate: formatDate(start), endDate: formatDate(end) } + } + }, [selectedMonth, selectedWeek, duration, weekDuration, consolidation, useCustomDates, customStartDate, customEndDate]) + + // Fetch covers forecast + const { data: forecastData, isLoading } = useQuery({ + queryKey: ['covers-forecast', startDate, endDate], + queryFn: async () => { + const response = await api.get('/forecast/covers-forecast', { params: { start_date: startDate, end_date: endDate } }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch average spend settings for revenue calculation + const { data: spendSettings } = useQuery({ + queryKey: ['resos-average-spend'], + queryFn: async () => { + try { + const { data } = await api.get('/resos/average-spend') + return data + } catch { + return null + } + }, + enabled: true, + }) + + // Fetch F&B budgets for date range + const { data: budgetData } = useQuery({ + queryKey: ['fb-budgets', startDate, endDate], + queryFn: async () => { + try { + const { data } = await api.get('/budget/daily', { params: { from_date: startDate, to_date: endDate } }) + // Extract net_dry and net_wet budgets + return data.filter((b: any) => b.budget_type === 'net_dry' || b.budget_type === 'net_wet') + } catch { + return null + } + }, + enabled: !!startDate && !!endDate, + }) + + // Consolidate data for weekly/monthly views + const consolidatedData = useMemo(() => { + if (!forecastData?.data || consolidation === 'daily') return null + + const groups: Record = {} + + forecastData.data.forEach(d => { + const dateObj = new Date(d.date) + let groupKey: string + let groupLabel: string + + if (consolidation === 'weekly') { + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + groupKey = formatDate(monday) + const weekD = new Date(monday) + weekD.setDate(weekD.getDate() + 4 - (weekD.getDay() || 7)) + const yearStart = new Date(weekD.getFullYear(), 0, 1) + const weekNum = Math.ceil((((weekD.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + groupLabel = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })})` + } else { + groupKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}` + groupLabel = dateObj.toLocaleString('default', { month: 'short', year: 'numeric' }) + } + + if (!groups[groupKey]) { + groups[groupKey] = { + label: groupLabel, + startDate: d.date, + breakfastOtb: 0, + breakfastForecast: 0, + breakfastPrior: 0, + lunchOtb: 0, + lunchPickup: 0, + lunchForecast: 0, + lunchPrior: 0, + dinnerOtb: 0, + dinnerResidentOtb: 0, + dinnerNonResidentOtb: 0, + dinnerForecast: 0, + dinnerPrior: 0, + totalOtb: 0, + totalForecast: 0, + totalPrior: 0, + days: 0 + } + } + + groups[groupKey].breakfastOtb += d.breakfast_otb + groups[groupKey].breakfastForecast += d.breakfast_forecast + groups[groupKey].breakfastPrior += d.breakfast_prior + groups[groupKey].lunchOtb += d.lunch_otb + groups[groupKey].lunchPickup += d.lunch_pickup + groups[groupKey].lunchForecast += d.lunch_forecast + groups[groupKey].lunchPrior += d.lunch_prior + groups[groupKey].dinnerOtb += d.dinner_otb + groups[groupKey].dinnerResidentOtb += d.dinner_resident_otb + groups[groupKey].dinnerNonResidentOtb += d.dinner_non_resident_otb + groups[groupKey].dinnerForecast += d.dinner_forecast + groups[groupKey].dinnerPrior += d.dinner_prior + groups[groupKey].totalOtb += d.total_otb + groups[groupKey].totalForecast += d.total_forecast + groups[groupKey].totalPrior += d.total_prior + groups[groupKey].days++ + }) + + return Object.entries(groups) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, data]) => ({ + key, + ...data, + variancePct: data.totalPrior > 0 ? ((data.totalForecast / data.totalPrior) - 1) * 100 : 0 + })) + }, [forecastData, consolidation]) + + // Build chart data - stacked bars by meal period and segment + const chartData = useMemo(() => { + if (consolidation !== 'daily' && consolidatedData) { + const labels = consolidatedData.map(d => d.label) + + // Stacked bar traces for forecast + const traces: any[] = [ + { + x: labels, + y: consolidatedData.map(d => d.breakfastForecast), + type: 'bar' as const, + name: 'Breakfast', + marker: { color: COVERS_COLORS.breakfast }, + hovertemplate: `Breakfast: %{y:,.0f} covers`, + }, + { + x: labels, + y: consolidatedData.map(d => d.lunchOtb), + type: 'bar' as const, + name: 'Lunch OTB', + marker: { color: COVERS_COLORS.lunchResident }, + hovertemplate: `Lunch OTB: %{y:,.0f} covers`, + }, + { + x: labels, + y: consolidatedData.map(d => d.lunchPickup), + type: 'bar' as const, + name: 'Lunch Pickup', + marker: { color: COVERS_COLORS.lunchPickup }, + hovertemplate: `Lunch Pickup: %{y:,.0f} covers`, + }, + { + x: labels, + y: consolidatedData.map(d => d.dinnerResidentOtb), + type: 'bar' as const, + name: 'Dinner (Resident)', + marker: { color: COVERS_COLORS.dinnerResident }, + hovertemplate: `Dinner Resident: %{y:,.0f} covers`, + }, + { + x: labels, + y: consolidatedData.map(d => d.dinnerNonResidentOtb), + type: 'bar' as const, + name: 'Dinner (Non-Res)', + marker: { color: COVERS_COLORS.dinnerNonResident }, + hovertemplate: `Dinner Non-Resident: %{y:,.0f} covers`, + }, + { + x: labels, + y: consolidatedData.map(d => Math.max(0, d.dinnerForecast - d.dinnerOtb)), + type: 'bar' as const, + name: 'Dinner Pickup', + marker: { color: COVERS_COLORS.dinnerNonResidentPickup }, + hovertemplate: `Dinner Pickup: %{y:,.0f} covers`, + }, + ] + + // Prior year line + traces.push({ + x: labels, + y: consolidatedData.map(d => d.totalPrior), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: COVERS_COLORS.priorYear, width: 2, dash: 'dot' as const }, + marker: { size: 6 }, + hovertemplate: `Prior Year: %{y:,.0f} covers`, + }) + + return { traces, labels } + } + + // Daily view + if (!forecastData?.data) return null + + // Separate past/future for different visualization + const dates = forecastData.data.map(d => d.date) + + const traces: any[] = [ + { + x: dates, + y: forecastData.data.map(d => d.breakfast_forecast), + type: 'bar' as const, + name: 'Breakfast', + marker: { color: COVERS_COLORS.breakfast }, + hovertemplate: `Breakfast: %{y:,.0f}`, + }, + { + x: dates, + y: forecastData.data.map(d => d.lunch_otb), + type: 'bar' as const, + name: 'Lunch OTB', + marker: { color: COVERS_COLORS.lunchResident }, + hovertemplate: `Lunch OTB: %{y:,.0f}`, + }, + { + x: dates, + y: forecastData.data.map(d => d.lunch_pickup), + type: 'bar' as const, + name: 'Lunch Pickup', + marker: { color: COVERS_COLORS.lunchPickup }, + hovertemplate: `Lunch Pickup: %{y:,.0f}`, + }, + { + x: dates, + y: forecastData.data.map(d => d.dinner_resident_otb), + type: 'bar' as const, + name: 'Dinner (Resident)', + marker: { color: COVERS_COLORS.dinnerResident }, + hovertemplate: `Dinner Resident: %{y:,.0f}`, + }, + { + x: dates, + y: forecastData.data.map(d => d.dinner_non_resident_otb), + type: 'bar' as const, + name: 'Dinner (Non-Res)', + marker: { color: COVERS_COLORS.dinnerNonResident }, + hovertemplate: `Dinner Non-Resident: %{y:,.0f}`, + }, + { + x: dates, + y: forecastData.data.map(d => d.dinner_resident_pickup + d.dinner_non_resident_pickup), + type: 'bar' as const, + name: 'Dinner Pickup', + marker: { color: COVERS_COLORS.dinnerNonResidentPickup }, + hovertemplate: `Dinner Pickup: %{y:,.0f}`, + }, + { + x: dates, + y: forecastData.data.map(d => d.total_prior), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: COVERS_COLORS.priorYear, width: 2, dash: 'dot' as const }, + marker: { size: 4 }, + hovertemplate: `Prior Year: %{y:,.0f}`, + }, + ] + + return { traces, labels: dates } + }, [forecastData, consolidatedData, consolidation]) + + // Calculate summary + const summary = useMemo(() => { + if (!forecastData?.summary) return null + const s = forecastData.summary + return { + breakfastForecast: s.breakfast_forecast, + breakfastPrior: s.breakfast_prior, + lunchForecast: s.lunch_forecast, + lunchPrior: s.lunch_prior, + dinnerForecast: s.dinner_forecast, + dinnerPrior: s.dinner_prior, + totalForecast: s.total_forecast, + totalPrior: s.total_prior, + totalOtb: s.total_otb, + daysCount: s.days_count + } + }, [forecastData]) + + // Calculate revenue from covers × spend per head + const revenueChartData = useMemo(() => { + if (!forecastData?.data || !spendSettings) return null + + // Convert gross spend (inc VAT) to net spend (exc VAT) - UK VAT is 20% + const VAT_RATE = 1.20 + const breakfastSpend = ((spendSettings.breakfast_food_spend || 0) + (spendSettings.breakfast_drinks_spend || 0)) / VAT_RATE + const lunchSpend = ((spendSettings.lunch_food_spend || 0) + (spendSettings.lunch_drinks_spend || 0)) / VAT_RATE + const dinnerSpend = ((spendSettings.dinner_food_spend || 0) + (spendSettings.dinner_drinks_spend || 0)) / VAT_RATE + + // Build budget lookup by date + const budgetByDate: Record = {} + if (budgetData) { + budgetData.forEach((b: any) => { + if (!budgetByDate[b.date]) budgetByDate[b.date] = { food: 0, drinks: 0 } + if (b.budget_type === 'net_dry') budgetByDate[b.date].food = b.budget_value + else if (b.budget_type === 'net_wet') budgetByDate[b.date].drinks = b.budget_value + }) + } + + if (consolidation !== 'daily' && consolidatedData) { + // For weekly/monthly view, aggregate + const labels = consolidatedData.map(d => d.label) + + // Group budget by week/month + const budgetByGroup: Record = {} + if (budgetData) { + budgetData.forEach((b: any) => { + const dateObj = new Date(b.date) + let groupKey: string + if (consolidation === 'weekly') { + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + groupKey = monday.toISOString().split('T')[0] + } else { + groupKey = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}` + } + if (!budgetByGroup[groupKey]) budgetByGroup[groupKey] = 0 + budgetByGroup[groupKey] += b.budget_value || 0 + }) + } + + const forecastRevenue = consolidatedData.map(d => + (d.breakfastForecast * breakfastSpend) + + (d.lunchForecast * lunchSpend) + + (d.dinnerForecast * dinnerSpend) + ) + + const priorRevenue = consolidatedData.map(d => + (d.breakfastPrior * breakfastSpend) + + (d.lunchPrior * lunchSpend) + + (d.dinnerPrior * dinnerSpend) + ) + + const budgetValues = consolidatedData.map(d => budgetByGroup[d.key] || 0) + + const traces: any[] = [ + { + x: labels, + y: forecastRevenue, + type: 'bar' as const, + name: 'Forecast Net Revenue', + marker: { color: 'var(--gold)' }, + hovertemplate: `Forecast: £%{y:,.0f}`, + }, + { + x: labels, + y: priorRevenue, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: COVERS_COLORS.priorYear, width: 2, dash: 'dot' as const }, + marker: { size: 6 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + // Add budget line if data exists + if (budgetValues.some(v => v > 0)) { + traces.push({ + x: labels, + y: budgetValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: '#f59e0b', width: 2 }, + marker: { size: 6 }, + hovertemplate: `Budget: £%{y:,.0f}`, + }) + } + + return { traces, labels } + } + + // Daily view + const dates = forecastData.data.map(d => d.date) + + const forecastRevenue = forecastData.data.map(d => + (d.breakfast_forecast * breakfastSpend) + + (d.lunch_forecast * lunchSpend) + + (d.dinner_forecast * dinnerSpend) + ) + + const priorRevenue = forecastData.data.map(d => + (d.breakfast_prior * breakfastSpend) + + (d.lunch_prior * lunchSpend) + + (d.dinner_prior * dinnerSpend) + ) + + const budgetValues = forecastData.data.map(d => { + const b = budgetByDate[d.date] + return b ? (b.food + b.drinks) : 0 + }) + + const traces: any[] = [ + { + x: dates, + y: forecastRevenue, + type: 'bar' as const, + name: 'Forecast Net Revenue', + marker: { color: 'var(--gold)' }, + hovertemplate: `Forecast: £%{y:,.0f}`, + }, + { + x: dates, + y: priorRevenue, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: COVERS_COLORS.priorYear, width: 2, dash: 'dot' as const }, + marker: { size: 4 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + // Add budget line if data exists + if (budgetValues.some(v => v > 0)) { + traces.push({ + x: dates, + y: budgetValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: '#f59e0b', width: 2 }, + marker: { size: 4 }, + hovertemplate: `Budget: £%{y:,.0f}`, + }) + } + + return { traces, labels: dates } + }, [forecastData, consolidatedData, consolidation, spendSettings, budgetData]) + + // Calculate revenue summary + const revenueSummary = useMemo(() => { + if (!summary || !spendSettings) return null + + // Convert gross spend (inc VAT) to net spend (exc VAT) - UK VAT is 20% + const VAT_RATE = 1.20 + const breakfastSpend = ((spendSettings.breakfast_food_spend || 0) + (spendSettings.breakfast_drinks_spend || 0)) / VAT_RATE + const lunchSpend = ((spendSettings.lunch_food_spend || 0) + (spendSettings.lunch_drinks_spend || 0)) / VAT_RATE + const dinnerSpend = ((spendSettings.dinner_food_spend || 0) + (spendSettings.dinner_drinks_spend || 0)) / VAT_RATE + + const forecastRevenue = + (summary.breakfastForecast * breakfastSpend) + + (summary.lunchForecast * lunchSpend) + + (summary.dinnerForecast * dinnerSpend) + + const priorRevenue = + (summary.breakfastPrior * breakfastSpend) + + (summary.lunchPrior * lunchSpend) + + (summary.dinnerPrior * dinnerSpend) + + // Calculate budget total + let budgetTotal = 0 + if (budgetData) { + budgetData.forEach((b: any) => { + budgetTotal += b.budget_value || 0 + }) + } + + return { + forecast: forecastRevenue, + prior: priorRevenue, + budget: budgetTotal, + varianceVsPrior: priorRevenue > 0 ? ((forecastRevenue / priorRevenue) - 1) * 100 : 0, + varianceVsBudget: budgetTotal > 0 ? ((forecastRevenue / budgetTotal) - 1) * 100 : 0, + } + }, [summary, spendSettings, budgetData]) + + // Title based on consolidation + const title = consolidation === 'daily' ? 'Restaurant Covers by Day' : + consolidation === 'weekly' ? 'Restaurant Covers by Week' : + 'Restaurant Covers by Month' + + return ( +
+

{title}

+ + {/* Controls - matching accommodation pages */} +
+
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ +
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ + {/* Custom Date Toggle */} +
+ +
+ { + setUseCustomDates(e.target.checked) + if (e.target.checked && !customStartDate) { + setCustomStartDate(startDate) + setCustomEndDate(endDate) + } + }} + /> + {useCustomDates && ( + <> + setCustomStartDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + to + setCustomEndDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + + )} +
+
+ +
+ +
+ {startDate} to {endDate} +
+
+
+ + {isLoading ? ( +
Loading covers forecast...
+ ) : !forecastData?.data?.length ? ( +
No covers data available for this period
+ ) : ( + <> + {/* Summary Cards */} + {summary && ( +
+
+ Breakfast + {summary.breakfastForecast.toLocaleString()} + + vs LY: {summary.breakfastPrior.toLocaleString()} + ({summary.breakfastPrior > 0 ? ((summary.breakfastForecast / summary.breakfastPrior - 1) * 100).toFixed(0) : 0}%) + +
+
+ Lunch + {summary.lunchForecast.toLocaleString()} + + vs LY: {summary.lunchPrior.toLocaleString()} + ({summary.lunchPrior > 0 ? ((summary.lunchForecast / summary.lunchPrior - 1) * 100).toFixed(0) : 0}%) + +
+
+ Dinner + {summary.dinnerForecast.toLocaleString()} + + vs LY: {summary.dinnerPrior.toLocaleString()} + ({summary.dinnerPrior > 0 ? ((summary.dinnerForecast / summary.dinnerPrior - 1) * 100).toFixed(0) : 0}%) + +
+
+ Total Covers + {summary.totalForecast.toLocaleString()} + + vs LY: {summary.totalPrior.toLocaleString()} + ({summary.totalPrior > 0 ? ((summary.totalForecast / summary.totalPrior - 1) * 100).toFixed(0) : 0}%) + +
+
+ )} + + {/* Covers Chart */} + {chartData && ( +
+ +
+ )} + + {/* Revenue Section */} + {revenueChartData && spendSettings && ( + <> +

Net Revenue Forecast (exc VAT)

+ + {/* Revenue Summary */} + {revenueSummary && ( +
+
+ Forecast Net Revenue + + £{revenueSummary.forecast.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + + vs LY: £{revenueSummary.prior.toLocaleString(undefined, { maximumFractionDigits: 0 })} + ({revenueSummary.varianceVsPrior >= 0 ? '+' : ''}{revenueSummary.varianceVsPrior.toFixed(0)}%) + +
+ {revenueSummary.budget > 0 && ( +
+ Budget + + £{revenueSummary.budget.toLocaleString(undefined, { maximumFractionDigits: 0 })} + + = 0 ? '#16a34a' : '#dc2626' + }}> + Forecast {revenueSummary.varianceVsBudget >= 0 ? '+' : ''}{revenueSummary.varianceVsBudget.toFixed(0)}% vs budget + +
+ )} +
+ Net Spend/Cover + + £{summary ? (revenueSummary.forecast / summary.totalForecast).toFixed(0) : '-'} + + + weighted avg (exc VAT) + +
+
+ )} + + {/* Revenue Chart */} +
+ +
+ + )} + + {/* Table Toggle */} + + + {/* Data Table */} + {showTable && consolidation === 'daily' && forecastData?.data && ( +
+ + + + + + + + + + + + + + + + + {forecastData.data.map((row) => { + const todayStr = formatDate(new Date()) + const isPast = row.date < todayStr + const isToday = row.date === todayStr + + return ( + + + + + + + + + + + + + ) + })} + +
DateDayBfastLunchDinner ResDinner Non-ResDinner TotalTotalPrior YrHotel Occ
{row.date}{row.day_of_week} + {row.lead_days > 0 && row.breakfast_pickup > 0 + ? `${row.breakfast_otb}+${row.breakfast_pickup}` + : row.breakfast_forecast} + + {row.lead_days > 0 && row.lunch_pickup > 0 + ? `${row.lunch_otb}+${row.lunch_pickup}` + : row.lunch_forecast} + + {row.lead_days > 0 && row.dinner_resident_pickup > 0 + ? `${row.dinner_resident_otb}+${row.dinner_resident_pickup}` + + (row.dinner_resident_calc ? ` (${Math.round(row.dinner_resident_calc.forecasted_guests)})` : '') + : row.dinner_resident_otb} + + {row.lead_days > 0 && row.dinner_non_resident_pickup > 0 + ? `${row.dinner_non_resident_otb}+${row.dinner_non_resident_pickup}` + : row.dinner_non_resident_otb} + + {row.dinner_forecast} + + {row.total_forecast} + + {row.total_prior} + + {row.hotel_occupancy_pct}% +
+
+ )} + + {/* Consolidated Table */} + {showTable && consolidation !== 'daily' && consolidatedData && ( +
+ + + + + + + + + + + + + + {consolidatedData.map((row) => ( + + + + + + + + + + ))} + +
{consolidation === 'weekly' ? 'Week' : 'Month'}BreakfastLunchDinnerTotalPrior YrVar %
{row.label} + {row.breakfastForecast.toLocaleString()} + + {row.lunchForecast.toLocaleString()} + + {row.dinnerForecast.toLocaleString()} + + {row.totalForecast.toLocaleString()} + + {row.totalPrior.toLocaleString()} + = 0 ? '#16a34a' : '#dc2626' + }}> + {row.variancePct >= 0 ? '+' : ''}{row.variancePct.toFixed(1)}% +
+
+ )} + + )} +
+ ) +} + +// ============================================ +// RESTAURANT REVENUE FORECAST COMPONENT +// ============================================ + +interface RestaurantRevenueForecastProps { + consolidation: 'daily' | 'weekly' | 'monthly' + revenueType: 'dry' | 'wet' +} + +const RestaurantRevenueForecast: React.FC = ({ consolidation, revenueType }) => { + + // Helper: get Monday of the week containing a date + const getMondayOfWeek = (date: Date): Date => { + const d = new Date(date) + const day = d.getDay() + const diff = day === 0 ? -6 : 1 - day + d.setDate(d.getDate() + diff) + d.setHours(0, 0, 0, 0) + return d + } + + // Helper: get financial year start (August) + const getFinancialYearStart = (date: Date): string => { + const year = date.getFullYear() + const month = date.getMonth() + const fyStartYear = month >= 7 ? year : year - 1 // Aug-Dec = same year, Jan-Jul = previous year + return `${fyStartYear}-08` + } + + // Generate month options (past 24 months + next 12 months for FY coverage) + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const today = new Date() + for (let i = -24; i <= 12; i++) { + const date = new Date(today.getFullYear(), today.getMonth() + i, 1) + options.push({ + value: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`, + label: date.toLocaleString('default', { month: 'long', year: 'numeric' }), + }) + } + return options + }, []) + + // Generate week options + const weekOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const today = new Date() + const currentMonday = getMondayOfWeek(today) + for (let i = -26; i <= 26; i++) { + const monday = new Date(currentMonday) + monday.setDate(monday.getDate() + i * 7) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + const weekD = new Date(monday) + weekD.setDate(weekD.getDate() + 4 - (weekD.getDay() || 7)) + const yearStart = new Date(weekD.getFullYear(), 0, 1) + const weekNum = Math.ceil((((weekD.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + options.push({ + value: formatDate(monday), + label: `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })})`, + }) + } + return options + }, []) + + // State for date range + const today = new Date() + const [selectedMonth, setSelectedMonth] = useState(() => { + // For monthly view, default to financial year start (August) + if (consolidation === 'monthly') { + return getFinancialYearStart(today) + } + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` + }) + const [selectedWeek, setSelectedWeek] = useState(() => formatDate(getMondayOfWeek(today))) + // Daily view defaults to 1 month, monthly view defaults to 12 months (full FY) + const [duration, setDuration] = useState<'1' | '3' | '6' | '12'>(consolidation === 'daily' ? '1' : '12') + const [weekDuration, setWeekDuration] = useState<'4' | '8' | '13' | '26'>('13') // Default 13 weeks for weekly + const [useCustomDates, setUseCustomDates] = useState(false) + const [customStartDate, setCustomStartDate] = useState('') + const [customEndDate, setCustomEndDate] = useState('') + + // Calculate date range + const { startDate, endDate } = useMemo(() => { + if (useCustomDates && customStartDate && customEndDate) { + return { startDate: customStartDate, endDate: customEndDate } + } + if (consolidation === 'weekly') { + const start = new Date(selectedWeek) + const durationWeeks = parseInt(weekDuration) + const end = new Date(start) + end.setDate(start.getDate() + (durationWeeks * 7) - 1) + return { startDate: formatDate(start), endDate: formatDate(end) } + } else { + const [year, month] = selectedMonth.split('-').map(Number) + const start = new Date(year, month - 1, 1) + const durationMonths = parseInt(duration) + const end = new Date(year, month - 1 + durationMonths, 0) + return { startDate: formatDate(start), endDate: formatDate(end) } + } + }, [selectedMonth, selectedWeek, duration, weekDuration, consolidation, useCustomDates, customStartDate, customEndDate]) + + // Fetch revenue forecast (actual for past, forecast for future) + const { data: revenueData, isLoading } = useQuery<{ + data: Array<{ + date: string + day_of_week: string + is_past: boolean + actual_revenue: number + otb_revenue: number + pickup_revenue: number + forecast_revenue: number + prior_revenue: number + }> + summary: { + actual_total: number + prior_actual_total: number + otb_total: number + pickup_total: number + forecast_remaining: number + prior_future_total: number + prior_year_total: number + projected_total: number + days_actual: number + days_forecast: number + } + }>({ + queryKey: ['revenue-forecast', startDate, endDate, revenueType], + queryFn: async () => { + const response = await api.get('/forecast/revenue-forecast', { + params: { + start_date: startDate, + end_date: endDate, + revenue_type: revenueType + } + }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch budgets for date range + const budgetType = revenueType === 'dry' ? 'net_dry' : 'net_wet' + const { data: budgetData } = useQuery({ + queryKey: ['fb-budgets-rev', startDate, endDate, budgetType], + queryFn: async () => { + try { + const { data } = await api.get('/budget/daily', { params: { from_date: startDate, to_date: endDate, budget_type: budgetType } }) + return data + } catch { + return null + } + }, + enabled: !!startDate && !!endDate, + }) + + // Build consolidated data for weekly/monthly + // Now uses actual revenue from DB for past, forecast for future + const consolidatedData = useMemo(() => { + if (!revenueData?.data || consolidation === 'daily') return null + + const groups: Record = {} + + revenueData.data.forEach(d => { + // Parse date string directly to avoid timezone issues + const [year, month, day] = d.date.split('-').map(Number) + let groupKey: string + let groupLabel: string + + if (consolidation === 'weekly') { + // Create date at noon local time to avoid timezone shifting + const dateObj = new Date(year, month - 1, day, 12, 0, 0) + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + groupKey = formatDate(monday) + const weekD = new Date(monday) + weekD.setDate(weekD.getDate() + 4 - (weekD.getDay() || 7)) + const yearStart = new Date(weekD.getFullYear(), 0, 1) + const weekNum = Math.ceil((((weekD.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + groupLabel = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })})` + } else { + // Monthly: extract year-month directly from date string + groupKey = `${year}-${String(month).padStart(2, '0')}` + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + groupLabel = `${monthNames[month - 1]} ${year}` + } + + if (!groups[groupKey]) { + groups[groupKey] = { + key: groupKey, + label: groupLabel, + actualRevenue: 0, + otbRevenue: 0, + pickupRevenue: 0, + priorRevenue: 0, + budget: 0, + } + } + + // Past dates: use actual revenue from DB + // Future dates: use OTB + pickup forecast + if (d.is_past) { + groups[groupKey].actualRevenue += d.actual_revenue + groups[groupKey].otbRevenue += d.actual_revenue // For chart display + } else { + groups[groupKey].otbRevenue += d.otb_revenue + groups[groupKey].pickupRevenue += d.pickup_revenue + } + groups[groupKey].priorRevenue += d.prior_revenue + }) + + // Add budget data + if (budgetData) { + budgetData.forEach((b: any) => { + // Parse date string directly to avoid timezone issues + const [bYear, bMonth, bDay] = b.date.split('-').map(Number) + let groupKey: string + if (consolidation === 'weekly') { + // Create date at noon local time to avoid timezone shifting + const dateObj = new Date(bYear, bMonth - 1, bDay, 12, 0, 0) + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + groupKey = formatDate(monday) + } else { + // Monthly: extract year-month directly from date string + groupKey = `${bYear}-${String(bMonth).padStart(2, '0')}` + } + if (groups[groupKey]) { + groups[groupKey].budget += b.budget_value || 0 + } + }) + } + + return Object.values(groups).sort((a, b) => a.key.localeCompare(b.key)) + }, [revenueData, consolidation, budgetData]) + + // Build chart data with stacked Actual/OTB + Pickup bars + const chartData = useMemo(() => { + if (!revenueData?.data) return null + + const otbColor = revenueType === 'dry' ? '#16a34a' : '#06b6d4' + const pickupColor = revenueType === 'dry' ? '#4ade80' : '#67e8f9' // Lighter shade for pickup + + if (consolidation !== 'daily' && consolidatedData) { + const labels = consolidatedData.map(d => d.label) + const traces: any[] = [ + { + x: labels, + y: consolidatedData.map(d => d.otbRevenue), + type: 'bar' as const, + name: 'Actual/OTB', + marker: { color: otbColor }, + hovertemplate: `Actual/OTB: £%{y:,.0f}`, + }, + { + x: labels, + y: consolidatedData.map(d => d.pickupRevenue), + type: 'bar' as const, + name: 'Pickup', + marker: { color: pickupColor }, + hovertemplate: `Pickup: £%{y:,.0f}`, + }, + { + x: labels, + y: consolidatedData.map(d => d.priorRevenue), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: COVERS_COLORS.priorYear, width: 2, dash: 'dot' as const }, + marker: { size: 6 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + if (consolidatedData.some(d => d.budget > 0)) { + traces.push({ + x: labels, + y: consolidatedData.map(d => d.budget), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: '#f59e0b', width: 2 }, + marker: { size: 6 }, + hovertemplate: `Budget: £%{y:,.0f}`, + }) + } + + return { traces, labels } + } + + // Daily view - uses revenue data directly + const dates = revenueData.data.map(d => d.date) + + // Build budget lookup + const budgetByDate: Record = {} + if (budgetData) { + budgetData.forEach((b: any) => { + const budgetDate = typeof b.date === 'string' ? b.date.split('T')[0] : formatDate(new Date(b.date)) + budgetByDate[budgetDate] = b.budget_value || 0 + }) + } + + // For past: actual_revenue, for future: otb_revenue + const otbRevenue = revenueData.data.map(d => d.is_past ? d.actual_revenue : d.otb_revenue) + const pickupRevenue = revenueData.data.map(d => d.is_past ? 0 : d.pickup_revenue) + const priorRevenue = revenueData.data.map(d => d.prior_revenue) + const budgetValues = revenueData.data.map(d => budgetByDate[d.date] || 0) + + const traces: any[] = [ + { + x: dates, + y: otbRevenue, + type: 'bar' as const, + name: 'Actual/OTB', + marker: { color: otbColor }, + hovertemplate: `Actual/OTB: £%{y:,.0f}`, + }, + { + x: dates, + y: pickupRevenue, + type: 'bar' as const, + name: 'Pickup', + marker: { color: pickupColor }, + hovertemplate: `Pickup: £%{y:,.0f}`, + }, + { + x: dates, + y: priorRevenue, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: COVERS_COLORS.priorYear, width: 2, dash: 'dot' as const }, + marker: { size: 4 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + if (budgetValues.some(v => v > 0)) { + traces.push({ + x: dates, + y: budgetValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: '#f59e0b', width: 2 }, + marker: { size: 4 }, + hovertemplate: `Budget: £%{y:,.0f}`, + }) + } + + return { traces, labels: dates } + }, [revenueData, consolidatedData, consolidation, budgetData, revenueType]) + + // Summary uses data from API - past is actual revenue, future is forecast + const summary = useMemo(() => { + if (!revenueData?.summary) return null + + const todayStr = formatDate(new Date()) + + // Budget totals - normalize date format for comparison + let totalBudget = 0 + let pastBudget = 0 + let futureBudget = 0 + if (budgetData && budgetData.length > 0) { + budgetData.forEach((b: any) => { + const budgetValue = b.budget_value || 0 + const budgetDate = typeof b.date === 'string' ? b.date.split('T')[0] : formatDate(new Date(b.date)) + totalBudget += budgetValue + if (budgetDate < todayStr) { + pastBudget += budgetValue + } else { + futureBudget += budgetValue + } + }) + } + + const s = revenueData.summary + return { + actualTotal: s.actual_total, + priorActualTotal: s.prior_actual_total, + pastBudget, + futureOtbTotal: s.otb_total, + priorFutureTotal: s.prior_future_total, + futureBudget, + otbPace: s.actual_total + s.otb_total, // Actual + future OTB (no pickup) + forecastRemainingTotal: s.forecast_remaining, + projectedTotal: s.projected_total, + priorYearTotal: s.prior_year_total, + totalBudget, + daysActual: s.days_actual, + daysForecast: s.days_forecast, + } + }, [revenueData, budgetData]) + + const title = revenueType === 'dry' + ? `Restaurant Dry (Food) Revenue by ${consolidation === 'daily' ? 'Day' : consolidation === 'weekly' ? 'Week' : 'Month'}` + : `Restaurant Wet (Drinks) Revenue by ${consolidation === 'daily' ? 'Day' : consolidation === 'weekly' ? 'Week' : 'Month'}` + + return ( +
+

{title}

+

+ Net revenue (exc VAT) - Actual from Newbook for past dates, forecast from covers × spend for future +

+ + {/* Controls - matching accommodation pages */} +
+
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ +
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ + {/* Custom Date Toggle */} +
+ +
+ { + setUseCustomDates(e.target.checked) + if (e.target.checked && !customStartDate) { + setCustomStartDate(startDate) + setCustomEndDate(endDate) + } + }} + /> + {useCustomDates && ( + <> + setCustomStartDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + to + setCustomEndDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + + )} +
+
+ +
+ +
+ {startDate} to {endDate} +
+
+
+ + {isLoading ? ( +
Loading revenue forecast...
+ ) : !revenueData?.data?.length ? ( +
No data available for this period
+ ) : ( + <> + {/* Summary Cards - 4 blocks matching accommodation pages */} + {summary && (() => { + const mainColor = revenueType === 'dry' ? '#16a34a' : '#06b6d4' + const fmt = (v: number) => `£${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}` + + // Actual vs LY + const actualDiff = summary.actualTotal - summary.priorActualTotal + const actualPct = summary.priorActualTotal > 0 ? (actualDiff / summary.priorActualTotal) * 100 : 0 + // Actual vs Budget + const actualVsBudgetDiff = summary.actualTotal - summary.pastBudget + const actualVsBudgetPct = summary.pastBudget > 0 ? (actualVsBudgetDiff / summary.pastBudget) * 100 : 0 + + // OTB Pace vs LY Total + const otbVsLyDiff = summary.otbPace - summary.priorYearTotal + const otbVsLyPct = summary.priorYearTotal > 0 ? (otbVsLyDiff / summary.priorYearTotal) * 100 : 0 + // OTB Pace vs Budget + const otbVsBudgetDiff = summary.otbPace - summary.totalBudget + const otbVsBudgetPct = summary.totalBudget > 0 ? (otbVsBudgetDiff / summary.totalBudget) * 100 : 0 + + // Forecast Remaining vs LY + const forecastDiff = summary.forecastRemainingTotal - summary.priorFutureTotal + const forecastPct = summary.priorFutureTotal > 0 ? (forecastDiff / summary.priorFutureTotal) * 100 : 0 + // Forecast vs Future Budget + const forecastVsBudgetDiff = summary.forecastRemainingTotal - summary.futureBudget + const forecastVsBudgetPct = summary.futureBudget > 0 ? (forecastVsBudgetDiff / summary.futureBudget) * 100 : 0 + + // Projected vs LY + const projectedDiff = summary.projectedTotal - summary.priorYearTotal + const projectedPct = summary.priorYearTotal > 0 ? (projectedDiff / summary.priorYearTotal) * 100 : 0 + // Projected vs Budget + const projectedVsBudgetDiff = summary.projectedTotal - summary.totalBudget + const projectedVsBudgetPct = summary.totalBudget > 0 ? (projectedVsBudgetDiff / summary.totalBudget) * 100 : 0 + + return ( +
+ {/* Actual to Date */} +
+ ACTUAL TO DATE ({summary.daysActual} days) + + {fmt(summary.actualTotal)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {fmt(summary.priorActualTotal)} ({actualDiff >= 0 ? '+' : ''}{fmt(actualDiff)}, {actualPct >= 0 ? '+' : ''}{actualPct.toFixed(0)}%) + + {summary.pastBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.pastBudget)} ({actualVsBudgetDiff >= 0 ? '+' : ''}{actualVsBudgetPct.toFixed(0)}%) + + )} +
+ + {/* OTB Pace */} +
+ OTB PACE + + {fmt(summary.otbPace)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY Total: {fmt(summary.priorYearTotal)} ({otbVsLyDiff >= 0 ? '+' : ''}{fmt(otbVsLyDiff)}, {otbVsLyPct >= 0 ? '+' : ''}{otbVsLyPct.toFixed(0)}%) + + {summary.totalBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.totalBudget)} ({otbVsBudgetDiff >= 0 ? '+' : ''}{otbVsBudgetPct.toFixed(0)}%) + + )} +
+ + {/* Forecast Remaining */} +
+ FORECAST REMAINING ({summary.daysForecast} days) + + {fmt(summary.forecastRemainingTotal)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {fmt(summary.priorFutureTotal)} ({forecastDiff >= 0 ? '+' : ''}{fmt(forecastDiff)}, {forecastPct >= 0 ? '+' : ''}{forecastPct.toFixed(0)}%) + + {summary.futureBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.futureBudget)} ({forecastVsBudgetDiff >= 0 ? '+' : ''}{forecastVsBudgetPct.toFixed(0)}%) + + )} +
+ + {/* Projected Total */} +
+ PROJECTED TOTAL + = 0 ? '#16a34a' : '#dc2626', + }}> + {fmt(summary.projectedTotal)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {fmt(summary.priorYearTotal)} ({projectedDiff >= 0 ? '+' : ''}{fmt(projectedDiff)}, {projectedPct >= 0 ? '+' : ''}{projectedPct.toFixed(0)}%) + + {summary.totalBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.totalBudget)} ({projectedVsBudgetDiff >= 0 ? '+' : ''}{projectedVsBudgetPct.toFixed(0)}%) + + )} +
+
+ ) + })()} + + {/* Chart */} + {chartData && ( +
+ +
+ )} + + )} +
+ ) +} + + +// ============================================ +// TOTAL REVENUE FORECAST (Combined: Accom + Dry + Wet) +// ============================================ + +interface TotalRevenueForecastProps { + consolidation: 'daily' | 'weekly' | 'monthly' +} + +const TotalRevenueForecast: React.FC = ({ consolidation }) => { + + // Helper: get Monday of the week containing a date + const getMondayOfWeek = (date: Date): Date => { + const d = new Date(date) + const day = d.getDay() + const diff = day === 0 ? -6 : 1 - day + d.setDate(d.getDate() + diff) + d.setHours(0, 0, 0, 0) + return d + } + + // Helper: get financial year start (August) + const getFinancialYearStart = (date: Date): string => { + const year = date.getFullYear() + const month = date.getMonth() + const fyStartYear = month >= 7 ? year : year - 1 + return `${fyStartYear}-08` + } + + // Generate month options + const monthOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const today = new Date() + for (let i = -24; i <= 12; i++) { + const date = new Date(today.getFullYear(), today.getMonth() + i, 1) + options.push({ + value: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`, + label: date.toLocaleString('default', { month: 'long', year: 'numeric' }), + }) + } + return options + }, []) + + // Generate week options + const weekOptions = useMemo(() => { + const options: { value: string; label: string }[] = [] + const today = new Date() + const currentMonday = getMondayOfWeek(today) + for (let i = -26; i <= 26; i++) { + const monday = new Date(currentMonday) + monday.setDate(monday.getDate() + i * 7) + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + const weekD = new Date(monday) + weekD.setDate(weekD.getDate() + 4 - (weekD.getDay() || 7)) + const yearStart = new Date(weekD.getFullYear(), 0, 1) + const weekNum = Math.ceil((((weekD.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + options.push({ + value: formatDate(monday), + label: `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${sunday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })})`, + }) + } + return options + }, []) + + // State for date range + const today = new Date() + const [selectedMonth, setSelectedMonth] = useState(() => { + if (consolidation === 'monthly') { + return getFinancialYearStart(today) + } + return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}` + }) + const [selectedWeek, setSelectedWeek] = useState(() => formatDate(getMondayOfWeek(today))) + // Daily view defaults to 1 month, monthly view defaults to 12 months (full FY) + const [duration, setDuration] = useState<'1' | '3' | '6' | '12'>(consolidation === 'daily' ? '1' : '12') + const [weekDuration, setWeekDuration] = useState<'4' | '8' | '13' | '26'>('13') + const [useCustomDates, setUseCustomDates] = useState(false) + const [customStartDate, setCustomStartDate] = useState('') + const [customEndDate, setCustomEndDate] = useState('') + + // Calculate date range + const { startDate, endDate } = useMemo(() => { + if (useCustomDates && customStartDate && customEndDate) { + return { startDate: customStartDate, endDate: customEndDate } + } + if (consolidation === 'weekly') { + const start = new Date(selectedWeek) + const durationWeeks = parseInt(weekDuration) + const end = new Date(start) + end.setDate(start.getDate() + (durationWeeks * 7) - 1) + return { startDate: formatDate(start), endDate: formatDate(end) } + } else { + const [year, month] = selectedMonth.split('-').map(Number) + const start = new Date(year, month - 1, 1) + const durationMonths = parseInt(duration) + const end = new Date(year, month - 1 + durationMonths, 0) + return { startDate: formatDate(start), endDate: formatDate(end) } + } + }, [selectedMonth, selectedWeek, duration, weekDuration, consolidation, useCustomDates, customStartDate, customEndDate]) + + // Fetch combined revenue forecast + const { data: revenueData, isLoading } = useQuery<{ + data: Array<{ + date: string + day_of_week: string + is_past: boolean + actual_revenue: number + otb_revenue: number + pickup_revenue: number + forecast_revenue: number + prior_revenue: number + actual_accom?: number + actual_dry?: number + actual_wet?: number + otb_accom?: number + otb_dry?: number + otb_wet?: number + pickup_accom?: number + pickup_dry?: number + pickup_wet?: number + }> + summary: { + actual_total: number + prior_actual_total: number + otb_total: number + pickup_total: number + forecast_remaining: number + prior_future_total: number + prior_year_total: number + projected_total: number + days_actual: number + days_forecast: number + } + }>({ + queryKey: ['combined-revenue-forecast', startDate, endDate], + queryFn: async () => { + const response = await api.get('/forecast/combined-revenue-forecast', { + params: { + start_date: startDate, + end_date: endDate, + } + }) + return response.data + }, + enabled: !!startDate && !!endDate, + }) + + // Fetch budget data - get all revenue budget types and sum them + const { data: budgetData } = useQuery({ + queryKey: ['total-rev-budgets', startDate, endDate], + queryFn: async () => { + // Fetch all revenue budget types and sum them + const budgetTypes = ['net_accom', 'net_dry', 'net_wet'] + const budgetByDate: Record = {} + + for (const budgetType of budgetTypes) { + try { + const { data } = await api.get('/budget/daily', { params: { from_date: startDate, to_date: endDate, budget_type: budgetType } }) + if (data && Array.isArray(data)) { + data.forEach((b: { date: string; budget_value: number }) => { + const dateKey = typeof b.date === 'string' ? b.date.split('T')[0] : b.date + budgetByDate[dateKey] = (budgetByDate[dateKey] || 0) + (b.budget_value || 0) + }) + } + } catch { /* skip failed budget type */ } + } + + // Convert to array format + return Object.entries(budgetByDate).map(([date, budget_value]) => ({ + date, + budget_value + })) + }, + enabled: !!startDate && !!endDate, + }) + + // Build consolidated data for weekly/monthly views + const consolidatedData = useMemo(() => { + if (!revenueData?.data || consolidation === 'daily') return null + + const groups: Record = {} + + revenueData.data.forEach(d => { + const [year, month, day] = d.date.split('-').map(Number) + let groupKey: string + let groupLabel: string + + if (consolidation === 'weekly') { + const dateObj = new Date(year, month - 1, day, 12, 0, 0) + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + groupKey = formatDate(monday) + const weekD = new Date(monday) + weekD.setDate(weekD.getDate() + 4 - (weekD.getDay() || 7)) + const yearStart = new Date(weekD.getFullYear(), 0, 1) + const weekNum = Math.ceil((((weekD.getTime() - yearStart.getTime()) / 86400000) + 1) / 7) + groupLabel = `W${weekNum} (${monday.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })})` + } else { + groupKey = `${year}-${String(month).padStart(2, '0')}` + const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + groupLabel = `${monthNames[month - 1]} ${year}` + } + + if (!groups[groupKey]) { + groups[groupKey] = { + key: groupKey, + label: groupLabel, + actualRevenue: 0, + otbRevenue: 0, + pickupRevenue: 0, + priorRevenue: 0, + budget: 0, + } + } + + if (d.is_past) { + groups[groupKey].actualRevenue += d.actual_revenue + groups[groupKey].otbRevenue += d.actual_revenue + } else { + groups[groupKey].otbRevenue += d.otb_revenue + groups[groupKey].pickupRevenue += d.pickup_revenue + } + groups[groupKey].priorRevenue += d.prior_revenue + }) + + // Add budget data + if (budgetData) { + budgetData.forEach((b: any) => { + const [bYear, bMonth, bDay] = b.date.split('-').map(Number) + let groupKey: string + if (consolidation === 'weekly') { + const dateObj = new Date(bYear, bMonth - 1, bDay, 12, 0, 0) + const dayOfWeek = dateObj.getDay() + const monday = new Date(dateObj) + monday.setDate(dateObj.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1)) + groupKey = formatDate(monday) + } else { + groupKey = `${bYear}-${String(bMonth).padStart(2, '0')}` + } + if (groups[groupKey]) { + groups[groupKey].budget += b.budget_value || 0 + } + }) + } + + return Object.values(groups).sort((a, b) => a.key.localeCompare(b.key)) + }, [revenueData, consolidation, budgetData]) + + // Build chart data with stacked bars + const chartData = useMemo(() => { + if (!revenueData?.data) return null + + if (consolidation !== 'daily' && consolidatedData) { + const labels = consolidatedData.map(d => d.label) + const traces: any[] = [ + { + x: labels, + y: consolidatedData.map(d => d.otbRevenue), + type: 'bar' as const, + name: 'Actual/OTB', + marker: { color: '#16a34a' }, + hovertemplate: `Actual/OTB: £%{y:,.0f}`, + }, + { + x: labels, + y: consolidatedData.map(d => d.pickupRevenue), + type: 'bar' as const, + name: 'Forecast (Pickup)', + marker: { color: CHART_COLORS.blended }, + hovertemplate: `Forecast: £%{y:,.0f}`, + }, + { + x: labels, + y: consolidatedData.map(d => d.priorRevenue), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: CHART_COLORS.priorFinal, width: 2, dash: 'dot' as const }, + marker: { size: 6 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + if (consolidatedData.some(d => d.budget > 0)) { + traces.push({ + x: labels, + y: consolidatedData.map(d => d.budget), + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: CHART_COLORS.budget, width: 2, dash: 'dash' as const }, + marker: { size: 6 }, + hovertemplate: `Budget: £%{y:,.0f}`, + }) + } + + return { traces, labels } + } + + // Daily view + const dates = revenueData.data.map(d => d.date) + + // Build budget lookup + const budgetByDate: Record = {} + if (budgetData) { + budgetData.forEach((b: any) => { + const budgetDate = typeof b.date === 'string' ? b.date.split('T')[0] : formatDate(new Date(b.date)) + budgetByDate[budgetDate] = b.budget_value || 0 + }) + } + + const otbRevenue = revenueData.data.map(d => d.is_past ? d.actual_revenue : d.otb_revenue) + const pickupRevenue = revenueData.data.map(d => d.is_past ? 0 : d.pickup_revenue) + const priorRevenue = revenueData.data.map(d => d.prior_revenue) + const budgetValues = revenueData.data.map(d => budgetByDate[d.date] || 0) + + const traces: any[] = [ + { + x: dates, + y: otbRevenue, + type: 'bar' as const, + name: 'Actual/OTB', + marker: { color: '#16a34a' }, + hovertemplate: `Actual/OTB: £%{y:,.0f}`, + }, + { + x: dates, + y: pickupRevenue, + type: 'bar' as const, + name: 'Forecast (Pickup)', + marker: { color: CHART_COLORS.blended }, + hovertemplate: `Forecast: £%{y:,.0f}`, + }, + { + x: dates, + y: priorRevenue, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Prior Year', + line: { color: CHART_COLORS.priorFinal, width: 2, dash: 'dot' as const }, + marker: { size: 4 }, + hovertemplate: `Prior Year: £%{y:,.0f}`, + }, + ] + + if (budgetValues.some(v => v > 0)) { + traces.push({ + x: dates, + y: budgetValues, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Budget', + line: { color: CHART_COLORS.budget, width: 2, dash: 'dash' as const }, + marker: { size: 4 }, + hovertemplate: `Budget: £%{y:,.0f}`, + }) + } + + return { traces, labels: dates } + }, [revenueData, consolidatedData, consolidation, budgetData]) + + // Summary calculation + const summary = useMemo(() => { + if (!revenueData?.summary) return null + + const todayStr = formatDate(new Date()) + let totalBudget = 0 + let pastBudget = 0 + let futureBudget = 0 + if (budgetData && budgetData.length > 0) { + budgetData.forEach((b: any) => { + const budgetValue = b.budget_value || 0 + const budgetDate = typeof b.date === 'string' ? b.date.split('T')[0] : formatDate(new Date(b.date)) + totalBudget += budgetValue + if (budgetDate < todayStr) { + pastBudget += budgetValue + } else { + futureBudget += budgetValue + } + }) + } + + const s = revenueData.summary + return { + actualTotal: s.actual_total, + priorActualTotal: s.prior_actual_total, + pastBudget, + futureOtbTotal: s.otb_total, + priorFutureTotal: s.prior_future_total, + futureBudget, + otbPace: s.actual_total + s.otb_total, + forecastRemainingTotal: s.forecast_remaining, + projectedTotal: s.projected_total, + priorYearTotal: s.prior_year_total, + totalBudget, + daysActual: s.days_actual, + daysForecast: s.days_forecast, + } + }, [revenueData, budgetData]) + + const title = `Combined Total Revenue by ${consolidation === 'daily' ? 'Day' : consolidation === 'weekly' ? 'Week' : 'Month'}` + + return ( +
+
+

{title}

+

+ Net revenue (exc VAT) - Accommodation + Dry (Food) + Wet (Drinks) combined. Actual from Newbook for past dates, forecast for future. +

+
+ + {/* Controls - matching accommodation pages */} +
+ {/* From Selector - Week-based for weekly, Month-based for daily/monthly */} +
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ + {/* Duration Selector - Weeks for weekly, Months for daily/monthly */} +
+ + {consolidation === 'weekly' ? ( + + ) : ( + + )} +
+ + {/* Period Display */} +
+ +
+ {startDate} to {endDate} +
+
+ + {/* Custom Date Toggle */} +
+ +
+ { + setUseCustomDates(e.target.checked) + if (e.target.checked && !customStartDate) { + setCustomStartDate(startDate) + setCustomEndDate(endDate) + } + }} + /> + {useCustomDates && ( + <> + setCustomStartDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + to + setCustomEndDate(e.target.value)} + style={{ ...styles.dateInput, width: '130px' }} + /> + + )} +
+
+
+ + {/* Loading state */} + {isLoading ? ( +
Loading combined revenue data...
+ ) : ( + <> + {/* Summary Cards - 4 blocks matching accommodation pages */} + {summary && (() => { + const fmt = (v: number) => `£${v.toLocaleString(undefined, { maximumFractionDigits: 0 })}` + + // Actual vs LY + const actualDiff = summary.actualTotal - summary.priorActualTotal + const actualPct = summary.priorActualTotal > 0 ? (actualDiff / summary.priorActualTotal) * 100 : 0 + // Actual vs Budget + const actualVsBudgetDiff = summary.actualTotal - summary.pastBudget + const actualVsBudgetPct = summary.pastBudget > 0 ? (actualVsBudgetDiff / summary.pastBudget) * 100 : 0 + + // OTB Pace vs LY Total + const otbVsLyDiff = summary.otbPace - summary.priorYearTotal + const otbVsLyPct = summary.priorYearTotal > 0 ? (otbVsLyDiff / summary.priorYearTotal) * 100 : 0 + // OTB Pace vs Budget + const otbVsBudgetDiff = summary.otbPace - summary.totalBudget + const otbVsBudgetPct = summary.totalBudget > 0 ? (otbVsBudgetDiff / summary.totalBudget) * 100 : 0 + + // Forecast Remaining vs LY + const forecastDiff = summary.forecastRemainingTotal - summary.priorFutureTotal + const forecastPct = summary.priorFutureTotal > 0 ? (forecastDiff / summary.priorFutureTotal) * 100 : 0 + // Forecast vs Future Budget + const forecastVsBudgetDiff = summary.forecastRemainingTotal - summary.futureBudget + const forecastVsBudgetPct = summary.futureBudget > 0 ? (forecastVsBudgetDiff / summary.futureBudget) * 100 : 0 + + // Projected vs LY + const projectedDiff = summary.projectedTotal - summary.priorYearTotal + const projectedPct = summary.priorYearTotal > 0 ? (projectedDiff / summary.priorYearTotal) * 100 : 0 + // Projected vs Budget + const projectedVsBudgetDiff = summary.projectedTotal - summary.totalBudget + const projectedVsBudgetPct = summary.totalBudget > 0 ? (projectedVsBudgetDiff / summary.totalBudget) * 100 : 0 + + return ( +
+ {/* Actual to Date */} +
+ ACTUAL TO DATE ({summary.daysActual} days) + + {fmt(summary.actualTotal)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {fmt(summary.priorActualTotal)} ({actualDiff >= 0 ? '+' : ''}{fmt(actualDiff)}, {actualPct >= 0 ? '+' : ''}{actualPct.toFixed(0)}%) + + {summary.pastBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.pastBudget)} ({actualVsBudgetDiff >= 0 ? '+' : ''}{actualVsBudgetPct.toFixed(0)}%) + + )} +
+ + {/* OTB Pace */} +
+ OTB PACE + + {fmt(summary.otbPace)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY Total: {fmt(summary.priorYearTotal)} ({otbVsLyDiff >= 0 ? '+' : ''}{fmt(otbVsLyDiff)}, {otbVsLyPct >= 0 ? '+' : ''}{otbVsLyPct.toFixed(0)}%) + + {summary.totalBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.totalBudget)} ({otbVsBudgetDiff >= 0 ? '+' : ''}{otbVsBudgetPct.toFixed(0)}%) + + )} +
+ + {/* Forecast Remaining */} +
+ FORECAST REMAINING ({summary.daysForecast} days) + + {fmt(summary.forecastRemainingTotal)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {fmt(summary.priorFutureTotal)} ({forecastDiff >= 0 ? '+' : ''}{fmt(forecastDiff)}, {forecastPct >= 0 ? '+' : ''}{forecastPct.toFixed(0)}%) + + {summary.futureBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.futureBudget)} ({forecastVsBudgetDiff >= 0 ? '+' : ''}{forecastVsBudgetPct.toFixed(0)}%) + + )} +
+ + {/* Projected Total */} +
+ PROJECTED TOTAL + = 0 ? '#16a34a' : '#dc2626', + }}> + {fmt(summary.projectedTotal)} + + = 0 ? '#16a34a' : '#dc2626' }}> + vs LY: {fmt(summary.priorYearTotal)} ({projectedDiff >= 0 ? '+' : ''}{fmt(projectedDiff)}, {projectedPct >= 0 ? '+' : ''}{projectedPct.toFixed(0)}%) + + {summary.totalBudget > 0 && ( + = 0 ? '#16a34a' : '#dc2626', marginTop: '2px' }}> + vs Budget: {fmt(summary.totalBudget)} ({projectedVsBudgetDiff >= 0 ? '+' : ''}{projectedVsBudgetPct.toFixed(0)}%) + + )} +
+
+ ) + })()} + + {/* Chart */} + {chartData && ( +
+ +
+ )} + + {/* Data Table */} + {consolidatedData && ( +
+ + + + + + + + + + + + + + + {consolidatedData.map((row, idx) => { + const total = row.otbRevenue + row.pickupRevenue + const vsPY = row.priorRevenue > 0 ? ((total / row.priorRevenue) - 1) * 100 : 0 + const vsBudget = row.budget > 0 ? ((total / row.budget) - 1) * 100 : 0 + return ( + + + + + + + + + + + ) + })} + +
{consolidation === 'weekly' ? 'Week' : 'Month'}Actual/OTBPickupTotalPrior Yearvs PYBudgetvs Budget
{row.label}£{row.otbRevenue.toLocaleString('en-GB', { maximumFractionDigits: 0 })}£{row.pickupRevenue.toLocaleString('en-GB', { maximumFractionDigits: 0 })}£{total.toLocaleString('en-GB', { maximumFractionDigits: 0 })}£{row.priorRevenue.toLocaleString('en-GB', { maximumFractionDigits: 0 })}= 0 ? '#16a34a' : '#dc2626' }}> + {vsPY >= 0 ? '+' : ''}{vsPY.toFixed(1)}% + + {row.budget > 0 ? `£${row.budget.toLocaleString('en-GB', { maximumFractionDigits: 0 })}` : '-'} + = 0 ? '#16a34a' : '#dc2626' }}> + {row.budget > 0 ? `${vsBudget >= 0 ? '+' : ''}${vsBudget.toFixed(1)}%` : '-'} +
+
+ )} + + )} +
+ ) +} + + +const styles: Record = { + layout: { + display: 'flex', + gap: '32px', + padding: '32px', + }, + sidebar: { + width: '200px', + flexShrink: 0, + background: 'var(--card-bg)', + borderRadius: '16px', + padding: '16px', + boxShadow: 'var(--shadow-md)', + height: 'fit-content', + position: 'sticky', + top: '16px', + }, + sidebarTitle: { + margin: '0 0 16px 0', + fontSize: '1.125rem', + color: 'var(--text-dark)', + fontWeight: 600, + }, + nav: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + navItem: { + padding: '8px 16px', + border: 'none', + background: 'transparent', + textAlign: 'left', + cursor: 'pointer', + borderRadius: 'var(--radius)', + fontSize: '0.875rem', + color: 'var(--text-mid)', + fontWeight: 400, + transition: 'all 0.15s ease', + }, + navItemActive: { + background: 'var(--gold)', + color: '#ffffff', + fontWeight: 500, + }, + navSection: { + padding: '4px 8px', + fontSize: '0.75rem', + fontWeight: 600, + color: 'var(--text-mid)', + textTransform: 'uppercase' as const, + letterSpacing: '0.5px', + marginTop: '4px', + }, + navItemIndented: { + paddingLeft: '24px', + fontSize: '0.875rem', + }, + content: { + flex: 1, + minWidth: 0, + }, + section: { + background: 'var(--card-bg)', + padding: '24px', + borderRadius: '16px', + boxShadow: 'var(--shadow-md)', + }, + sectionHeader: { + marginBottom: '24px', + }, + sectionTitle: { + color: 'var(--text-dark)', + margin: 0, + marginBottom: '4px', + fontSize: '1.5rem', + fontWeight: 600, + }, + hint: { + color: 'var(--text-mid)', + margin: 0, + fontSize: '0.875rem', + }, + controlsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', + gap: '24px', + marginBottom: '24px', + padding: '16px', + background: 'var(--body-bg)', + borderRadius: '12px', + }, + controlGroup: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + label: { + fontSize: '0.75rem', + color: 'var(--text-mid)', + textTransform: 'uppercase', + letterSpacing: '0.5px', + }, + dateInputs: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + dateInput: { + padding: '8px', + border: '1px solid var(--card-border)', + borderRadius: 'var(--radius)', + fontSize: '0.875rem', + background: 'var(--card-bg)', + color: 'var(--text-dark)', + }, + dateSeparator: { + color: 'var(--text-mid)', + fontSize: '0.875rem', + }, + toggleGroup: { + display: 'flex', + border: '1px solid var(--card-border)', + borderRadius: 'var(--radius)', + overflow: 'hidden', + }, + toggleButton: { + flex: 1, + padding: '8px 16px', + border: 'none', + background: 'var(--card-bg)', + color: 'var(--text-mid)', + fontSize: '0.875rem', + cursor: 'pointer', + transition: 'all 0.15s ease', + }, + toggleButtonActive: { + background: 'var(--gold)', + color: '#ffffff', + }, + dataTableToggle: { + display: 'block', + width: '100%', + padding: '8px 16px', + marginTop: '16px', + marginBottom: '8px', + border: '1px solid var(--card-border)', + borderRadius: 'var(--radius)', + background: 'var(--card-bg)', + color: 'var(--text-mid)', + fontSize: '0.875rem', + fontWeight: 500, + cursor: 'pointer', + transition: 'all 0.15s ease', + textAlign: 'center' as const, + }, + quickSelectGroup: { + display: 'flex', + gap: '4px', + }, + quickSelectButton: { + padding: '4px 8px', + border: '1px solid var(--card-border)', + borderRadius: 'var(--radius)', + background: 'var(--card-bg)', + color: 'var(--text-mid)', + fontSize: '0.75rem', + cursor: 'pointer', + transition: 'all 0.15s ease', + }, + monthSelect: { + padding: '8px', + border: '1px solid var(--card-border)', + borderRadius: 'var(--radius)', + background: 'var(--card-bg)', + color: 'var(--text-dark)', + fontSize: '0.875rem', + cursor: 'pointer', + minWidth: '140px', + }, + select: { + padding: '8px', + border: '1px solid var(--card-border)', + borderRadius: 'var(--radius)', + background: 'var(--card-bg)', + color: 'var(--text-dark)', + fontSize: '0.875rem', + cursor: 'pointer', + minWidth: '140px', + }, + summaryGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', + gap: '16px', + marginBottom: '24px', + }, + summaryCard: { + display: 'flex', + flexDirection: 'column', + padding: '16px', + background: 'var(--body-bg)', + borderRadius: '12px', + textAlign: 'center', + }, + summaryLabel: { + fontSize: '0.75rem', + color: 'var(--text-mid)', + textTransform: 'uppercase', + letterSpacing: '0.5px', + marginBottom: '4px', + }, + summaryValue: { + fontSize: '1.5rem', + fontWeight: 700, + color: 'var(--text-dark)', + }, + summarySubtext: { + fontSize: '0.75rem', + color: 'var(--text-mid)', + marginTop: '4px', + }, + chartContainer: { + marginBottom: '24px', + }, + loadingContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '300px', + color: 'var(--text-mid)', + fontSize: '1rem', + }, + emptyContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '200px', + color: 'var(--text-mid)', + fontSize: '1rem', + background: 'var(--body-bg)', + borderRadius: 'var(--radius)', + marginBottom: '24px', + }, + tableToggle: { + padding: '8px 16px', + border: '1px solid var(--card-border)', + borderRadius: 'var(--radius)', + background: 'var(--card-bg)', + color: 'var(--text-mid)', + fontSize: '0.875rem', + cursor: 'pointer', + marginBottom: '16px', + }, + tableContainer: { + overflowX: 'auto', + maxHeight: '400px', + overflowY: 'auto', + marginBottom: '24px', + }, + table: { + width: '100%', + borderCollapse: 'collapse', + fontSize: '0.875rem', + }, + th: { + padding: '8px', + textAlign: 'left', + borderBottom: '2px solid var(--card-border)', + color: 'var(--text-mid)', + fontWeight: 500, + position: 'sticky', + top: 0, + background: 'var(--card-bg)', + whiteSpace: 'nowrap', + }, + thRight: { + textAlign: 'right', + }, + tr: { + transition: 'background-color 0.1s ease', + }, + td: { + padding: '8px', + borderBottom: '1px solid var(--card-border)', + whiteSpace: 'nowrap', + }, + tdRight: { + textAlign: 'right', + }, + paceButton: { + padding: '4px 8px', + border: 'none', + borderRadius: '6px', + background: 'var(--gold)', + color: '#ffffff', + fontSize: '0.75rem', + cursor: 'pointer', + }, + paceCurveSection: { + marginTop: '24px', + padding: '24px', + background: 'var(--body-bg)', + borderRadius: '12px', + }, + paceCurveTitle: { + margin: 0, + marginBottom: '4px', + fontSize: '1.125rem', + fontWeight: 600, + color: 'var(--text-dark)', + }, +} + +export default Forecasts diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx new file mode 100644 index 0000000..ad09363 --- /dev/null +++ b/frontend/src/pages/History.tsx @@ -0,0 +1,3133 @@ +import React, { useState, useMemo } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import Plot from 'react-plotly.js' +import api from '../api' + +type ReportPage = 'occupancy' | 'bookings' | 'rates' | 'ave_rate' | 'revenue' | 'pickup_3d' | 'restaurant_bookings' | 'restaurant_covers' + +interface MenuGroup { + group: string + items: { id: ReportPage; label: string }[] +} + +const History: React.FC = () => { + const { report } = useParams<{ report?: string }>() + const navigate = useNavigate() + const activePage = (report as ReportPage) || 'occupancy' + + const menuGroups: MenuGroup[] = [ + { + group: 'Hotel', + items: [ + { id: 'occupancy', label: 'Occupancy' }, + { id: 'bookings', label: 'Bookings' }, + { id: 'rates', label: 'Rate Totals' }, + { id: 'ave_rate', label: 'Ave Rate' }, + { id: 'revenue', label: 'Revenue' }, + { id: 'pickup_3d', label: '3D Pickup' }, + ] + }, + { + group: 'Restaurant', + items: [ + { id: 'restaurant_bookings', label: 'Bookings' }, + { id: 'restaurant_covers', label: 'Covers' }, + ] + } + ] + + const handlePageChange = (id: ReportPage) => { + navigate(`/history/${id}`) + } + + return ( +
+
+

History

+ +
+ +
+ {activePage === 'occupancy' && } + {activePage === 'bookings' && } + {activePage === 'rates' && } + {activePage === 'ave_rate' && } + {activePage === 'revenue' && } + {activePage === 'pickup_3d' && } + {activePage === 'restaurant_bookings' && } + {activePage === 'restaurant_covers' && } +
+
+ ) +} + +export default History + +// ============================================ +// DATE HELPERS +// ============================================ + +const formatDate = (date: Date): string => { + return date.toISOString().split('T')[0] +} + +const parseDate = (dateStr: string): Date => { + const [year, month, day] = dateStr.split('-').map(Number) + return new Date(Date.UTC(year, month - 1, day)) +} + +const getStartOfWeek = (date: Date): Date => { + const day = date.getUTCDay() + const diff = date.getUTCDate() - day + (day === 0 ? -6 : 1) + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), diff)) +} + +const getComparisonStartDate = ( + startDate: Date, + comparisonType: ComparisonType, + consolidation: ConsolidationType +): Date => { + if (comparisonType === 'previous_period') { + return startDate + } else { + if (consolidation === 'day') { + return new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() - 364)) + } else if (consolidation === 'week') { + const priorDate = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() - 364)) + return getStartOfWeek(priorDate) + } else if (consolidation === 'month') { + return new Date(Date.UTC(startDate.getUTCFullYear() - 1, startDate.getUTCMonth(), 1)) + } + return new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate() - 364)) + } +} + +type ConsolidationType = 'day' | 'week' | 'month' +type ComparisonType = 'none' | 'previous_period' | 'previous_year' +type QuickSelectType = '7days' | '14days' | '1month' | '3months' | '6months' | '1year' +type OccupancyType = 'bookable' | 'total' | 'both' + +const getLast12Months = () => { + const months: { label: string; start: string; end: string }[] = [] + const now = new Date() + for (let i = 0; i < 12; i++) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1) + const year = date.getFullYear() + const month = date.getMonth() + const startDate = new Date(year, month, 1) + const endDate = new Date(year, month + 1, 0) + const monthName = date.toLocaleString('default', { month: 'short' }) + months.push({ label: `${monthName} ${year}`, start: formatDate(startDate), end: formatDate(endDate) }) + } + return months +} + +interface OccupancyDataPoint { + date: string + total_occupancy_pct: number | null + bookable_occupancy_pct: number | null + booking_count: number + rooms_count: number + bookable_count: number +} + +// ============================================ +// STYLES +// ============================================ + +const styles: Record = { + layout: { display: 'flex', gap: '24px', padding: '24px' }, + sidebar: { width: '200px', flexShrink: 0, background: 'var(--card-bg)', borderRadius: '12px', padding: '16px', boxShadow: 'var(--shadow-md)', height: 'fit-content', position: 'sticky', top: '16px' }, + sidebarTitle: { margin: '0 0 16px 0', fontSize: '1.125rem', color: 'var(--text-dark)', fontWeight: 600 }, + nav: { display: 'flex', flexDirection: 'column', gap: '16px' }, + navGroup: { display: 'flex', flexDirection: 'column', gap: '4px' }, + navGroupTitle: { padding: '8px 12px', fontSize: '0.75rem', fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.5px', marginTop: '8px' }, + navItem: { padding: '12px 16px', border: 'none', background: 'transparent', textAlign: 'left', cursor: 'pointer', borderRadius: '8px', fontSize: '0.875rem', color: '#475569', fontWeight: 400, transition: 'all 0.15s ease' }, + navItemActive: { background: 'var(--gold)', color: '#ffffff', fontWeight: 500 }, + content: { flex: 1, minWidth: 0 }, + section: { background: 'var(--card-bg)', padding: '20px', borderRadius: '12px', boxShadow: 'var(--shadow-md)' }, + sectionHeader: { marginBottom: '20px' }, + sectionTitle: { color: 'var(--text-dark)', margin: 0, marginBottom: '8px', fontSize: '1.5rem', fontWeight: 600 }, + hint: { color: '#475569', margin: 0, fontSize: '0.875rem' }, + controlsGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: '20px', marginBottom: '16px' }, + controlGroup: { display: 'flex', flexDirection: 'column', gap: '12px' }, + controlLabel: { fontSize: '0.875rem', fontWeight: 500, color: 'var(--text-dark)' }, + dateInputs: { display: 'flex', alignItems: 'center', gap: '12px' }, + dateInput: { padding: '12px', borderRadius: '8px', border: '1px solid var(--card-border)', fontSize: '0.875rem', outline: 'none', flex: 1 }, + dateSeparator: { color: 'var(--text-mid)', fontSize: '0.875rem' }, + buttonGroup: { display: 'flex', gap: '1px', background: 'var(--card-border)', borderRadius: '8px', overflow: 'hidden' }, + toggleButton: { flex: 1, padding: '12px 16px', border: 'none', background: 'var(--body-bg)', fontSize: '0.875rem', color: '#475569', cursor: 'pointer', transition: 'all 0.15s' }, + toggleButtonActive: { background: 'var(--gold)', color: '#ffffff', fontWeight: 500 }, + quickSelect: { display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '20px', flexWrap: 'wrap' }, + quickSelectLabel: { fontSize: '0.875rem', color: 'var(--text-mid)' }, + quickSelectButton: { padding: '8px 12px', border: '1px solid var(--card-border)', background: 'var(--card-bg)', borderRadius: '8px', fontSize: '0.75rem', color: '#475569', cursor: 'pointer', transition: 'all 0.15s' }, + monthSelect: { padding: '8px 12px', border: '1px solid var(--card-border)', background: 'var(--card-bg)', borderRadius: '8px', fontSize: '0.75rem', color: 'var(--text-dark)', cursor: 'pointer', minWidth: '100px' }, + comparisonInfo: { padding: '12px', background: '#e0f2fe', color: '#0369a1', borderRadius: '8px', fontSize: '0.875rem', marginBottom: '20px' }, + chartContainer: { marginBottom: '24px', minHeight: '400px' }, + loading: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '400px', color: '#475569' }, + emptyState: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '400px', color: 'var(--text-mid)', background: 'var(--body-bg)', borderRadius: '8px' }, + summaryGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }, + summaryCard: { padding: '16px', background: 'var(--body-bg)', borderRadius: '8px', textAlign: 'center' }, + summaryLabel: { fontSize: '0.75rem', color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '8px' }, + summaryValue: { fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-dark)' }, + summaryComparison: { marginTop: '8px', fontSize: '0.875rem', display: 'flex', justifyContent: 'center', gap: '8px' }, + comparisonValue: { color: '#475569' }, + comparisonDiff: { fontWeight: 500 }, + tableSection: { marginTop: '24px' }, + tableToggle: { padding: '12px 16px', border: '1px solid var(--card-border)', background: 'var(--body-bg)', borderRadius: '8px', fontSize: '0.875rem', color: '#475569', cursor: 'pointer', transition: 'all 0.15s', marginBottom: '16px' }, + tableWrapper: { overflowX: 'auto', border: '1px solid var(--card-border)', borderRadius: '8px', maxHeight: '400px', overflowY: 'auto' }, + table: { width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }, + tableHeader: { padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--text-dark)', background: 'var(--body-bg)', borderBottom: '2px solid var(--card-border)', position: 'sticky', top: 0 }, + tableHeaderRight: { padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--text-dark)', background: 'var(--body-bg)', borderBottom: '2px solid var(--card-border)', position: 'sticky', top: 0 }, + tableRowEven: { background: 'var(--card-bg)' }, + tableRowOdd: { background: 'var(--body-bg)' }, + tableCell: { padding: '12px 16px', borderBottom: '1px solid var(--card-border)', color: 'var(--text-dark)' }, + tableCellRight: { padding: '12px 16px', borderBottom: '1px solid var(--card-border)', color: 'var(--text-dark)', textAlign: 'right' }, + noData: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '200px', color: 'var(--text-mid)', background: 'var(--body-bg)', borderRadius: '8px' }, + summaryChange: { marginTop: '8px', fontSize: '0.875rem', fontWeight: 500 }, +} + +// ============================================ +// OCCUPANCY REPORT +// ============================================ + +const OccupancyReport: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() - 1) + const defaultStart = new Date(defaultEnd) + defaultStart.setMonth(defaultEnd.getMonth() - 1) + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [consolidation, setConsolidation] = useState('day') + const [comparison, setComparison] = useState('previous_year') + const [occupancyType, setOccupancyType] = useState('both') + const [showTable, setShowTable] = useState(false) + + const monthOptions = useMemo(() => getLast12Months(), []) + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { setStartDate(month.start); setEndDate(month.end) } + } + + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date() + end.setDate(end.getDate() - 1) + const start = new Date(end) + switch (type) { + case '7days': start.setDate(end.getDate() - 6); break + case '14days': start.setDate(end.getDate() - 13); break + case '1month': start.setMonth(end.getMonth() - 1); break + case '3months': start.setMonth(end.getMonth() - 3); break + case '6months': start.setMonth(end.getMonth() - 6); break + case '1year': start.setFullYear(end.getFullYear() - 1); break + } + setStartDate(formatDate(start)); setEndDate(formatDate(end)) + } + + const comparisonDates = useMemo(() => { + if (comparison === 'none') return null + const start = parseDate(startDate) + const end = parseDate(endDate) + const periodDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1 + if (comparison === 'previous_period') { + const compEnd = new Date(start); compEnd.setDate(start.getDate() - 1) + const compStart = new Date(compEnd); compStart.setDate(compEnd.getDate() - periodDays + 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } else { + const compStart = getComparisonStartDate(start, comparison, consolidation) + const compEnd = new Date(compStart); compEnd.setDate(compStart.getDate() + periodDays - 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } + }, [startDate, endDate, comparison, consolidation]) + + const { data: mainData, isLoading: mainLoading } = useQuery({ + queryKey: ['occupancy-report', startDate, endDate, consolidation], + queryFn: async () => { + const res = await api.get('/reports/occupancy', { params: { start_date: startDate, end_date: endDate, consolidation } }) + return res.data + }, + }) + + const { data: comparisonData, isLoading: comparisonLoading } = useQuery({ + queryKey: ['occupancy-report-comparison', comparisonDates?.start, comparisonDates?.end, consolidation], + queryFn: async () => { + if (!comparisonDates) return [] + const res = await api.get('/reports/occupancy', { params: { start_date: comparisonDates.start, end_date: comparisonDates.end, consolidation } }) + return res.data + }, + enabled: !!comparisonDates, + }) + + const chartData = useMemo(() => { + if (!mainData) return { labels: [], mainSeries: [], comparisonSeries: [], mainDates: [], comparisonDates: [], mainTotalSeries: [], mainBookableSeries: [], compTotalSeries: [], compBookableSeries: [] } + + const rangeStart = parseDate(mainData[0]?.date || '') + const rangeEnd = parseDate(mainData[mainData.length - 1]?.date || '') + const rangeMonths = (rangeEnd.getFullYear() - rangeStart.getFullYear()) * 12 + (rangeEnd.getMonth() - rangeStart.getMonth()) + const includeYear = rangeMonths >= 11 + + const formatLabel = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') { + return includeYear ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' }) : date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + } else if (consolidation === 'week') { + const weekEnd = new Date(date); weekEnd.setDate(date.getDate() + 6) + return includeYear ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}` : `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}` + } else { + return date.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) + } + } + + const formatDateWithDay = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') { + return date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) + } else if (consolidation === 'week') { + const weekEnd = new Date(date); weekEnd.setDate(date.getDate() + 6) + return `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` + } else { + return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + } + } + + const labels = mainData.map(d => formatLabel(d.date)) + const mainDates = mainData.map(d => formatDateWithDay(d.date)) + const mainTotalSeries = mainData.map(d => d.total_occupancy_pct ?? 0) + const mainBookableSeries = mainData.map(d => d.bookable_occupancy_pct ?? 0) + const getOccupancy = (d: OccupancyDataPoint) => occupancyType === 'bookable' ? (d.bookable_occupancy_pct ?? 0) : (d.total_occupancy_pct ?? 0) + const mainSeries = mainData.map(d => getOccupancy(d)) + + let comparisonSeries: (number | null)[] = [] + let compTotalSeries: (number | null)[] = [] + let compBookableSeries: (number | null)[] = [] + let comparisonDates: string[] = [] + + if (comparisonData && comparisonData.length > 0 && comparison !== 'none') { + comparisonSeries = mainData.map((_, i) => i < comparisonData.length ? getOccupancy(comparisonData[i]) : null) + compTotalSeries = mainData.map((_, i) => i < comparisonData.length ? (comparisonData[i].total_occupancy_pct ?? 0) : null) + compBookableSeries = mainData.map((_, i) => i < comparisonData.length ? (comparisonData[i].bookable_occupancy_pct ?? 0) : null) + comparisonDates = mainData.map((_, i) => i < comparisonData.length ? formatDateWithDay(comparisonData[i].date) : '') + } + + return { labels, mainSeries, comparisonSeries, mainDates, comparisonDates, mainTotalSeries, mainBookableSeries, compTotalSeries, compBookableSeries } + }, [mainData, comparisonData, consolidation, comparison, occupancyType]) + + const isLoading = mainLoading || (comparison !== 'none' && comparisonLoading) + + return ( +
+
+
+

Occupancy Report

+

View occupancy trends over time with optional comparison to previous periods

+
+
+ +
+
+ +
+ setStartDate(e.target.value)} style={styles.dateInput} /> + to + setEndDate(e.target.value)} style={styles.dateInput} /> +
+
+
+ +
+ {(['day', 'week', 'month'] as ConsolidationType[]).map((type) => ( + + ))} +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ Quick select: + {([{ type: '7days', label: '7 days' }, { type: '14days', label: '14 days' }, { type: '1month', label: '1 month' }, { type: '3months', label: '3 months' }, { type: '6months', label: '6 months' }, { type: '1year', label: '1 year' }] as { type: QuickSelectType; label: string }[]).map(({ type, label }) => ( + + ))} + +
+ + {comparison !== 'none' && comparisonDates && ( +
Comparing with: {comparisonDates.start} to {comparisonDates.end}
+ )} + +
+ {isLoading ? ( +
Loading occupancy data...
+ ) : chartData.labels.length === 0 ? ( +
No occupancy data available for the selected date range
+ ) : ( + 0 ? [{ + x: chartData.labels, y: chartData.compBookableSeries, customdata: chartData.comparisonDates, + type: 'scatter' as const, mode: 'lines+markers' as const, + name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Bookable', + line: { color: '#93c5fd', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, + hovertemplate: '%{customdata}
Bookable: %{y:.1f}%', + }] : []), + ...(comparison !== 'none' && chartData.compTotalSeries.length > 0 ? [{ + x: chartData.labels, y: chartData.compTotalSeries, customdata: chartData.comparisonDates, + type: 'scatter' as const, mode: 'lines+markers' as const, + name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Total', + line: { color: '#3b82f6', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, + hovertemplate: '%{customdata}
Total: %{y:.1f}%', + }] : []), + { x: chartData.labels, y: chartData.mainBookableSeries, customdata: chartData.mainDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Current Bookable', line: { color: '#f8a5b6', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
Bookable: %{y:.1f}%' }, + { x: chartData.labels, y: chartData.mainTotalSeries, customdata: chartData.mainDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Current Total', line: { color: '#e94560', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
Total: %{y:.1f}%' }, + ] : [ + ...(comparison !== 'none' && chartData.comparisonSeries.length > 0 ? [{ + x: chartData.labels, y: chartData.comparisonSeries, customdata: chartData.comparisonDates, + type: 'scatter' as const, mode: 'lines+markers' as const, + name: comparison === 'previous_year' ? 'Previous Year' : 'Previous Period', + line: { color: '#3b82f6', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, + hovertemplate: '%{customdata}
%{y:.1f}%', + }] : []), + { x: chartData.labels, y: chartData.mainSeries, customdata: chartData.mainDates, type: 'scatter', mode: 'lines+markers', name: 'Current Period', line: { color: '#e94560', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
%{y:.1f}%' }, + ]} + layout={{ + autosize: true, height: 400, margin: { l: 50, r: 30, t: 30, b: 50 }, + paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', + font: { family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', color: '#666' }, + xaxis: { showgrid: false, tickangle: -45, nticks: Math.min(chartData.labels.length, 15) }, + yaxis: { title: { text: occupancyType === 'both' ? 'Occupancy %' : (occupancyType === 'bookable' ? 'Bookable Occupancy %' : 'Total Occupancy %') }, gridcolor: '#eee', zeroline: false }, + hovermode: 'x unified', showlegend: occupancyType === 'both' || comparison !== 'none', + legend: { orientation: 'h', y: 1.15 }, + }} + config={{ displayModeBar: true, responsive: true, modeBarButtonsToRemove: ['lasso2d', 'select2d', 'autoScale2d'] }} + style={{ width: '100%', height: '400px' }} + /> + )} +
+ + {mainData && mainData.length > 0 && (() => { + const getOcc = (d: OccupancyDataPoint) => (occupancyType === 'bookable' || occupancyType === 'both') ? (d.bookable_occupancy_pct ?? 0) : (d.total_occupancy_pct ?? 0) + const mainAvg = mainData.reduce((sum, d) => sum + getOcc(d), 0) / mainData.length + const mainPeak = Math.max(...mainData.map(d => getOcc(d))) + const mainLowest = Math.min(...mainData.map(d => getOcc(d))) + const mainRoomNights = mainData.reduce((sum, d) => sum + d.booking_count, 0) + const hasComparison = comparison !== 'none' && comparisonData && comparisonData.length > 0 + const compAvg = hasComparison ? comparisonData.reduce((sum, d) => sum + getOcc(d), 0) / comparisonData.length : 0 + const compPeak = hasComparison ? Math.max(...comparisonData.map(d => getOcc(d))) : 0 + const compLowest = hasComparison ? Math.min(...comparisonData.map(d => getOcc(d))) : 0 + const compRoomNights = hasComparison ? comparisonData.reduce((sum, d) => sum + d.booking_count, 0) : 0 + const formatDiff = (current: number, previous: number, isPercent: boolean = true) => { + const diff = current - previous; const sign = diff >= 0 ? '+' : '' + return `${sign}${diff.toFixed(isPercent ? 1 : 0)}${isPercent ? '%' : ''}` + } + const summaryLabel = occupancyType === 'both' ? ' (Bookable)' : '' + return ( +
+
+
Average Occupancy{summaryLabel}
+
{mainAvg.toFixed(1)}%
+ {hasComparison && (
{compAvg.toFixed(1)}%= compAvg ? '#16a34a' : '#dc2626' }}>({formatDiff(mainAvg, compAvg)})
)} +
+
+
Peak Occupancy
+
{mainPeak.toFixed(1)}%
+ {hasComparison && (
{compPeak.toFixed(1)}%= compPeak ? '#16a34a' : '#dc2626' }}>({formatDiff(mainPeak, compPeak)})
)} +
+
+
Lowest Occupancy
+
{mainLowest.toFixed(1)}%
+ {hasComparison && (
{compLowest.toFixed(1)}%= compLowest ? '#16a34a' : '#dc2626' }}>({formatDiff(mainLowest, compLowest)})
)} +
+
+
Total Room Nights
+
{mainRoomNights.toLocaleString()}
+ {hasComparison && (
{compRoomNights.toLocaleString()}= compRoomNights ? '#16a34a' : '#dc2626' }}>({formatDiff(mainRoomNights, compRoomNights, false)})
)} +
+
+ ) + })()} + + {mainData && mainData.length > 0 && ( +
+ + {showTable && ( +
+ + + + + + + + {comparison !== 'none' && comparisonData && (<>)} + + + + {mainData.map((row, index) => { + const compRow = comparisonData && index < comparisonData.length ? comparisonData[index] : null + const date = parseDate(row.date) + const dateLabel = consolidation === 'day' ? date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) : consolidation === 'week' ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${new Date(date.getTime() + 6 * 24 * 60 * 60 * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` : date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + return ( + + + + + + {comparison !== 'none' && comparisonData && (<>)} + + ) + })} + +
DateTotal %Bookable %BookingsPrior Total %Prior Bookable %Prior Bookings
{dateLabel}{(row.total_occupancy_pct ?? 0).toFixed(1)}%{(row.bookable_occupancy_pct ?? 0).toFixed(1)}%{row.booking_count}{compRow ? `${(compRow.total_occupancy_pct ?? 0).toFixed(1)}%` : '-'}{compRow ? `${(compRow.bookable_occupancy_pct ?? 0).toFixed(1)}%` : '-'}{compRow ? compRow.booking_count : '-'}
+
+ )} +
+ )} +
+ ) +} + +// ============================================ +// BOOKINGS REPORT +// ============================================ + +type BookingsDisplayType = 'bookings' | 'guests' | 'both' + +interface BookingsDataPoint { + date: string + booking_count: number + guests_count: number + rooms_count: number +} + +const BookingsReport: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today); defaultEnd.setDate(today.getDate() - 1) + const defaultStart = new Date(defaultEnd); defaultStart.setMonth(defaultEnd.getMonth() - 1) + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [consolidation, setConsolidation] = useState('day') + const [comparison, setComparison] = useState('previous_year') + const [displayType, setDisplayType] = useState('both') + const [showTable, setShowTable] = useState(false) + + const monthOptions = useMemo(() => getLast12Months(), []) + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { setStartDate(month.start); setEndDate(month.end) } + } + + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date(); end.setDate(end.getDate() - 1) + const start = new Date(end) + switch (type) { + case '7days': start.setDate(end.getDate() - 6); break + case '14days': start.setDate(end.getDate() - 13); break + case '1month': start.setMonth(end.getMonth() - 1); break + case '3months': start.setMonth(end.getMonth() - 3); break + case '6months': start.setMonth(end.getMonth() - 6); break + case '1year': start.setFullYear(end.getFullYear() - 1); break + } + setStartDate(formatDate(start)); setEndDate(formatDate(end)) + } + + const comparisonDates = useMemo(() => { + if (comparison === 'none') return null + const start = parseDate(startDate); const end = parseDate(endDate) + const periodDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1 + if (comparison === 'previous_period') { + const compEnd = new Date(start); compEnd.setDate(start.getDate() - 1) + const compStart = new Date(compEnd); compStart.setDate(compEnd.getDate() - periodDays + 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } else { + const compStart = getComparisonStartDate(start, comparison, consolidation) + const compEnd = new Date(compStart); compEnd.setDate(compStart.getDate() + periodDays - 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } + }, [startDate, endDate, comparison, consolidation]) + + const { data: mainData, isLoading: mainLoading } = useQuery({ + queryKey: ['bookings-report', startDate, endDate, consolidation], + queryFn: async () => { + const res = await api.get('/reports/bookings', { params: { start_date: startDate, end_date: endDate, consolidation } }) + return res.data + }, + }) + + const { data: comparisonData, isLoading: comparisonLoading } = useQuery({ + queryKey: ['bookings-report-comparison', comparisonDates?.start, comparisonDates?.end, consolidation], + queryFn: async () => { + if (!comparisonDates) return [] + const res = await api.get('/reports/bookings', { params: { start_date: comparisonDates.start, end_date: comparisonDates.end, consolidation } }) + return res.data + }, + enabled: !!comparisonDates, + }) + + const chartData = useMemo(() => { + if (!mainData) return { labels: [], mainSeries: [], comparisonSeries: [], mainDates: [], comparisonDates: [], mainBookingsSeries: [], mainGuestsSeries: [], compBookingsSeries: [], compGuestsSeries: [] } + + const rangeStart = parseDate(mainData[0]?.date || '') + const rangeEnd = parseDate(mainData[mainData.length - 1]?.date || '') + const rangeMonths = (rangeEnd.getFullYear() - rangeStart.getFullYear()) * 12 + (rangeEnd.getMonth() - rangeStart.getMonth()) + const includeYear = rangeMonths >= 11 + + const formatLabel = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') return includeYear ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' }) : date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + else if (consolidation === 'week') { const weekEnd = new Date(date); weekEnd.setDate(date.getDate() + 6); return includeYear ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}` : `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}` } + else return date.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) + } + + const formatDateWithDay = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') return date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) + else if (consolidation === 'week') { const weekEnd = new Date(date); weekEnd.setDate(date.getDate() + 6); return `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` } + else return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + } + + const labels = mainData.map(d => formatLabel(d.date)) + const mainDates = mainData.map(d => formatDateWithDay(d.date)) + const mainBookingsSeries = mainData.map(d => d.booking_count) + const mainGuestsSeries = mainData.map(d => d.guests_count) + const getValue = (d: BookingsDataPoint) => displayType === 'guests' ? d.guests_count : d.booking_count + const mainSeries = mainData.map(d => getValue(d)) + + let comparisonSeries: (number | null)[] = [] + let compBookingsSeries: (number | null)[] = [] + let compGuestsSeries: (number | null)[] = [] + let comparisonDatesArr: string[] = [] + + if (comparisonData && comparisonData.length > 0 && comparison !== 'none') { + comparisonSeries = mainData.map((_, i) => i < comparisonData.length ? getValue(comparisonData[i]) : null) + compBookingsSeries = mainData.map((_, i) => i < comparisonData.length ? comparisonData[i].booking_count : null) + compGuestsSeries = mainData.map((_, i) => i < comparisonData.length ? comparisonData[i].guests_count : null) + comparisonDatesArr = mainData.map((_, i) => i < comparisonData.length ? formatDateWithDay(comparisonData[i].date) : '') + } + + return { labels, mainSeries, comparisonSeries, mainDates, comparisonDates: comparisonDatesArr, mainBookingsSeries, mainGuestsSeries, compBookingsSeries, compGuestsSeries } + }, [mainData, comparisonData, consolidation, comparison, displayType]) + + const isLoading = mainLoading || (comparison !== 'none' && comparisonLoading) + + return ( +
+
+
+

Bookings Report

+

View booking counts and guest numbers over time with optional comparison

+
+
+ +
+
+ +
+ setStartDate(e.target.value)} style={styles.dateInput} /> + to + setEndDate(e.target.value)} style={styles.dateInput} /> +
+
+
+ +
+ {(['day', 'week', 'month'] as ConsolidationType[]).map((type) => ( + + ))} +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ Quick select: + {([{ type: '7days', label: '7 days' }, { type: '14days', label: '14 days' }, { type: '1month', label: '1 month' }, { type: '3months', label: '3 months' }, { type: '6months', label: '6 months' }, { type: '1year', label: '1 year' }] as { type: QuickSelectType; label: string }[]).map(({ type, label }) => ( + + ))} + +
+ + {comparison !== 'none' && comparisonDates && ( +
Comparing with: {comparisonDates.start} to {comparisonDates.end}
+ )} + +
+ {isLoading ? ( +
Loading bookings data...
+ ) : chartData.labels.length === 0 ? ( +
No bookings data available for the selected date range
+ ) : ( + 0 ? [{ x: chartData.labels, y: chartData.compGuestsSeries, customdata: chartData.comparisonDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Guests', line: { color: '#93c5fd', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, hovertemplate: '%{customdata}
Guests: %{y}' }] : []), + ...(comparison !== 'none' && chartData.compBookingsSeries.length > 0 ? [{ x: chartData.labels, y: chartData.compBookingsSeries, customdata: chartData.comparisonDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Bookings', line: { color: '#3b82f6', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, hovertemplate: '%{customdata}
Bookings: %{y}' }] : []), + { x: chartData.labels, y: chartData.mainGuestsSeries, customdata: chartData.mainDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Current Guests', line: { color: '#f8a5b6', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
Guests: %{y}' }, + { x: chartData.labels, y: chartData.mainBookingsSeries, customdata: chartData.mainDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Current Bookings', line: { color: '#e94560', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
Bookings: %{y}' }, + ] : [ + ...(comparison !== 'none' && chartData.comparisonSeries.length > 0 ? [{ x: chartData.labels, y: chartData.comparisonSeries, customdata: chartData.comparisonDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: comparison === 'previous_year' ? 'Previous Year' : 'Previous Period', line: { color: '#3b82f6', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, hovertemplate: '%{customdata}
%{y}' }] : []), + { x: chartData.labels, y: chartData.mainSeries, customdata: chartData.mainDates, type: 'scatter', mode: 'lines+markers', name: 'Current Period', line: { color: '#e94560', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
%{y}' }, + ]} + layout={{ + autosize: true, height: 400, margin: { l: 50, r: 30, t: 30, b: 50 }, + paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', + font: { family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', color: '#666' }, + xaxis: { showgrid: false, tickangle: -45, nticks: Math.min(chartData.labels.length, 15) }, + yaxis: { title: { text: displayType === 'both' ? 'Count' : (displayType === 'bookings' ? 'Bookings' : 'Guests') }, gridcolor: '#eee', zeroline: false }, + hovermode: 'x unified', showlegend: displayType === 'both' || comparison !== 'none', + legend: { orientation: 'h', y: 1.15 }, + }} + config={{ displayModeBar: true, responsive: true, modeBarButtonsToRemove: ['lasso2d', 'select2d', 'autoScale2d'] }} + style={{ width: '100%', height: '400px' }} + /> + )} +
+ + {mainData && mainData.length > 0 && (() => { + const getVal = (d: BookingsDataPoint) => (displayType === 'guests' || displayType === 'both') ? d.guests_count : d.booking_count + const mainTotal = mainData.reduce((sum, d) => sum + d.booking_count, 0) + const mainGuests = mainData.reduce((sum, d) => sum + d.guests_count, 0) + const mainAvg = mainData.reduce((sum, d) => sum + getVal(d), 0) / mainData.length + const mainPeak = Math.max(...mainData.map(d => getVal(d))) + const hasComparison = comparison !== 'none' && comparisonData && comparisonData.length > 0 + const compTotal = hasComparison ? comparisonData.reduce((sum, d) => sum + d.booking_count, 0) : 0 + const compGuests = hasComparison ? comparisonData.reduce((sum, d) => sum + d.guests_count, 0) : 0 + const compAvg = hasComparison ? comparisonData.reduce((sum, d) => sum + getVal(d), 0) / comparisonData.length : 0 + const compPeak = hasComparison ? Math.max(...comparisonData.map(d => getVal(d))) : 0 + const formatDiff = (current: number, previous: number, isDecimal: boolean = false) => { const diff = current - previous; const sign = diff >= 0 ? '+' : ''; return `${sign}${isDecimal ? diff.toFixed(1) : diff.toLocaleString()}` } + const summaryLabel = displayType === 'both' ? ' (Guests)' : '' + return ( +
+
Total Bookings
{mainTotal.toLocaleString()}
{hasComparison && (
{compTotal.toLocaleString()}= compTotal ? '#16a34a' : '#dc2626' }}>({formatDiff(mainTotal, compTotal)})
)}
+
Total Guests
{mainGuests.toLocaleString()}
{hasComparison && (
{compGuests.toLocaleString()}= compGuests ? '#16a34a' : '#dc2626' }}>({formatDiff(mainGuests, compGuests)})
)}
+
Daily Average{summaryLabel}
{mainAvg.toFixed(1)}
{hasComparison && (
{compAvg.toFixed(1)}= compAvg ? '#16a34a' : '#dc2626' }}>({formatDiff(mainAvg, compAvg, true)})
)}
+
Peak Day{summaryLabel}
{mainPeak.toLocaleString()}
{hasComparison && (
{compPeak.toLocaleString()}= compPeak ? '#16a34a' : '#dc2626' }}>({formatDiff(mainPeak, compPeak)})
)}
+
+ ) + })()} + + {mainData && mainData.length > 0 && ( +
+ + {showTable && ( +
+ + + + + + + {comparison !== 'none' && comparisonData && (<>)} + + + + {mainData.map((row, index) => { + const compRow = comparisonData && index < comparisonData.length ? comparisonData[index] : null + const date = parseDate(row.date) + const dateLabel = consolidation === 'day' ? date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) : consolidation === 'week' ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${new Date(date.getTime() + 6 * 24 * 60 * 60 * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` : date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + return ( + + + + + {comparison !== 'none' && comparisonData && (<>)} + + ) + })} + +
DateBookingsGuestsPrior BookingsPrior Guests
{dateLabel}{row.booking_count}{row.guests_count}{compRow ? compRow.booking_count : '-'}{compRow ? compRow.guests_count : '-'}
+
+ )} +
+ )} +
+ ) +} + +// ============================================ +// GUEST RATES REPORT +// ============================================ + +type RatesDisplayType = 'gross' | 'net' | 'both' + +interface RatesDataPoint { + date: string + guest_rate_total: number + net_booking_rev_total: number + booking_count: number + avg_guest_rate: number | null + avg_net_rate: number | null +} + +const GuestRatesReport: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today); defaultEnd.setDate(today.getDate() - 1) + const defaultStart = new Date(defaultEnd); defaultStart.setMonth(defaultEnd.getMonth() - 1) + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [consolidation, setConsolidation] = useState('day') + const [comparison, setComparison] = useState('previous_year') + const [displayType, setDisplayType] = useState('both') + const [showTable, setShowTable] = useState(false) + + const monthOptions = useMemo(() => getLast12Months(), []) + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10); const month = monthOptions[idx] + if (month) { setStartDate(month.start); setEndDate(month.end) } + } + + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date(); end.setDate(end.getDate() - 1); const start = new Date(end) + switch (type) { + case '7days': start.setDate(end.getDate() - 6); break + case '14days': start.setDate(end.getDate() - 13); break + case '1month': start.setMonth(end.getMonth() - 1); break + case '3months': start.setMonth(end.getMonth() - 3); break + case '6months': start.setMonth(end.getMonth() - 6); break + case '1year': start.setFullYear(end.getFullYear() - 1); break + } + setStartDate(formatDate(start)); setEndDate(formatDate(end)) + } + + const comparisonDates = useMemo(() => { + if (comparison === 'none') return null + const start = parseDate(startDate); const end = parseDate(endDate) + const periodDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1 + if (comparison === 'previous_period') { + const compEnd = new Date(start); compEnd.setDate(start.getDate() - 1) + const compStart = new Date(compEnd); compStart.setDate(compEnd.getDate() - periodDays + 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } else { + const compStart = getComparisonStartDate(start, comparison, consolidation) + const compEnd = new Date(compStart); compEnd.setDate(compStart.getDate() + periodDays - 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } + }, [startDate, endDate, comparison, consolidation]) + + const { data: mainData, isLoading: mainLoading } = useQuery({ + queryKey: ['rates-report', startDate, endDate, consolidation], + queryFn: async () => { + const res = await api.get('/reports/rates', { params: { start_date: startDate, end_date: endDate, consolidation } }) + return res.data + }, + }) + + const { data: comparisonData, isLoading: comparisonLoading } = useQuery({ + queryKey: ['rates-report-comparison', comparisonDates?.start, comparisonDates?.end, consolidation], + queryFn: async () => { + if (!comparisonDates) return [] + const res = await api.get('/reports/rates', { params: { start_date: comparisonDates.start, end_date: comparisonDates.end, consolidation } }) + return res.data + }, + enabled: !!comparisonDates, + }) + + const chartData = useMemo(() => { + if (!mainData) return { labels: [], mainSeries: [], comparisonSeries: [], mainDates: [], comparisonDates: [], mainGrossSeries: [], mainNetSeries: [], compGrossSeries: [], compNetSeries: [] } + + const rangeStart = parseDate(mainData[0]?.date || '') + const rangeEnd = parseDate(mainData[mainData.length - 1]?.date || '') + const rangeMonths = (rangeEnd.getFullYear() - rangeStart.getFullYear()) * 12 + (rangeEnd.getMonth() - rangeStart.getMonth()) + const includeYear = rangeMonths >= 11 + + const formatLabel = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') return includeYear ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' }) : date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + else if (consolidation === 'week') { const weekEnd = new Date(date); weekEnd.setDate(date.getDate() + 6); return includeYear ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}` : `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}` } + else return date.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) + } + + const formatDateWithDay = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') return date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) + else if (consolidation === 'week') { const weekEnd = new Date(date); weekEnd.setDate(date.getDate() + 6); return `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` } + else return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + } + + const labels = mainData.map(d => formatLabel(d.date)) + const mainDates = mainData.map(d => formatDateWithDay(d.date)) + const mainGrossSeries = mainData.map(d => d.guest_rate_total) + const mainNetSeries = mainData.map(d => d.net_booking_rev_total) + const getValue = (d: RatesDataPoint) => displayType === 'net' ? d.net_booking_rev_total : d.guest_rate_total + const mainSeries = mainData.map(d => getValue(d)) + + let comparisonSeries: (number | null)[] = [] + let compGrossSeries: (number | null)[] = [] + let compNetSeries: (number | null)[] = [] + let comparisonDatesArr: string[] = [] + + if (comparisonData && comparisonData.length > 0 && comparison !== 'none') { + comparisonSeries = mainData.map((_, i) => i < comparisonData.length ? getValue(comparisonData[i]) : null) + compGrossSeries = mainData.map((_, i) => i < comparisonData.length ? comparisonData[i].guest_rate_total : null) + compNetSeries = mainData.map((_, i) => i < comparisonData.length ? comparisonData[i].net_booking_rev_total : null) + comparisonDatesArr = mainData.map((_, i) => i < comparisonData.length ? formatDateWithDay(comparisonData[i].date) : '') + } + + return { labels, mainSeries, comparisonSeries, mainDates, comparisonDates: comparisonDatesArr, mainGrossSeries, mainNetSeries, compGrossSeries, compNetSeries } + }, [mainData, comparisonData, consolidation, comparison, displayType]) + + const isLoading = mainLoading || (comparison !== 'none' && comparisonLoading) + const formatCurrency = (value: number) => `£${value.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + + return ( +
+
+
+

Rate Totals Report

+

View gross tariff (calculated amount) and net revenue totals over time

+
+
+ +
+
+ +
+ setStartDate(e.target.value)} style={styles.dateInput} /> + to + setEndDate(e.target.value)} style={styles.dateInput} /> +
+
+
+ +
+ {(['day', 'week', 'month'] as ConsolidationType[]).map((type) => ( + + ))} +
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
+ +
+ Quick select: + {([{ type: '7days', label: '7 days' }, { type: '14days', label: '14 days' }, { type: '1month', label: '1 month' }, { type: '3months', label: '3 months' }, { type: '6months', label: '6 months' }, { type: '1year', label: '1 year' }] as { type: QuickSelectType; label: string }[]).map(({ type, label }) => ( + + ))} + +
+ + {comparison !== 'none' && comparisonDates && ( +
Comparing with: {comparisonDates.start} to {comparisonDates.end}
+ )} + +
+ {isLoading ? ( +
Loading rates data...
+ ) : chartData.labels.length === 0 ? ( +
No rates data available for the selected date range
+ ) : ( + 0 ? [{ x: chartData.labels, y: chartData.compNetSeries, customdata: chartData.comparisonDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Net', line: { color: '#93c5fd', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, hovertemplate: '%{customdata}
Net: £%{y:,.2f}' }] : []), + ...(comparison !== 'none' && chartData.compGrossSeries.length > 0 ? [{ x: chartData.labels, y: chartData.compGrossSeries, customdata: chartData.comparisonDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Gross', line: { color: '#3b82f6', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, hovertemplate: '%{customdata}
Gross: £%{y:,.2f}' }] : []), + { x: chartData.labels, y: chartData.mainNetSeries, customdata: chartData.mainDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Current Net', line: { color: '#f8a5b6', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
Net: £%{y:,.2f}' }, + { x: chartData.labels, y: chartData.mainGrossSeries, customdata: chartData.mainDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Current Gross', line: { color: '#e94560', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
Gross: £%{y:,.2f}' }, + ] : [ + ...(comparison !== 'none' && chartData.comparisonSeries.length > 0 ? [{ x: chartData.labels, y: chartData.comparisonSeries, customdata: chartData.comparisonDates, type: 'scatter' as const, mode: 'lines+markers' as const, name: comparison === 'previous_year' ? 'Previous Year' : 'Previous Period', line: { color: '#3b82f6', width: 2, dash: 'dash' as const }, marker: { size: 6 }, connectgaps: true, hovertemplate: '%{customdata}
£%{y:,.2f}' }] : []), + { x: chartData.labels, y: chartData.mainSeries, customdata: chartData.mainDates, type: 'scatter', mode: 'lines+markers', name: 'Current Period', line: { color: '#e94560', width: 2 }, marker: { size: 6 }, hovertemplate: '%{customdata}
£%{y:,.2f}' }, + ]} + layout={{ + autosize: true, height: 400, margin: { l: 70, r: 30, t: 30, b: 50 }, + paper_bgcolor: 'transparent', plot_bgcolor: 'transparent', + font: { family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', color: '#666' }, + xaxis: { showgrid: false, tickangle: -45, nticks: Math.min(chartData.labels.length, 15) }, + yaxis: { title: { text: displayType === 'both' ? 'Revenue (£)' : (displayType === 'gross' ? 'Gross Revenue (£)' : 'Net Revenue (£)') }, gridcolor: '#eee', zeroline: false, tickprefix: '£' }, + hovermode: 'x unified', showlegend: displayType === 'both' || comparison !== 'none', + legend: { orientation: 'h', y: 1.15 }, + }} + config={{ displayModeBar: true, responsive: true, modeBarButtonsToRemove: ['lasso2d', 'select2d', 'autoScale2d'] }} + style={{ width: '100%', height: '400px' }} + /> + )} +
+ + {mainData && mainData.length > 0 && (() => { + const mainGrossTotal = mainData.reduce((sum, d) => sum + d.guest_rate_total, 0) + const mainNetTotal = mainData.reduce((sum, d) => sum + d.net_booking_rev_total, 0) + const mainBookings = mainData.reduce((sum, d) => sum + d.booking_count, 0) + const mainAvgGross = mainBookings > 0 ? mainGrossTotal / mainBookings : 0 + const mainAvgNet = mainBookings > 0 ? mainNetTotal / mainBookings : 0 + const hasComparison = comparison !== 'none' && comparisonData && comparisonData.length > 0 + const compGrossTotal = hasComparison ? comparisonData.reduce((sum, d) => sum + d.guest_rate_total, 0) : 0 + const compNetTotal = hasComparison ? comparisonData.reduce((sum, d) => sum + d.net_booking_rev_total, 0) : 0 + const compBookings = hasComparison ? comparisonData.reduce((sum, d) => sum + d.booking_count, 0) : 0 + const compAvgGross = compBookings > 0 ? compGrossTotal / compBookings : 0 + const compAvgNet = compBookings > 0 ? compNetTotal / compBookings : 0 + const formatDiff = (current: number, previous: number) => { const diff = current - previous; const sign = diff >= 0 ? '+' : ''; return `${sign}£${Math.abs(diff).toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` } + const formatDiffPercent = (current: number, previous: number) => { if (previous === 0) return '+N/A'; const pctDiff = ((current - previous) / previous) * 100; const sign = pctDiff >= 0 ? '+' : ''; return `${sign}${pctDiff.toFixed(1)}%` } + return ( +
+
Total Gross Rate
{formatCurrency(mainGrossTotal)}
{hasComparison && (
{formatCurrency(compGrossTotal)}= compGrossTotal ? '#16a34a' : '#dc2626' }}>({formatDiffPercent(mainGrossTotal, compGrossTotal)})
)}
+
Total Net-Gross Rate
{formatCurrency(mainNetTotal)}
{hasComparison && (
{formatCurrency(compNetTotal)}= compNetTotal ? '#16a34a' : '#dc2626' }}>({formatDiffPercent(mainNetTotal, compNetTotal)})
)}
+
Avg Gross Rate
{formatCurrency(mainAvgGross)}
{hasComparison && (
{formatCurrency(compAvgGross)}= compAvgGross ? '#16a34a' : '#dc2626' }}>({formatDiff(mainAvgGross, compAvgGross)})
)}
+
Avg Net-Gross Rate
{formatCurrency(mainAvgNet)}
{hasComparison && (
{formatCurrency(compAvgNet)}= compAvgNet ? '#16a34a' : '#dc2626' }}>({formatDiff(mainAvgNet, compAvgNet)})
)}
+
+ ) + })()} + + {mainData && mainData.length > 0 && ( +
+ + {showTable && ( +
+ + + + + + + + + {comparison !== 'none' && comparisonData && (<>)} + + + + {mainData.map((row, index) => { + const compRow = comparisonData && index < comparisonData.length ? comparisonData[index] : null + const date = parseDate(row.date) + const dateLabel = consolidation === 'day' ? date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) : consolidation === 'week' ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${new Date(date.getTime() + 6 * 24 * 60 * 60 * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` : date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + const avgGross = row.booking_count > 0 ? row.guest_rate_total / row.booking_count : 0 + return ( + + + + + + + {comparison !== 'none' && comparisonData && (<>)} + + ) + })} + +
DateGrossNet-GrossBookingsAvg GrossPrior GrossPrior Net-Gross
{dateLabel}{formatCurrency(row.guest_rate_total)}{formatCurrency(row.net_booking_rev_total)}{row.booking_count}{formatCurrency(avgGross)}{compRow ? formatCurrency(compRow.guest_rate_total) : '-'}{compRow ? formatCurrency(compRow.net_booking_rev_total) : '-'}
+
+ )} +
+ )} +
+ ) +} + +type AveRateDisplayType = 'both' | 'gross' | 'net' + +const AverageRatesReport: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() - 1) + const defaultStart = new Date(defaultEnd) + defaultStart.setMonth(defaultEnd.getMonth() - 1) + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [consolidation, setConsolidation] = useState('day') + const [comparison, setComparison] = useState('previous_year') + const [displayType, setDisplayType] = useState('both') + const [showTable, setShowTable] = useState(false) + + const monthOptions = useMemo(() => getLast12Months(), []) + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date() + end.setDate(end.getDate() - 1) + const start = new Date(end) + + switch (type) { + case '7days': + start.setDate(end.getDate() - 6) + break + case '14days': + start.setDate(end.getDate() - 13) + break + case '1month': + start.setMonth(end.getMonth() - 1) + break + case '3months': + start.setMonth(end.getMonth() - 3) + break + case '6months': + start.setMonth(end.getMonth() - 6) + break + case '1year': + start.setFullYear(end.getFullYear() - 1) + break + } + + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const comparisonDates = useMemo(() => { + if (comparison === 'none') return null + + const start = parseDate(startDate) + const end = parseDate(endDate) + const periodDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1 + + if (comparison === 'previous_period') { + const compEnd = new Date(start) + compEnd.setDate(start.getDate() - 1) + const compStart = new Date(compEnd) + compStart.setDate(compEnd.getDate() - periodDays + 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } else { + const compStart = getComparisonStartDate(start, comparison, consolidation) + const compEnd = new Date(compStart) + compEnd.setDate(compStart.getDate() + periodDays - 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } + }, [startDate, endDate, comparison, consolidation]) + + const { data: mainData, isLoading: mainLoading } = useQuery({ + queryKey: ['ave-rates-report', startDate, endDate, consolidation], + queryFn: async () => { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + consolidation, + }) + const response = await api.get(`/reports/rates?${params}`) + return response.data + }, + }) + + const { data: comparisonData, isLoading: comparisonLoading } = useQuery({ + queryKey: ['ave-rates-report-comparison', comparisonDates?.start, comparisonDates?.end, consolidation], + queryFn: async () => { + if (!comparisonDates) return [] + const params = new URLSearchParams({ + start_date: comparisonDates.start, + end_date: comparisonDates.end, + consolidation, + }) + const response = await api.get(`/reports/rates?${params}`) + return response.data + }, + enabled: !!comparisonDates, + }) + + const calcAvgGross = (d: RatesDataPoint) => d.booking_count > 0 ? d.guest_rate_total / d.booking_count : 0 + const calcAvgNet = (d: RatesDataPoint) => d.booking_count > 0 ? d.net_booking_rev_total / d.booking_count : 0 + + const chartData = useMemo(() => { + if (!mainData) return { + labels: [], mainDates: [], comparisonDates: [], + mainAvgGrossSeries: [], mainAvgNetSeries: [], + compAvgGrossSeries: [], compAvgNetSeries: [] + } + + const rangeStart = parseDate(mainData[0]?.date || '') + const rangeEnd = parseDate(mainData[mainData.length - 1]?.date || '') + const rangeMonths = (rangeEnd.getFullYear() - rangeStart.getFullYear()) * 12 + (rangeEnd.getMonth() - rangeStart.getMonth()) + const includeYear = rangeMonths >= 11 + + const formatLabel = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') { + return includeYear + ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' }) + : date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + } else if (consolidation === 'week') { + const weekEnd = new Date(date) + weekEnd.setDate(date.getDate() + 6) + return includeYear + ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}` + : `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}` + } else { + return date.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) + } + } + + const formatDateWithDay = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') { + return date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) + } else if (consolidation === 'week') { + const weekEnd = new Date(date) + weekEnd.setDate(date.getDate() + 6) + return `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` + } else { + return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + } + } + + const labels = mainData.map(d => formatLabel(d.date)) + const mainDates = mainData.map(d => formatDateWithDay(d.date)) + const mainAvgGrossSeries = mainData.map(d => calcAvgGross(d)) + const mainAvgNetSeries = mainData.map(d => calcAvgNet(d)) + + let compAvgGrossSeries: (number | null)[] = [] + let compAvgNetSeries: (number | null)[] = [] + let comparisonDatesArr: string[] = [] + + if (comparisonData && comparisonData.length > 0 && comparison !== 'none') { + compAvgGrossSeries = mainData.map((_, index) => { + if (index < comparisonData.length) return calcAvgGross(comparisonData[index]) + return null + }) + compAvgNetSeries = mainData.map((_, index) => { + if (index < comparisonData.length) return calcAvgNet(comparisonData[index]) + return null + }) + comparisonDatesArr = mainData.map((_, index) => { + if (index < comparisonData.length) return formatDateWithDay(comparisonData[index].date) + return '' + }) + } + + return { + labels, mainDates, comparisonDates: comparisonDatesArr, + mainAvgGrossSeries, mainAvgNetSeries, + compAvgGrossSeries, compAvgNetSeries + } + }, [mainData, comparisonData, consolidation, comparison]) + + const isLoading = mainLoading || (comparison !== 'none' && comparisonLoading) + const formatCurrency = (value: number) => `£${value.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + + return ( +
+
+
+

Average Rate Report

+

Average guest gross and net accommodation rate per booking

+
+
+ +
+
+ +
+ setStartDate(e.target.value)} style={styles.dateInput} /> + to + setEndDate(e.target.value)} style={styles.dateInput} /> +
+
+ +
+ +
+ {(['day', 'week', 'month'] as ConsolidationType[]).map((type) => ( + + ))} +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+ +
+ Quick select: + {[ + { type: '7days' as QuickSelectType, label: '7 days' }, + { type: '14days' as QuickSelectType, label: '14 days' }, + { type: '1month' as QuickSelectType, label: '1 month' }, + { type: '3months' as QuickSelectType, label: '3 months' }, + { type: '6months' as QuickSelectType, label: '6 months' }, + { type: '1year' as QuickSelectType, label: '1 year' }, + ].map(({ type, label }) => ( + + ))} + +
+ + {isLoading ? ( +
Loading...
+ ) : mainData && mainData.length > 0 ? ( +
+
+ Guest Gross: %{y:£,.2f}', + }] : []), + ...(displayType !== 'gross' ? [{ + x: chartData.labels, y: chartData.mainAvgNetSeries, + type: 'scatter' as const, mode: 'lines+markers' as const, name: 'Avg Gross Accom', + line: { color: 'var(--navy)', width: 2 }, marker: { size: 6 }, + text: chartData.mainDates, hovertemplate: '%{text}
Gross Accom: %{y:£,.2f}', + }] : []), + ...(comparison !== 'none' && displayType !== 'net' && chartData.compAvgGrossSeries.length > 0 ? [{ + x: chartData.labels, y: chartData.compAvgGrossSeries, + type: 'scatter' as const, mode: 'lines+markers' as const, + name: `Guest Gross (${comparison === 'previous_year' ? 'PY' : 'Prev'})`, + line: { color: 'var(--gold)', width: 2, dash: 'dash' as const }, marker: { size: 6 }, + text: chartData.comparisonDates, hovertemplate: '%{text}
Guest Gross: %{y:£,.2f}', opacity: 0.6, + }] : []), + ...(comparison !== 'none' && displayType !== 'gross' && chartData.compAvgNetSeries.length > 0 ? [{ + x: chartData.labels, y: chartData.compAvgNetSeries, + type: 'scatter' as const, mode: 'lines+markers' as const, + name: `Gross Accom (${comparison === 'previous_year' ? 'PY' : 'Prev'})`, + line: { color: 'var(--navy)', width: 2, dash: 'dash' as const }, marker: { size: 6 }, + text: chartData.comparisonDates, hovertemplate: '%{text}
Gross Accom: %{y:£,.2f}', opacity: 0.6, + }] : []), + ]} + layout={{ + autosize: true, height: 400, margin: { l: 60, r: 40, t: 20, b: 80 }, + xaxis: { tickangle: -45, tickfont: { size: 11 } }, + yaxis: { title: { text: 'Average Rate (£)' }, tickformat: ',.0f', tickprefix: '£', gridcolor: '#eee' }, + legend: { orientation: 'h', y: -0.25, x: 0.5, xanchor: 'center' }, + hovermode: 'x unified', + }} + config={{ responsive: true, displayModeBar: false }} + style={{ width: '100%' }} + /> +
+ +
+ {(() => { + const totalBookings = mainData.reduce((sum, d) => sum + d.booking_count, 0) + const totalGross = mainData.reduce((sum, d) => sum + d.guest_rate_total, 0) + const totalNet = mainData.reduce((sum, d) => sum + d.net_booking_rev_total, 0) + const avgGross = totalBookings > 0 ? totalGross / totalBookings : 0 + const avgNet = totalBookings > 0 ? totalNet / totalBookings : 0 + + const hasComparison = comparison !== 'none' && comparisonData && comparisonData.length > 0 + const compTotalBookings = hasComparison ? comparisonData.reduce((sum, d) => sum + d.booking_count, 0) : 0 + const compTotalGross = hasComparison ? comparisonData.reduce((sum, d) => sum + d.guest_rate_total, 0) : 0 + const compTotalNet = hasComparison ? comparisonData.reduce((sum, d) => sum + d.net_booking_rev_total, 0) : 0 + const compAvgGross = compTotalBookings > 0 ? compTotalGross / compTotalBookings : 0 + const compAvgNet = compTotalBookings > 0 ? compTotalNet / compTotalBookings : 0 + + const avgGrossChange = hasComparison && compAvgGross > 0 ? ((avgGross - compAvgGross) / compAvgGross) * 100 : null + const avgNetChange = hasComparison && compAvgNet > 0 ? ((avgNet - compAvgNet) / compAvgNet) * 100 : null + + return ( + <> +
+
Avg Guest Gross
+
{formatCurrency(avgGross)}
+ {avgGrossChange !== null && ( +
= 0 ? '#16a34a' : '#dc2626' }}> + {avgGrossChange >= 0 ? '+' : ''}{avgGrossChange.toFixed(1)}% vs {comparison === 'previous_year' ? 'PY' : 'prev'} +
+ )} +
+
+
Avg Gross Accom
+
{formatCurrency(avgNet)}
+ {avgNetChange !== null && ( +
= 0 ? '#16a34a' : '#dc2626' }}> + {avgNetChange >= 0 ? '+' : ''}{avgNetChange.toFixed(1)}% vs {comparison === 'previous_year' ? 'PY' : 'prev'} +
+ )} +
+
+
Total Bookings
+
{totalBookings.toLocaleString()}
+ {hasComparison && compTotalBookings > 0 && ( +
= compTotalBookings ? '#16a34a' : '#dc2626' }}> + {totalBookings >= compTotalBookings ? '+' : ''}{((totalBookings - compTotalBookings) / compTotalBookings * 100).toFixed(1)}% vs {comparison === 'previous_year' ? 'PY' : 'prev'} +
+ )} +
+ + ) + })()} +
+ + + + {showTable && ( +
+ + + + + + + + {comparison !== 'none' && comparisonData && ( + <> + + + + + )} + + + + {mainData.map((row, index) => { + const compRow = comparisonData && comparisonData[index] + const avgGross = row.booking_count > 0 ? row.guest_rate_total / row.booking_count : 0 + const avgNet = row.booking_count > 0 ? row.net_booking_rev_total / row.booking_count : 0 + const compAvgGross = compRow && compRow.booking_count > 0 ? compRow.guest_rate_total / compRow.booking_count : 0 + const compAvgNet = compRow && compRow.booking_count > 0 ? compRow.net_booking_rev_total / compRow.booking_count : 0 + + return ( + + + + + + {comparison !== 'none' && comparisonData && ( + <> + + + + + )} + + ) + })} + +
DateBookingsGuest GrossGross AccomComp BookingsComp Guest GrossComp Gross Accom
{chartData.mainDates[index]}{row.booking_count}{formatCurrency(avgGross)}{formatCurrency(avgNet)}{compRow ? compRow.booking_count : '-'}{compRow ? formatCurrency(compAvgGross) : '-'}{compRow ? formatCurrency(compAvgNet) : '-'}
+
+ )} +
+ ) : ( +
No data available for selected period
+ )} +
+ ) +} + +// ============================================ +// REVENUE REPORT +// ============================================ + +interface RevenueDataPoint { + date: string + accommodation: number + dry: number + wet: number + total: number +} + +const RevenueReport: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() - 1) + const defaultStart = new Date(defaultEnd) + defaultStart.setMonth(defaultEnd.getMonth() - 1) + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [consolidation, setConsolidation] = useState('day') + const [comparison, setComparison] = useState('previous_year') + const [showTable, setShowTable] = useState(false) + const [showBudget, setShowBudget] = useState(false) + + // Generate month options (last 12 months) + const monthOptions = useMemo(() => getLast12Months(), []) + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date() + end.setDate(end.getDate() - 1) + const start = new Date(end) + + switch (type) { + case '7days': + start.setDate(end.getDate() - 6) + break + case '14days': + start.setDate(end.getDate() - 13) + break + case '1month': + start.setMonth(end.getMonth() - 1) + break + case '3months': + start.setMonth(end.getMonth() - 3) + break + case '6months': + start.setMonth(end.getMonth() - 6) + break + case '1year': + start.setFullYear(end.getFullYear() - 1) + break + } + + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const comparisonDates = useMemo(() => { + if (comparison === 'none') return null + + const start = parseDate(startDate) + const end = parseDate(endDate) + const periodDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1 + + if (comparison === 'previous_period') { + const compEnd = new Date(start) + compEnd.setDate(start.getDate() - 1) + const compStart = new Date(compEnd) + compStart.setDate(compEnd.getDate() - periodDays + 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } else { + const compStart = getComparisonStartDate(start, comparison, consolidation) + const compEnd = new Date(compStart) + compEnd.setDate(compStart.getDate() + periodDays - 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } + }, [startDate, endDate, comparison, consolidation]) + + const { data: mainData, isLoading: mainLoading } = useQuery({ + queryKey: ['revenue-report', startDate, endDate, consolidation], + queryFn: async () => { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + consolidation, + }) + const response = await api.get(`/reports/revenue?${params}`) + return response.data + }, + }) + + const { data: comparisonData, isLoading: comparisonLoading } = useQuery({ + queryKey: ['revenue-report-comparison', comparisonDates?.start, comparisonDates?.end, consolidation], + queryFn: async () => { + if (!comparisonDates) return [] + const params = new URLSearchParams({ + start_date: comparisonDates.start, + end_date: comparisonDates.end, + consolidation, + }) + const response = await api.get(`/reports/revenue?${params}`) + return response.data + }, + enabled: !!comparisonDates, + }) + + // Fetch budget data for comparison + const { data: budgetData } = useQuery<{ date: string; budget_type: string; budget_value: number }[]>({ + queryKey: ['revenue-budget', startDate, endDate], + queryFn: async () => { + const params = new URLSearchParams({ + from_date: startDate, + to_date: endDate, + }) + try { + const response = await api.get(`/budget/daily?${params}`) + return response.data + } catch { + return [] + } + }, + enabled: showBudget, + }) + + // Organize budget data by date and type + const budgetByDate = useMemo(() => { + if (!budgetData) return {} + const byDate: Record> = {} + budgetData.forEach(d => { + if (!byDate[d.date]) byDate[d.date] = {} + byDate[d.date][d.budget_type] = d.budget_value + }) + return byDate + }, [budgetData]) + + const chartData = useMemo(() => { + if (!mainData) return { + labels: [], mainDates: [], comparisonDates: [], + mainAccomSeries: [], mainDrySeries: [], mainWetSeries: [], + compAccomSeries: [], compDrySeries: [], compWetSeries: [] + } + + const rangeStart = parseDate(mainData[0]?.date || '') + const rangeEnd = parseDate(mainData[mainData.length - 1]?.date || '') + const rangeMonths = (rangeEnd.getFullYear() - rangeStart.getFullYear()) * 12 + (rangeEnd.getMonth() - rangeStart.getMonth()) + const includeYear = rangeMonths >= 11 + + const formatLabel = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') { + return includeYear + ? date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' }) + : date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + } else if (consolidation === 'week') { + const weekEnd = new Date(date) + weekEnd.setDate(date.getDate() + 6) + return includeYear + ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: '2-digit' })}` + : `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })}` + } else { + return date.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' }) + } + } + + const formatDateWithDay = (dateStr: string): string => { + const date = parseDate(dateStr) + if (consolidation === 'day') { + return date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) + } else if (consolidation === 'week') { + const weekEnd = new Date(date) + weekEnd.setDate(date.getDate() + 6) + return `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${weekEnd.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` + } else { + return date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + } + } + + const labels = mainData.map(d => formatLabel(d.date)) + const mainDates = mainData.map(d => formatDateWithDay(d.date)) + + const mainAccomSeries = mainData.map(d => d.accommodation) + const mainDrySeries = mainData.map(d => d.dry) + const mainWetSeries = mainData.map(d => d.wet) + + let compAccomSeries: (number | null)[] = [] + let compDrySeries: (number | null)[] = [] + let compWetSeries: (number | null)[] = [] + let comparisonDatesArr: string[] = [] + + if (comparisonData && comparisonData.length > 0 && comparison !== 'none') { + compAccomSeries = mainData.map((_, index) => { + if (index < comparisonData.length) return comparisonData[index].accommodation + return null + }) + compDrySeries = mainData.map((_, index) => { + if (index < comparisonData.length) return comparisonData[index].dry + return null + }) + compWetSeries = mainData.map((_, index) => { + if (index < comparisonData.length) return comparisonData[index].wet + return null + }) + comparisonDatesArr = mainData.map((_, index) => { + if (index < comparisonData.length) return formatDateWithDay(comparisonData[index].date) + return '' + }) + } + + // Build budget series from budgetByDate + let budgetAccomSeries: (number | null)[] = [] + let budgetDrySeries: (number | null)[] = [] + let budgetWetSeries: (number | null)[] = [] + + if (showBudget && Object.keys(budgetByDate).length > 0) { + budgetAccomSeries = mainData.map((d) => budgetByDate[d.date]?.net_accom ?? null) + budgetDrySeries = mainData.map((d) => budgetByDate[d.date]?.net_dry ?? null) + budgetWetSeries = mainData.map((d) => budgetByDate[d.date]?.net_wet ?? null) + } + + return { + labels, mainDates, comparisonDates: comparisonDatesArr, + mainAccomSeries, mainDrySeries, mainWetSeries, + compAccomSeries, compDrySeries, compWetSeries, + budgetAccomSeries, budgetDrySeries, budgetWetSeries + } + }, [mainData, comparisonData, consolidation, comparison, showBudget, budgetByDate]) + + const isLoading = mainLoading || (comparison !== 'none' && comparisonLoading) + + const formatCurrency = (value: number) => `£${value.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + + return ( +
+
+
+

Revenue Report

+

+ View net revenue by category (Accommodation, Dry, Wet) over time +

+
+
+ + {/* Controls */} +
+
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ +
+ +
+ {(['day', 'week', 'month'] as ConsolidationType[]).map((type) => ( + + ))} +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ +
+
+
+ + {/* Quick Select */} +
+ Quick select: + {[ + { type: '7days' as QuickSelectType, label: '7 days' }, + { type: '14days' as QuickSelectType, label: '14 days' }, + { type: '1month' as QuickSelectType, label: '1 month' }, + { type: '3months' as QuickSelectType, label: '3 months' }, + { type: '6months' as QuickSelectType, label: '6 months' }, + { type: '1year' as QuickSelectType, label: '1 year' }, + ].map(({ type, label }) => ( + + ))} + +
+ + {/* Comparison Info */} + {comparison !== 'none' && comparisonDates && ( +
+ Comparing with: {comparisonDates.start} to {comparisonDates.end} +
+ )} + + {/* Chart */} +
+ {isLoading ? ( +
Loading revenue data...
+ ) : chartData.labels.length === 0 ? ( +
+ No revenue data available for the selected date range +
+ ) : ( + 0 + ? [{ + x: chartData.labels, + y: chartData.compWetSeries, + customdata: chartData.comparisonDates, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Wet', + line: { color: '#a5d6a7', width: 2, dash: 'dash' as const }, + marker: { size: 6 }, + connectgaps: true, + hovertemplate: '%{customdata}
Wet: £%{y:,.2f}', + }] + : []), + ...(comparison !== 'none' && chartData.compDrySeries.length > 0 + ? [{ + x: chartData.labels, + y: chartData.compDrySeries, + customdata: chartData.comparisonDates, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Dry', + line: { color: '#90caf9', width: 2, dash: 'dash' as const }, + marker: { size: 6 }, + connectgaps: true, + hovertemplate: '%{customdata}
Dry: £%{y:,.2f}', + }] + : []), + ...(comparison !== 'none' && chartData.compAccomSeries.length > 0 + ? [{ + x: chartData.labels, + y: chartData.compAccomSeries, + customdata: chartData.comparisonDates, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: (comparison === 'previous_year' ? 'Prior Year' : 'Prior Period') + ' Accom', + line: { color: '#ce93d8', width: 2, dash: 'dash' as const }, + marker: { size: 6 }, + connectgaps: true, + hovertemplate: '%{customdata}
Accommodation: £%{y:,.2f}', + }] + : []), + // Current period traces (solid) - top layer + { + x: chartData.labels, + y: chartData.mainWetSeries, + customdata: chartData.mainDates, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current Wet', + line: { color: '#4caf50', width: 2 }, + marker: { size: 6 }, + hovertemplate: '%{customdata}
Wet: £%{y:,.2f}', + }, + { + x: chartData.labels, + y: chartData.mainDrySeries, + customdata: chartData.mainDates, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current Dry', + line: { color: '#2196f3', width: 2 }, + marker: { size: 6 }, + hovertemplate: '%{customdata}
Dry: £%{y:,.2f}', + }, + { + x: chartData.labels, + y: chartData.mainAccomSeries, + customdata: chartData.mainDates, + type: 'scatter' as const, + mode: 'lines+markers' as const, + name: 'Current Accom', + line: { color: '#9c27b0', width: 2 }, + marker: { size: 6 }, + hovertemplate: '%{customdata}
Accommodation: £%{y:,.2f}', + }, + // Budget traces - dashed purple + ...(showBudget && chartData.budgetWetSeries && chartData.budgetWetSeries.some(v => v !== null) + ? [{ + x: chartData.labels, + y: chartData.budgetWetSeries, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Budget Wet', + line: { color: '#81c784', width: 2, dash: 'dot' as const }, + hovertemplate: 'Budget Wet: £%{y:,.2f}', + }] + : []), + ...(showBudget && chartData.budgetDrySeries && chartData.budgetDrySeries.some(v => v !== null) + ? [{ + x: chartData.labels, + y: chartData.budgetDrySeries, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Budget Dry', + line: { color: '#64b5f6', width: 2, dash: 'dot' as const }, + hovertemplate: 'Budget Dry: £%{y:,.2f}', + }] + : []), + ...(showBudget && chartData.budgetAccomSeries && chartData.budgetAccomSeries.some(v => v !== null) + ? [{ + x: chartData.labels, + y: chartData.budgetAccomSeries, + type: 'scatter' as const, + mode: 'lines' as const, + name: 'Budget Accom', + line: { color: '#ba68c8', width: 2, dash: 'dot' as const }, + hovertemplate: 'Budget Accom: £%{y:,.2f}', + }] + : []), + ]} + layout={{ + autosize: true, + height: 400, + margin: { l: 70, r: 30, t: 30, b: 50 }, + paper_bgcolor: 'transparent', + plot_bgcolor: 'transparent', + font: { family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', color: '#666' }, + xaxis: { + showgrid: false, + tickangle: -45, + nticks: Math.min(chartData.labels.length, 15), + }, + yaxis: { + title: { text: 'Revenue (£)' }, + gridcolor: '#eee', + zeroline: false, + tickprefix: '£', + }, + hovermode: 'x unified', + showlegend: true, + legend: { orientation: 'h', y: 1.15 }, + }} + config={{ + displayModeBar: true, + responsive: true, + modeBarButtonsToRemove: ['lasso2d', 'select2d', 'autoScale2d'], + }} + style={{ width: '100%', height: '400px' }} + /> + )} +
+ + {/* Summary Stats */} + {mainData && mainData.length > 0 && (() => { + const mainAccomTotal = mainData.reduce((sum, d) => sum + d.accommodation, 0) + const mainDryTotal = mainData.reduce((sum, d) => sum + d.dry, 0) + const mainWetTotal = mainData.reduce((sum, d) => sum + d.wet, 0) + const mainTotal = mainAccomTotal + mainDryTotal + mainWetTotal + + const hasComparison = comparison !== 'none' && comparisonData && comparisonData.length > 0 + + const compAccomTotal = hasComparison ? comparisonData.reduce((sum, d) => sum + d.accommodation, 0) : 0 + const compDryTotal = hasComparison ? comparisonData.reduce((sum, d) => sum + d.dry, 0) : 0 + const compWetTotal = hasComparison ? comparisonData.reduce((sum, d) => sum + d.wet, 0) : 0 + const compTotal = compAccomTotal + compDryTotal + compWetTotal + + const formatDiffPercent = (current: number, previous: number) => { + if (previous === 0) return '+N/A' + const pctDiff = ((current - previous) / previous) * 100 + const sign = pctDiff >= 0 ? '+' : '' + return `${sign}${pctDiff.toFixed(1)}%` + } + + return ( +
+
+
Accommodation
+
{formatCurrency(mainAccomTotal)}
+ {hasComparison && ( +
+ {formatCurrency(compAccomTotal)} + = compAccomTotal ? '#16a34a' : '#dc2626' + }}>({formatDiffPercent(mainAccomTotal, compAccomTotal)}) +
+ )} +
+
+
Dry (Food)
+
{formatCurrency(mainDryTotal)}
+ {hasComparison && ( +
+ {formatCurrency(compDryTotal)} + = compDryTotal ? '#16a34a' : '#dc2626' + }}>({formatDiffPercent(mainDryTotal, compDryTotal)}) +
+ )} +
+
+
Wet (Beverage)
+
{formatCurrency(mainWetTotal)}
+ {hasComparison && ( +
+ {formatCurrency(compWetTotal)} + = compWetTotal ? '#16a34a' : '#dc2626' + }}>({formatDiffPercent(mainWetTotal, compWetTotal)}) +
+ )} +
+
+
Total Revenue
+
{formatCurrency(mainTotal)}
+ {hasComparison && ( +
+ {formatCurrency(compTotal)} + = compTotal ? '#16a34a' : '#dc2626' + }}>({formatDiffPercent(mainTotal, compTotal)}) +
+ )} +
+
+ ) + })()} + + {/* Data Table */} + {mainData && mainData.length > 0 && ( +
+ + {showTable && ( +
+ + + + + + + + + {comparison !== 'none' && comparisonData && ( + <> + + + + + )} + + + + {mainData.map((row, index) => { + const compRow = comparisonData && index < comparisonData.length ? comparisonData[index] : null + const date = parseDate(row.date) + const dateLabel = consolidation === 'day' + ? date.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }) + : consolidation === 'week' + ? `${date.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })} - ${new Date(date.getTime() + 6 * 24 * 60 * 60 * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })}` + : date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) + return ( + + + + + + + {comparison !== 'none' && comparisonData && ( + <> + + + + + )} + + ) + })} + +
DateAccomDryWetTotalPrior AccomPrior DryPrior Wet
{dateLabel}{formatCurrency(row.accommodation)}{formatCurrency(row.dry)}{formatCurrency(row.wet)}{formatCurrency(row.total)}{compRow ? formatCurrency(compRow.accommodation) : '-'}{compRow ? formatCurrency(compRow.dry) : '-'}{compRow ? formatCurrency(compRow.wet) : '-'}
+
+ )} +
+ )} +
+ ) +} + +// ============================================ +// 3D PICKUP VISUALIZATION +// ============================================ + +// ============================================ +// RESTAURANT BOOKINGS REPORT +// ============================================ + +interface RestaurantBookingsDataPoint { + date: string + total_bookings: number + breakfast_bookings: number + lunch_bookings: number + afternoon_bookings: number + dinner_bookings: number + other_bookings: number +} + +const RestaurantBookingsReport: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() - 1) + const defaultStart = new Date(defaultEnd) + defaultStart.setMonth(defaultEnd.getMonth() - 1) + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [consolidation, setConsolidation] = useState('day') + const [comparison, setComparison] = useState('previous_year') + const [servicePeriod, setServicePeriod] = useState<'all' | 'breakfast' | 'lunch' | 'afternoon' | 'dinner'>('all') + + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date() + end.setDate(end.getDate() - 1) + const start = new Date(end) + + switch (type) { + case '7days': start.setDate(end.getDate() - 6); break + case '14days': start.setDate(end.getDate() - 13); break + case '1month': start.setMonth(end.getMonth() - 1); break + case '3months': start.setMonth(end.getMonth() - 3); break + case '6months': start.setMonth(end.getMonth() - 6); break + case '1year': start.setFullYear(end.getFullYear() - 1); break + } + + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const comparisonDates = useMemo(() => { + if (comparison === 'none') return null + + const start = parseDate(startDate) + const end = parseDate(endDate) + const periodDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1 + + if (comparison === 'previous_period') { + const compEnd = new Date(start) + compEnd.setDate(start.getDate() - 1) + const compStart = new Date(compEnd) + compStart.setDate(compEnd.getDate() - periodDays + 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } else { + const compStart = getComparisonStartDate(start, comparison, consolidation) + const compEnd = new Date(compStart) + compEnd.setDate(compStart.getDate() + periodDays - 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } + }, [startDate, endDate, comparison, consolidation]) + + const { data: mainData, isLoading: mainLoading } = useQuery({ + queryKey: ['restaurant-bookings', startDate, endDate, consolidation], + queryFn: async () => { + const params = new URLSearchParams({ start_date: startDate, end_date: endDate, consolidation }) + const response = await api.get(`/reports/restaurant-bookings?${params}`) + return response.data + }, + }) + + const { data: comparisonData } = useQuery({ + queryKey: ['restaurant-bookings-comparison', comparisonDates?.start, comparisonDates?.end, consolidation], + queryFn: async () => { + if (!comparisonDates) return [] + const params = new URLSearchParams({ start_date: comparisonDates.start, end_date: comparisonDates.end, consolidation }) + const response = await api.get(`/reports/restaurant-bookings?${params}`) + return response.data + }, + enabled: !!comparisonDates, + }) + + const chartData = useMemo(() => { + if (!mainData) return { labels: [], traces: [] } + + const labels = mainData.map(d => d.date) + + const traces: any[] = [] + + // Add period-specific traces based on filter + if (servicePeriod === 'all' || servicePeriod === 'breakfast') { + traces.push({ + x: labels, + y: mainData.map(d => d.breakfast_bookings), + type: 'bar', + name: 'Breakfast', + marker: { color: '#FF6B6B' }, + }) + } + if (servicePeriod === 'all' || servicePeriod === 'lunch') { + traces.push({ + x: labels, + y: mainData.map(d => d.lunch_bookings), + type: 'bar', + name: 'Lunch', + marker: { color: '#4ECDC4' }, + }) + } + if (servicePeriod === 'all' || servicePeriod === 'afternoon') { + traces.push({ + x: labels, + y: mainData.map(d => d.afternoon_bookings), + type: 'bar', + name: 'Afternoon', + marker: { color: '#45B7D1' }, + }) + } + if (servicePeriod === 'all' || servicePeriod === 'dinner') { + traces.push({ + x: labels, + y: mainData.map(d => d.dinner_bookings), + type: 'bar', + name: 'Dinner', + marker: { color: '#96CEB4' }, + }) + } + + if (comparisonData && comparisonData.length > 0) { + // Get comparison value based on selected period + const getComparisonValue = (d: RestaurantBookingsDataPoint) => { + if (servicePeriod === 'breakfast') return d.breakfast_bookings + if (servicePeriod === 'lunch') return d.lunch_bookings + if (servicePeriod === 'afternoon') return d.afternoon_bookings + if (servicePeriod === 'dinner') return d.dinner_bookings + return d.total_bookings + } + + traces.push({ + x: labels, + y: comparisonData.map(getComparisonValue), + type: 'scatter', + mode: 'lines+markers', + name: servicePeriod === 'all' ? 'Comparison Total' : `Comparison ${servicePeriod.charAt(0).toUpperCase() + servicePeriod.slice(1)}`, + line: { color: 'var(--text-mid)', dash: 'dot', width: 2 }, + marker: { size: 6 }, + }) + } + + return { labels, traces } + }, [mainData, comparisonData, servicePeriod]) + + const totalBookings = mainData?.reduce((sum, d) => sum + d.total_bookings, 0) || 0 + + return ( +
+
+

Restaurant Bookings

+
+ +
+
+ + setStartDate(e.target.value)} style={styles.dateInput} /> +
+ +
+ + setEndDate(e.target.value)} style={styles.dateInput} /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Quick select: + {(['7days', '14days', '1month', '3months', '6months', '1year'] as QuickSelectType[]).map((type) => ( + + ))} +
+ + {mainLoading ? ( +
Loading...
+ ) : ( + <> +
+
+
Total Bookings
+
{totalBookings.toLocaleString()}
+
+
+ +
+ +
+ + )} +
+ ) +} + + +// ============================================ +// RESTAURANT COVERS REPORT +// ============================================ + +interface RestaurantCoversDataPoint { + date: string + total_covers: number + breakfast_covers: number + lunch_covers: number + afternoon_covers: number + dinner_covers: number + other_covers: number + hotel_guest_covers: number + non_hotel_guest_covers: number +} + +const RestaurantCoversReport: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() - 1) + const defaultStart = new Date(defaultEnd) + defaultStart.setMonth(defaultEnd.getMonth() - 1) + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [consolidation, setConsolidation] = useState('day') + const [comparison, setComparison] = useState('previous_year') + const [servicePeriod, setServicePeriod] = useState<'all' | 'breakfast' | 'lunch' | 'afternoon' | 'dinner'>('all') + + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date() + end.setDate(end.getDate() - 1) + const start = new Date(end) + + switch (type) { + case '7days': start.setDate(end.getDate() - 6); break + case '14days': start.setDate(end.getDate() - 13); break + case '1month': start.setMonth(end.getMonth() - 1); break + case '3months': start.setMonth(end.getMonth() - 3); break + case '6months': start.setMonth(end.getMonth() - 6); break + case '1year': start.setFullYear(end.getFullYear() - 1); break + } + + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const comparisonDates = useMemo(() => { + if (comparison === 'none') return null + + const start = parseDate(startDate) + const end = parseDate(endDate) + const periodDays = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000)) + 1 + + if (comparison === 'previous_period') { + const compEnd = new Date(start) + compEnd.setDate(start.getDate() - 1) + const compStart = new Date(compEnd) + compStart.setDate(compEnd.getDate() - periodDays + 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } else { + const compStart = getComparisonStartDate(start, comparison, consolidation) + const compEnd = new Date(compStart) + compEnd.setDate(compStart.getDate() + periodDays - 1) + return { start: formatDate(compStart), end: formatDate(compEnd) } + } + }, [startDate, endDate, comparison, consolidation]) + + const { data: mainData, isLoading: mainLoading } = useQuery({ + queryKey: ['restaurant-covers', startDate, endDate, consolidation], + queryFn: async () => { + const params = new URLSearchParams({ start_date: startDate, end_date: endDate, consolidation }) + const response = await api.get(`/reports/restaurant-covers?${params}`) + return response.data + }, + }) + + const { data: comparisonData } = useQuery({ + queryKey: ['restaurant-covers-comparison', comparisonDates?.start, comparisonDates?.end, consolidation], + queryFn: async () => { + if (!comparisonDates) return [] + const params = new URLSearchParams({ start_date: comparisonDates.start, end_date: comparisonDates.end, consolidation }) + const response = await api.get(`/reports/restaurant-covers?${params}`) + return response.data + }, + enabled: !!comparisonDates, + }) + + const chartData = useMemo(() => { + if (!mainData) return { labels: [], traces: [] } + + const labels = mainData.map(d => d.date) + + const traces: any[] = [] + + // Add period-specific traces based on filter + if (servicePeriod === 'all' || servicePeriod === 'breakfast') { + traces.push({ + x: labels, + y: mainData.map(d => d.breakfast_covers), + type: 'bar', + name: 'Breakfast', + marker: { color: '#FF6B6B' }, + }) + } + if (servicePeriod === 'all' || servicePeriod === 'lunch') { + traces.push({ + x: labels, + y: mainData.map(d => d.lunch_covers), + type: 'bar', + name: 'Lunch', + marker: { color: '#4ECDC4' }, + }) + } + if (servicePeriod === 'all' || servicePeriod === 'afternoon') { + traces.push({ + x: labels, + y: mainData.map(d => d.afternoon_covers), + type: 'bar', + name: 'Afternoon', + marker: { color: '#45B7D1' }, + }) + } + if (servicePeriod === 'all' || servicePeriod === 'dinner') { + traces.push({ + x: labels, + y: mainData.map(d => d.dinner_covers), + type: 'bar', + name: 'Dinner', + marker: { color: '#96CEB4' }, + }) + } + + if (comparisonData && comparisonData.length > 0) { + // Get comparison value based on selected period + const getComparisonValue = (d: RestaurantCoversDataPoint) => { + if (servicePeriod === 'breakfast') return d.breakfast_covers + if (servicePeriod === 'lunch') return d.lunch_covers + if (servicePeriod === 'afternoon') return d.afternoon_covers + if (servicePeriod === 'dinner') return d.dinner_covers + return d.total_covers + } + + traces.push({ + x: labels, + y: comparisonData.map(getComparisonValue), + type: 'scatter', + mode: 'lines+markers', + name: servicePeriod === 'all' ? 'Comparison Total' : `Comparison ${servicePeriod.charAt(0).toUpperCase() + servicePeriod.slice(1)}`, + line: { color: 'var(--text-mid)', dash: 'dot', width: 2 }, + marker: { size: 6 }, + }) + } + + return { labels, traces } + }, [mainData, comparisonData, servicePeriod]) + + const totalCovers = mainData?.reduce((sum, d) => sum + d.total_covers, 0) || 0 + const hotelGuestCovers = mainData?.reduce((sum, d) => sum + d.hotel_guest_covers, 0) || 0 + const nonHotelGuestCovers = mainData?.reduce((sum, d) => sum + d.non_hotel_guest_covers, 0) || 0 + + return ( +
+
+

Restaurant Covers

+
+ +
+
+ + setStartDate(e.target.value)} style={styles.dateInput} /> +
+ +
+ + setEndDate(e.target.value)} style={styles.dateInput} /> +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ Quick select: + {(['7days', '14days', '1month', '3months', '6months', '1year'] as QuickSelectType[]).map((type) => ( + + ))} +
+ + {mainLoading ? ( +
Loading...
+ ) : ( + <> +
+
+
Total Covers
+
{totalCovers.toLocaleString()}
+
+
+
Hotel Guests
+
{hotelGuestCovers.toLocaleString()}
+
+
+
Non-Hotel Guests
+
{nonHotelGuestCovers.toLocaleString()}
+
+
+ +
+ +
+ + )} +
+ ) +} + + +// ============================================ +// PICKUP 3D VISUALIZATION +// ============================================ + +interface Pickup3DData { + start_date: string + end_date: string + metric: string + consolidation: string + arrival_dates: string[] + lead_times: number[] + surface_data: (number | null)[][] + final_values: (number | null)[] +} + +type Pickup3DConsolidationType = 'day' | 'week' + +const PickupVisualization: React.FC = () => { + const today = new Date() + const defaultEnd = new Date(today) + defaultEnd.setDate(today.getDate() - 1) // Yesterday + const defaultStart = new Date(defaultEnd) + defaultStart.setMonth(defaultEnd.getMonth() - 1) // 1 month ago + + const [startDate, setStartDate] = useState(formatDate(defaultStart)) + const [endDate, setEndDate] = useState(formatDate(defaultEnd)) + const [metric, setMetric] = useState<'rooms' | 'occupancy'>('rooms') + const [consolidation, setConsolidation] = useState('day') + + // Generate month options (last 12 months) + const monthOptions = useMemo(() => getLast12Months(), []) + + const handleMonthSelect = (monthIndex: string) => { + if (monthIndex === '') return + const idx = parseInt(monthIndex, 10) + const month = monthOptions[idx] + if (month) { + setStartDate(month.start) + setEndDate(month.end) + } + } + + // Quick select handlers + const handleQuickSelect = (type: QuickSelectType) => { + const end = new Date() + end.setDate(end.getDate() - 1) // Yesterday + const start = new Date(end) + + switch (type) { + case '7days': + start.setDate(end.getDate() - 6) + break + case '14days': + start.setDate(end.getDate() - 13) + break + case '1month': + start.setMonth(end.getMonth() - 1) + break + case '3months': + start.setMonth(end.getMonth() - 3) + break + case '6months': + start.setMonth(end.getMonth() - 6) + break + case '1year': + start.setFullYear(end.getFullYear() - 1) + break + } + + setStartDate(formatDate(start)) + setEndDate(formatDate(end)) + } + + const { data: pickupData, isLoading, error } = useQuery({ + queryKey: ['pickup-3d', startDate, endDate, metric, consolidation], + queryFn: async () => { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + metric: metric, + consolidation: consolidation, + }) + const response = await api.get(`/reports/pickup-3d?${params}`) + return response.data + }, + }) + + // Prepare Plotly data + const plotData = useMemo(() => { + if (!pickupData || !pickupData.surface_data?.length) return null + + const { arrival_dates, lead_times, surface_data, final_values } = pickupData + + // X-axis: Format dates based on consolidation + const xLabels = arrival_dates.map(d => { + const date = new Date(d) + if (consolidation === 'week') { + return `${date.getMonth() + 1}/${date.getDate()}` + } + // For daily, show day number or short date depending on range + if (arrival_dates.length <= 31) { + return date.getDate().toString() + } + return `${date.getMonth() + 1}/${date.getDate()}` + }) + + // Y-axis: Lead times (days out) + const yLabels = lead_times.map(lt => lt.toString()) + + // Z data is already [lead_time][arrival_date] + const z = surface_data + + return { + z, + x: xLabels, + y: yLabels, + finalValues: final_values, + arrivalDates: arrival_dates, + } + }, [pickupData, consolidation]) + + const metricLabel = metric === 'rooms' ? 'Room Nights' : 'Occupancy %' + + // Format date range for title + const formatDateRange = () => { + const start = parseDate(startDate) + const end = parseDate(endDate) + const startStr = start.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) + const endStr = end.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }) + return `${startStr} - ${endStr}` + } + + return ( +
+
+

3D Pickup Visualization

+

+ Visualize how bookings accumulated over time for each arrival date +

+
+ + {/* Controls Row 1: Date Range */} +
+
+ +
+ setStartDate(e.target.value)} + style={styles.dateInput} + /> + to + setEndDate(e.target.value)} + style={styles.dateInput} + /> +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ + {/* Quick Select Row */} +
+ Quick: + {(['7days', '14days', '1month', '3months', '6months', '1year'] as QuickSelectType[]).map((type) => ( + + ))} + +
+ + {/* 3D Chart */} +
+ {isLoading ? ( +
Loading pickup data...
+ ) : error ? ( +
Error loading data. Please try again.
+ ) : !plotData ? ( +
+ No pickup data available for this date range. +
+ ) : ( + ' + + 'Lead Time: %{y} days
' + + `${metricLabel}: %{z:.1f}`, + } as Partial, + // Final values line (d0 - arrival day) + ...(plotData.finalValues.some(v => v !== null && v > 0) ? [{ + type: 'scatter3d' as const, + mode: 'lines+markers' as const, + x: plotData.x, + y: plotData.x.map(() => '0'), // All at lead time 0 + z: plotData.finalValues, + line: { + color: 'rgba(233, 69, 96, 1)', + width: 6, + }, + marker: { + size: 5, + color: 'rgba(233, 69, 96, 1)', + }, + name: 'Final (Day of Arrival)', + hovertemplate: + 'Date: %{x}
' + + `Final ${metricLabel}: %{z:.1f}`, + } as Partial] : []), + ]} + layout={{ + title: { + text: `${metricLabel} Pickup - ${formatDateRange()}`, + font: { size: 16 }, + }, + scene: { + xaxis: { + title: { text: consolidation === 'week' ? 'Week Starting' : 'Arrival Date' }, + tickfont: { size: 10 }, + }, + yaxis: { + title: { text: 'Lead Time (Days Out)' }, + tickfont: { size: 10 }, + autorange: 'reversed' as const, // 0 at front, higher values at back + }, + zaxis: { + title: { text: metricLabel }, + tickfont: { size: 10 }, + }, + camera: { + eye: { x: 1.8, y: -1.8, z: 1.0 }, + }, + }, + margin: { l: 0, r: 0, t: 50, b: 0 }, + paper_bgcolor: 'transparent', + font: { family: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }, + }} + style={{ width: '100%', height: '550px' }} + config={{ + displayModeBar: true, + displaylogo: false, + modeBarButtonsToRemove: ['toImage', 'sendDataToCloud'], + }} + /> + )} +
+ + {/* Explanation */} +
+

How to Read This Chart

+
    +
  • X-axis ({consolidation === 'week' ? 'Week Starting' : 'Arrival Date'}): Each {consolidation === 'week' ? 'week' : 'day'} in the selected range
  • +
  • Y-axis (Lead Time): Days before arrival when bookings were recorded (0 = arrival day, 30 = 30 days before)
  • +
  • Z-axis (Height/Color): {metric === 'rooms' ? 'Number of rooms booked' : 'Occupancy percentage'}
  • +
  • Surface shape: The surface rises as you move toward lead time 0 (front), showing how bookings accumulated over time
  • +
  • Red line at the front: Final values on the day of arrival
  • +
+

+ Tip: Click and drag to rotate the view. Use scroll to zoom. Double-click to reset. +

+
+
+ ) +} + +const pickup3dStyles: Record = { + controls: { + display: 'flex', + flexWrap: 'wrap', + gap: '16px', + marginBottom: '20px', + alignItems: 'flex-end', + }, + controlGroup: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + select: { + padding: `${'12px'} ${'16px'}`, + borderRadius: '8px', + border: `1px solid ${'var(--card-border)'}`, + fontSize: '0.875rem', + background: 'var(--card-bg)', + color: 'var(--text-dark)', + cursor: 'pointer', + minWidth: '120px', + }, + chartContainer: { + marginBottom: '20px', + minHeight: '550px', + background: 'var(--body-bg)', + borderRadius: '12px', + overflow: 'hidden', + }, + error: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '550px', + color: '#dc2626', + }, + explanation: { + padding: '16px', + background: 'var(--body-bg)', + borderRadius: '8px', + marginTop: '16px', + }, + explanationTitle: { + margin: `0 0 ${'12px'} 0`, + fontSize: '1rem', + fontWeight: 600, + color: 'var(--text-dark)', + }, + explanationList: { + margin: 0, + paddingLeft: '20px', + color: '#475569', + fontSize: '0.875rem', + lineHeight: 1.6, + }, + explanationNote: { + margin: `${'12px'} 0 0 0`, + fontSize: '0.75rem', + color: 'var(--text-mid)', + fontStyle: 'italic', + }, +} + +// ============================================ +// STYLES +// ============================================ + diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..f235d5b --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,7469 @@ +import React, { useState, useEffect } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' + +// ── Stack theme compatibility shim (matches old theme.ts keys, new stack values) ── +const colors = { + primary: '#1a1a2e', + primaryLight: '#2d2d44', + primaryDark: '#16213e', + accent: '#c9a84c', + accentHover: '#b8973d', + background: '#f4f5f7', + surface: '#ffffff', + surfaceHover: '#fafafa', + text: '#1e293b', + textSecondary: '#475569', + textMuted: '#64748b', + textLight: '#ffffff', + border: '#e4e8ee', + borderLight: '#eef1f5', + borderFocus: '#1a1a2e', + success: '#16a34a', + successBg: '#dcfce7', + warning: '#f59e0b', + warningBg: '#fef3c7', + error: '#dc2626', + errorBg: '#fee2e2', + info: '#0369a1', + infoBg: '#e0f2fe', +} +const spacing = { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem', xl: '2rem', xxl: '3rem' } +const radius = { sm: '4px', md: '6px', lg: '8px', xl: '12px', full: '9999px' } +const shadows = { + sm: '0 1px 2px rgba(16, 24, 40, 0.06)', + md: '0 2px 8px rgba(16, 24, 40, 0.08)', + lg: '0 4px 20px rgba(16, 24, 40, 0.12)', + xl: '0 10px 40px rgba(16, 24, 40, 0.2)', +} +const typography = { + fontFamily: "'Inter', system-ui, sans-serif", + xs: '0.75rem', sm: '0.875rem', base: '1rem', lg: '1.125rem', + xl: '1.25rem', xxl: '1.5rem', xxxl: '1.75rem', display: '2.5rem', + normal: 400, medium: 500, semibold: 600, bold: 700, +} +const mergeStyles = (...styles: (React.CSSProperties | undefined)[]): React.CSSProperties => + Object.assign({}, ...styles.filter(Boolean)) +const BUTTON_BASE: React.CSSProperties = { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + gap: spacing.sm, padding: `${spacing.sm} ${spacing.md}`, borderRadius: radius.md, + fontSize: typography.sm, fontWeight: typography.semibold, cursor: 'pointer', + transition: 'background 0.2s ease, transform 0.1s ease', border: 'none', outline: 'none', +} +const buttonStyle = ( + variant: 'primary' | 'secondary' | 'outline' | 'ghost' = 'primary', + size: 'small' | 'medium' | 'large' = 'medium' +): React.CSSProperties => { + const variants: Record = { + primary: { background: colors.accent, color: colors.textLight }, + secondary: { background: colors.primary, color: colors.textLight }, + outline: { background: 'transparent', color: colors.primary, border: `1px solid ${colors.border}` }, + ghost: { background: 'transparent', color: colors.text }, + } + const sizes: Record = { + small: { padding: `${spacing.xs} ${spacing.sm}`, fontSize: typography.xs }, + medium: {}, + large: { padding: `${spacing.md} ${spacing.lg}`, fontSize: typography.base }, + } + return mergeStyles(BUTTON_BASE, variants[variant], sizes[size]) +} +const badgeStyle = (status: 'success' | 'warning' | 'error' | 'info' = 'info'): React.CSSProperties => { + const statuses: Record = { + success: { background: colors.successBg, color: colors.success }, + warning: { background: colors.warningBg, color: colors.warning }, + error: { background: colors.errorBg, color: colors.error }, + info: { background: colors.infoBg, color: colors.info }, + } + return mergeStyles({ + display: 'inline-flex', alignItems: 'center', + padding: `${spacing.xs} ${spacing.sm}`, borderRadius: radius.full, + fontSize: typography.xs, fontWeight: typography.medium, + }, statuses[status]) +} + +type SettingsPage = 'newbook' | 'resos' | 'database' | 'special-dates' | 'budget' | 'tax-rates' | 'forecast-snapshots' | 'backup' | 'api-keys' | 'ai-insights' + +const Settings: React.FC = () => { + const [activePage, setActivePage] = useState('newbook') + + const menuItems: { id: SettingsPage; label: string }[] = [ + { id: 'newbook', label: 'Newbook' }, + { id: 'resos', label: 'Resos' }, + { id: 'special-dates', label: 'Special Dates' }, + { id: 'budget', label: 'Budget' }, + { id: 'tax-rates', label: 'Tax Rates' }, + { id: 'forecast-snapshots', label: 'Forecast Snapshots' }, + { id: 'ai-insights', label: 'AI Insights' }, + { id: 'api-keys', label: 'API Keys' }, + { id: 'backup', label: 'Backup & Restore' }, + { id: 'database', label: 'Database Browser' }, + ] + + return ( +
+
+

Settings

+ +
+ +
+ {activePage === 'newbook' && } + {activePage === 'resos' && } + {activePage === 'special-dates' && } + {activePage === 'budget' && } + {activePage === 'tax-rates' && } + {activePage === 'forecast-snapshots' && } + {activePage === 'backup' && } + {activePage === 'database' && } + {activePage === 'ai-insights' && } + {activePage === 'api-keys' && } +
+
+ ) +} + +// ============================================ +// NEWBOOK SETTINGS PAGE +// ============================================ + +interface NewbookSettings { + newbook_api_key: string | null + newbook_api_key_set: boolean + newbook_username: string | null + newbook_password_set: boolean + newbook_region: string | null +} + +interface RoomCategory { + id: number + site_id: string + site_name: string + site_type: string | null + room_count: number + is_included: boolean + display_order: number +} + +// ============================================ +// ROOM CATEGORIES SECTION +// ============================================ + +const RoomCategoriesSection: React.FC = () => { + const queryClient = useQueryClient() + const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') + const [fetchMessage, setFetchMessage] = useState('') + + // Fetch room categories + const { data: roomCategories, isLoading } = useQuery({ + queryKey: ['room-categories'], + queryFn: async () => { + const response = await fetch('/forecasting/api/config/room-categories') + if (!response.ok) return [] + return response.json() + }, + }) + + // Fetch from Newbook API + const handleFetch = async () => { + setFetchStatus('fetching') + setFetchMessage('') + try { + const response = await fetch('/forecasting/api/config/room-categories/fetch', { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setFetchStatus('success') + setFetchMessage(data.message || 'Room categories fetched successfully') + queryClient.invalidateQueries({ queryKey: ['room-categories'] }) + } else { + setFetchStatus('error') + setFetchMessage(data.detail || 'Failed to fetch room categories') + } + } catch { + setFetchStatus('error') + setFetchMessage('Failed to fetch room categories') + } + setTimeout(() => { + setFetchStatus('idle') + setFetchMessage('') + }, 5000) + } + + // Update a single category (toggle included) + const handleToggle = async (category: RoomCategory) => { + try { + await fetch('/forecasting/api/config/room-categories/bulk-update', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + updates: [{ id: category.id, is_included: !category.is_included }] + }) + }) + queryClient.invalidateQueries({ queryKey: ['room-categories'] }) + } catch (err) { + console.error('Failed to update room category', err) + } + } + + // Update display order for a category + const handleOrderChange = async (category: RoomCategory, newOrder: number) => { + try { + await fetch('/forecasting/api/config/room-categories/bulk-update', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + updates: [{ id: category.id, display_order: newOrder }] + }) + }) + queryClient.invalidateQueries({ queryKey: ['room-categories'] }) + } catch (err) { + console.error('Failed to update display order', err) + } + } + + // Select/deselect all + const handleSelectAll = async (include: boolean) => { + if (!roomCategories?.length) return + try { + await fetch('/forecasting/api/config/room-categories/bulk-update', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + updates: roomCategories.map(c => ({ id: c.id, is_included: include })) + }) + }) + queryClient.invalidateQueries({ queryKey: ['room-categories'] }) + } catch (err) { + console.error('Failed to update room categories', err) + } + } + + const includedRooms = roomCategories?.filter(c => c.is_included).reduce((sum, c) => sum + (c.room_count || 0), 0) || 0 + const includedTypes = roomCategories?.filter(c => c.is_included).length || 0 + + return ( +
+

Room Categories (for Occupancy)

+

+ Select which room types to include in occupancy and guest calculations. Exclude overflow rooms etc. +

+ +
+ + {roomCategories && roomCategories.length > 0 && ( + <> + + + + )} +
+ + {fetchMessage && ( +
+ {fetchMessage} +
+ )} + + {isLoading ? ( +
Loading room categories...
+ ) : roomCategories && roomCategories.length > 0 ? ( + <> +
+ + {includedRooms} rooms in {includedTypes} types selected + +
+
+ {roomCategories.map((cat) => ( +
+ handleToggle(cat)} + style={styles.checkbox} + /> + {cat.site_name} + {cat.room_count} rooms + handleOrderChange(cat, parseInt(e.target.value) || 0)} + style={styles.displayOrderInput} + min={0} + title="Display order (lower = first)" + /> +
+ ))} +
+ + ) : ( +
+ No room categories loaded. Click "Fetch Room Categories" to load from Newbook. +
+ )} +
+ ) +} + +// ============================================ +// GL REVENUE MAPPING SECTION +// ============================================ + +interface GLAccount { + id: number + gl_account_id: string + gl_code: string | null + gl_name: string | null + gl_group_id: string | null + gl_group_name: string | null + department: 'accommodation' | 'dry' | 'wet' | null + is_active: boolean +} + +type Department = 'accommodation' | 'dry' | 'wet' + +const DEPARTMENTS: { key: Department; label: string }[] = [ + { key: 'accommodation', label: 'Accommodation' }, + { key: 'dry', label: 'Dry (Food)' }, + { key: 'wet', label: 'Wet (Beverage)' }, +] + +const GLRevenueMappingSection: React.FC = () => { + const queryClient = useQueryClient() + const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') + const [fetchMessage, setFetchMessage] = useState('') + const [modalDepartment, setModalDepartment] = useState(null) + + // Fetch GL accounts + const { data: glAccounts, isLoading } = useQuery({ + queryKey: ['gl-accounts'], + queryFn: async () => { + const response = await fetch('/forecasting/api/config/gl-accounts') + if (!response.ok) return [] + return response.json() + }, + }) + + // Fetch from Newbook API + const handleFetch = async () => { + setFetchStatus('fetching') + setFetchMessage('') + try { + const response = await fetch('/forecasting/api/config/gl-accounts/fetch', { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setFetchStatus('success') + setFetchMessage(data.message || 'GL accounts fetched successfully') + queryClient.invalidateQueries({ queryKey: ['gl-accounts'] }) + } else { + setFetchStatus('error') + setFetchMessage(data.detail || 'Failed to fetch GL accounts') + } + } catch { + setFetchStatus('error') + setFetchMessage('Failed to fetch GL accounts') + } + setTimeout(() => { + setFetchStatus('idle') + setFetchMessage('') + }, 5000) + } + + // Update department for accounts + const handleUpdateDepartments = async (updates: { id: number; department: string | null }[]) => { + try { + await fetch('/forecasting/api/config/gl-accounts/department', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ updates }) + }) + queryClient.invalidateQueries({ queryKey: ['gl-accounts'] }) + } catch (err) { + console.error('Failed to update GL accounts', err) + } + } + + // Get accounts for a specific department + const getAccountsForDepartment = (dept: Department) => + glAccounts?.filter(acc => acc.department === dept) || [] + + // Remove account from department + const handleRemoveFromDepartment = (accountId: number) => { + handleUpdateDepartments([{ id: accountId, department: null }]) + } + + return ( +
+

GL Revenue Mapping

+

+ Map GL accounts to revenue departments for aggregation. Fetch accounts first, then assign to each department. +

+ +
+ + {glAccounts && glAccounts.length > 0 && ( + {glAccounts.length} accounts loaded + )} +
+ + {fetchMessage && ( +
+ {fetchMessage} +
+ )} + + {isLoading ? ( +
Loading GL accounts...
+ ) : glAccounts && glAccounts.length > 0 ? ( +
+ {DEPARTMENTS.map(dept => { + const deptAccounts = getAccountsForDepartment(dept.key) + return ( +
+
+ {dept.label} + {deptAccounts.length} accounts +
+
+ {deptAccounts.length === 0 ? ( +
No accounts mapped
+ ) : ( + deptAccounts.map(acc => ( +
+ + {acc.gl_name || acc.gl_code || acc.gl_account_id} + + {acc.gl_code && {acc.gl_code}} + +
+ )) + )} +
+ +
+ ) + })} +
+ ) : ( +
+ No GL accounts loaded. Click "Fetch GL Accounts" to load from Newbook. +
+ )} + + {/* GL Account Selection Modal */} + {modalDepartment && glAccounts && ( + d.key === modalDepartment)?.label || ''} + glAccounts={glAccounts} + onUpdate={handleUpdateDepartments} + onClose={() => setModalDepartment(null)} + /> + )} +
+ ) +} + +// GL Account Selection Modal Component +interface GLAccountModalProps { + department: Department + departmentLabel: string + glAccounts: GLAccount[] + onUpdate: (updates: { id: number; department: string | null }[]) => void + onClose: () => void +} + +const GLAccountModal: React.FC = ({ + department, + departmentLabel, + glAccounts, + onUpdate, + onClose +}) => { + // Group accounts by gl_group_name + const grouped: Record = {} + glAccounts.forEach(acc => { + const groupName = acc.gl_group_name || 'Ungrouped' + if (!grouped[groupName]) grouped[groupName] = [] + grouped[groupName].push(acc) + }) + const sortedGroups = Object.keys(grouped).sort() + + // Check if account is selected for this department + const isSelected = (acc: GLAccount) => acc.department === department + + // Toggle single account + const handleToggle = (acc: GLAccount) => { + if (isSelected(acc)) { + onUpdate([{ id: acc.id, department: null }]) + } else { + onUpdate([{ id: acc.id, department }]) + } + } + + // Toggle entire group + const handleGroupToggle = (groupAccounts: GLAccount[]) => { + const allSelected = groupAccounts.every(acc => acc.department === department) + if (allSelected) { + // Deselect all in group + onUpdate(groupAccounts.map(acc => ({ id: acc.id, department: null }))) + } else { + // Select all in group + onUpdate(groupAccounts.map(acc => ({ id: acc.id, department }))) + } + } + + return ( +
+
e.stopPropagation()}> +
+

Select GL Accounts for {departmentLabel}

+ +
+
+ {sortedGroups.map(groupName => { + const groupAccounts = grouped[groupName] + const selectedCount = groupAccounts.filter(acc => acc.department === department).length + const allSelected = selectedCount === groupAccounts.length + const someSelected = selectedCount > 0 && !allSelected + + return ( +
+ +
+ {groupAccounts.map(acc => ( + + ))} +
+
+ ) + })} +
+
+ +
+
+
+ ) +} + +// ============================================ +// BOOKINGS DATA SYNC SECTION +// ============================================ + +interface SyncStatus { + last_successful_sync: { + completed_at: string | null + records_fetched: number | null + records_created: number | null + triggered_by: string | null + } | null + last_sync: { + started_at: string | null + completed_at: string | null + status: string | null + records_fetched: number | null + error_message: string | null + triggered_by: string | null + } | null + auto_sync: { + enabled: boolean + type: string + time: string + } + total_records: number +} + +interface SyncLog { + id: number + started_at: string + completed_at: string | null + status: string + records_fetched: number | null + records_created: number | null + date_from: string | null + date_to: string | null + error_message: string | null + triggered_by: string | null +} + +const BookingsDataSyncSection: React.FC = () => { + const queryClient = useQueryClient() + const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') + const [syncMessage, setSyncMessage] = useState('') + const [fromDate, setFromDate] = useState('') + const [toDate, setToDate] = useState('') + const [autoEnabled, setAutoEnabled] = useState(false) + const [autoType, setAutoType] = useState('incremental') + const [syncTime, setSyncTime] = useState('05:00') + + // Fetch sync status + const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ + queryKey: ['bookings-sync-status'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/bookings-data/status') + if (!response.ok) throw new Error('Failed to fetch status') + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Fetch sync logs + const { data: logs, isLoading: logsLoading } = useQuery({ + queryKey: ['bookings-sync-logs'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/bookings-data/logs?limit=5') + if (!response.ok) return [] + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Update local state when status loads + React.useEffect(() => { + if (status?.auto_sync) { + setAutoEnabled(status.auto_sync.enabled) + setAutoType(status.auto_sync.type) + setSyncTime(status.auto_sync.time || '05:00') + } + // Check if sync is currently running + if (status?.last_sync?.status === 'running') { + setSyncStatus('syncing') + } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { + // Sync completed + setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') + setSyncMessage(status?.last_sync?.status === 'success' + ? `Synced ${status?.last_sync?.records_fetched || 0} bookings` + : status?.last_sync?.error_message || 'Sync failed') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + queryClient.invalidateQueries({ queryKey: ['bookings-sync-status'] }) + queryClient.invalidateQueries({ queryKey: ['bookings-sync-logs'] }) + } + }, [status, syncStatus, queryClient]) + + // Trigger sync + const handleSync = async (mode: 'incremental' | 'staying_range') => { + setSyncStatus('syncing') + setSyncMessage('') + try { + let url = `/forecasting/api/sync/bookings-data/sync?sync_mode=${mode}` + if (mode === 'staying_range' && fromDate && toDate) { + url += `&from_date=${fromDate}&to_date=${toDate}` + } + const response = await fetch(url, { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setSyncMessage(data.message || 'Sync started...') + // Keep polling via refetchInterval + } else { + setSyncStatus('error') + setSyncMessage(data.detail || 'Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } catch { + setSyncStatus('error') + setSyncMessage('Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } + + // Update auto sync config + const handleAutoConfigSave = async () => { + try { + const response = await fetch('/forecasting/api/sync/bookings-data/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + enabled: autoEnabled, + sync_type: autoType, + sync_time: syncTime + }) + }) + if (response.ok) { + refetchStatus() + } + } catch (err) { + console.error('Failed to update config', err) + } + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString() + } + + const formatTrigger = (trigger: string | null) => { + if (!trigger) return '-' + if (trigger.startsWith('user:')) return trigger.replace('user:', '') + if (trigger === 'scheduler') return 'Auto' + return trigger + } + + return ( +
+

Bookings Data Sync

+

+ Sync booking data from Newbook. Use date range for specific periods, or incremental for recent changes. +

+ + {statusLoading ? ( +
Loading sync status...
+ ) : ( + <> + {/* Status summary */} +
+
+ Total Records + {status?.total_records?.toLocaleString() || 0} +
+
+ Last Sync + + {status?.last_successful_sync?.completed_at + ? formatDate(status.last_successful_sync.completed_at) + : 'Never'} + +
+
+ Auto Sync + + {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'} + +
+
+ + {/* Sync controls */} +
+ {/* Incremental sync */} +
+

Incremental Update

+

+ Fetches bookings modified since last sync (or last 7 days if no history). +

+ +
+ + {/* Date range sync */} +
+

Date Range Sync

+

+ Fetches bookings staying during the specified date range. +

+
+ setFromDate(e.target.value)} + style={styles.dateInput} + /> + to + setToDate(e.target.value)} + style={styles.dateInput} + /> +
+ +
+ + {/* Auto sync config */} +
+

Automatic Sync

+

+ Enable scheduled daily sync at configured time. +

+ +
+ + at + setSyncTime(e.target.value)} + style={styles.syncTimeInput} + disabled={!autoEnabled} + /> +
+ +
+
+ + {/* Sync message */} + {syncMessage && ( +
+ {syncMessage} +
+ )} + + {/* Recent sync logs */} +
+

Recent Syncs

+ {logsLoading ? ( +
Loading logs...
+ ) : logs && logs.length > 0 ? ( +
+ {logs.map((log) => ( +
+
+ + {log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} + + {formatDate(log.started_at)} + + {log.records_fetched !== null ? `${log.records_fetched} fetched` : ''} + {log.records_created !== null ? `, ${log.records_created} new` : ''} + +
+
+ {formatTrigger(log.triggered_by)} + {log.date_from && log.date_to && ( + {log.date_from} → {log.date_to} + )} +
+ {log.error_message && ( +
{log.error_message}
+ )} +
+ ))} +
+ ) : ( +
No sync history yet.
+ )} +
+ + )} +
+ ) +} + +// ============================================ +// OCCUPANCY DATA SYNC SECTION +// ============================================ + +interface OccupancySyncStatus { + last_successful_sync: { + completed_at: string | null + records_fetched: number | null + records_created: number | null + date_from: string | null + date_to: string | null + triggered_by: string | null + } | null + last_sync: { + started_at: string | null + completed_at: string | null + status: string | null + records_fetched: number | null + date_from: string | null + date_to: string | null + error_message: string | null + triggered_by: string | null + } | null + auto_sync: { + enabled: boolean + time: string + } + total_records: number + data_range: { + from: string | null + to: string | null + } +} + +const OccupancyDataSyncSection: React.FC = () => { + const queryClient = useQueryClient() + const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') + const [syncMessage, setSyncMessage] = useState('') + const [fromDate, setFromDate] = useState('') + const [toDate, setToDate] = useState('') + const [autoEnabled, setAutoEnabled] = useState(false) + const [syncTime, setSyncTime] = useState('05:00') + + // Fetch sync status + const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ + queryKey: ['occupancy-sync-status'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/occupancy-data/status') + if (!response.ok) throw new Error('Failed to fetch status') + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Fetch sync logs + const { data: logs, isLoading: logsLoading } = useQuery({ + queryKey: ['occupancy-sync-logs'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/occupancy-data/logs?limit=5') + if (!response.ok) return [] + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Update local state when status loads + React.useEffect(() => { + if (status?.auto_sync) { + setAutoEnabled(status.auto_sync.enabled) + setSyncTime(status.auto_sync.time || '05:00') + } + // Check if sync is currently running + if (status?.last_sync?.status === 'running') { + setSyncStatus('syncing') + } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { + // Sync completed + setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') + setSyncMessage(status?.last_sync?.status === 'success' + ? `Synced ${status?.last_sync?.records_fetched || 0} records` + : status?.last_sync?.error_message || 'Sync failed') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + queryClient.invalidateQueries({ queryKey: ['occupancy-sync-status'] }) + queryClient.invalidateQueries({ queryKey: ['occupancy-sync-logs'] }) + } + }, [status, syncStatus, queryClient]) + + // Trigger sync + const handleSync = async () => { + setSyncStatus('syncing') + setSyncMessage('') + try { + let url = '/forecasting/api/sync/occupancy-data/sync' + if (fromDate && toDate) { + url += `?from_date=${fromDate}&to_date=${toDate}` + } + const response = await fetch(url, { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setSyncMessage(data.message || 'Sync started...') + // Keep polling via refetchInterval + } else { + setSyncStatus('error') + setSyncMessage(data.detail || 'Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } catch { + setSyncStatus('error') + setSyncMessage('Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } + + // Update auto sync config + const handleAutoConfigSave = async () => { + try { + const response = await fetch('/forecasting/api/sync/occupancy-data/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + enabled: autoEnabled, + sync_time: syncTime + }) + }) + if (response.ok) { + refetchStatus() + } + } catch (err) { + console.error('Failed to update config', err) + } + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString() + } + + const formatTrigger = (trigger: string | null) => { + if (!trigger) return '-' + if (trigger.startsWith('user:')) return trigger.replace('user:', '') + if (trigger === 'scheduler') return 'Auto' + return trigger + } + + return ( +
+

Occupancy Report Data Sync

+

+ Sync occupancy report data from Newbook. This includes available rooms, occupied, maintenance, and revenue per category. +

+ + {statusLoading ? ( +
Loading sync status...
+ ) : ( + <> + {/* Status summary */} +
+
+ Total Records + {status?.total_records?.toLocaleString() || 0} +
+
+ Data Range + + {status?.data_range?.from && status?.data_range?.to + ? `${status.data_range.from} → ${status.data_range.to}` + : 'No data'} + +
+
+ Last Sync + + {status?.last_successful_sync?.completed_at + ? formatDate(status.last_successful_sync.completed_at) + : 'Never'} + +
+
+ Auto Sync + + {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'} + +
+
+ + {/* Sync controls */} +
+ {/* Date range sync */} +
+

Manual Sync

+

+ Sync occupancy data for date range. Default: -7 to +365 days if not specified. +

+
+ setFromDate(e.target.value)} + style={styles.dateInput} + placeholder="From" + /> + to + setToDate(e.target.value)} + style={styles.dateInput} + placeholder="To" + /> +
+ +
+ + {/* Auto sync config */} +
+

Automatic Sync

+

+ Enable scheduled daily sync at configured time (-7 to +365 days). +

+ +
+ Sync at + setSyncTime(e.target.value)} + style={styles.syncTimeInput} + disabled={!autoEnabled} + /> +
+ +
+
+ + {/* Sync message */} + {syncMessage && ( +
+ {syncMessage} +
+ )} + + {/* Recent sync logs */} +
+

Recent Syncs

+ {logsLoading ? ( +
Loading logs...
+ ) : logs && logs.length > 0 ? ( +
+ {logs.map((log) => ( +
+
+ + {log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} + + {formatDate(log.started_at)} + + {log.records_fetched !== null ? `${log.records_fetched} records` : ''} + +
+
+ {formatTrigger(log.triggered_by)} + {log.date_from && log.date_to && ( + {log.date_from} → {log.date_to} + )} +
+ {log.error_message && ( +
{log.error_message}
+ )} +
+ ))} +
+ ) : ( +
No sync history yet.
+ )} +
+ + )} +
+ ) +} + +// ============================================ +// EARNED REVENUE DATA SYNC SECTION +// ============================================ + +interface EarnedRevenueSyncStatus { + last_successful_sync: { + completed_at: string | null + records_fetched: number | null + records_created: number | null + date_from: string | null + date_to: string | null + triggered_by: string | null + } | null + last_sync: { + started_at: string | null + completed_at: string | null + status: string | null + records_fetched: number | null + date_from: string | null + date_to: string | null + error_message: string | null + triggered_by: string | null + } | null + auto_sync: { + enabled: boolean + time: string + } + total_records: number + data_range: { + from: string | null + to: string | null + } +} + +const EarnedRevenueDataSyncSection: React.FC = () => { + const queryClient = useQueryClient() + const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') + const [syncMessage, setSyncMessage] = useState('') + const [fromDate, setFromDate] = useState('') + const [toDate, setToDate] = useState('') + const [autoEnabled, setAutoEnabled] = useState(false) + const [syncTime, setSyncTime] = useState('05:10') + + // Fetch sync status + const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ + queryKey: ['earned-revenue-sync-status'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/earned-revenue-data/status') + if (!response.ok) throw new Error('Failed to fetch status') + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Fetch sync logs + const { data: logs, isLoading: logsLoading } = useQuery({ + queryKey: ['earned-revenue-sync-logs'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/earned-revenue-data/logs?limit=5') + if (!response.ok) return [] + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Update local state when status loads + React.useEffect(() => { + if (status?.auto_sync) { + setAutoEnabled(status.auto_sync.enabled) + setSyncTime(status.auto_sync.time || '05:10') + } + // Check if sync is currently running + if (status?.last_sync?.status === 'running') { + setSyncStatus('syncing') + } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { + // Sync completed + setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') + setSyncMessage(status?.last_sync?.status === 'success' + ? `Synced ${status?.last_sync?.records_fetched || 0} records` + : status?.last_sync?.error_message || 'Sync failed') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + queryClient.invalidateQueries({ queryKey: ['earned-revenue-sync-status'] }) + queryClient.invalidateQueries({ queryKey: ['earned-revenue-sync-logs'] }) + } + }, [status, syncStatus, queryClient]) + + // Trigger sync + const handleSync = async () => { + setSyncStatus('syncing') + setSyncMessage('') + try { + let url = '/forecasting/api/sync/earned-revenue-data/sync' + if (fromDate && toDate) { + url += `?from_date=${fromDate}&to_date=${toDate}` + } + const response = await fetch(url, { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setSyncMessage(data.message || 'Sync started...') + // Keep polling via refetchInterval + } else { + setSyncStatus('error') + setSyncMessage(data.detail || 'Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } catch { + setSyncStatus('error') + setSyncMessage('Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } + + // Update auto sync config + const handleAutoConfigSave = async () => { + try { + const response = await fetch('/forecasting/api/sync/earned-revenue-data/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + enabled: autoEnabled, + sync_time: syncTime + }) + }) + if (response.ok) { + refetchStatus() + } + } catch (err) { + console.error('Failed to update config', err) + } + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString() + } + + const formatTrigger = (trigger: string | null) => { + if (!trigger) return '-' + if (trigger.startsWith('user:')) return trigger.replace('user:', '') + if (trigger === 'scheduler') return 'Auto' + return trigger + } + + return ( +
+

Earned Revenue Data Sync

+

+ Sync earned revenue from Newbook (official GL figures). Used for revenue accuracy tracking. +

+ + {statusLoading ? ( +
Loading sync status...
+ ) : ( + <> + {/* Status summary */} +
+
+ Total Records + {status?.total_records?.toLocaleString() || 0} +
+
+ Data Range + + {status?.data_range?.from && status?.data_range?.to + ? `${status.data_range.from} → ${status.data_range.to}` + : 'No data'} + +
+
+ Last Sync + + {status?.last_successful_sync?.completed_at + ? formatDate(status.last_successful_sync.completed_at) + : 'Never'} + +
+
+ Auto Sync + + {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'} + +
+
+ + {/* Sync controls */} +
+ {/* Date range sync */} +
+

Manual Sync

+

+ Sync earned revenue for date range. Default: last 7 days if not specified. +

+
+ setFromDate(e.target.value)} + style={styles.dateInput} + placeholder="From" + /> + to + setToDate(e.target.value)} + style={styles.dateInput} + placeholder="To" + /> +
+ +
+ + {/* Auto sync config */} +
+

Automatic Sync

+

+ Enable scheduled daily sync at configured time (last 7 days). +

+ +
+ Sync at + setSyncTime(e.target.value)} + style={styles.syncTimeInput} + disabled={!autoEnabled} + /> +
+ +
+
+ + {/* Sync message */} + {syncMessage && ( +
+ {syncMessage} +
+ )} + + {/* Recent sync logs */} +
+

Recent Syncs

+ {logsLoading ? ( +
Loading logs...
+ ) : logs && logs.length > 0 ? ( +
+ {logs.map((log) => ( +
+
+ + {log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} + + {formatDate(log.started_at)} + + {log.records_fetched !== null ? `${log.records_fetched} records` : ''} + +
+
+ {formatTrigger(log.triggered_by)} + {log.date_from && log.date_to && ( + {log.date_from} → {log.date_to} + )} +
+ {log.error_message && ( +
{log.error_message}
+ )} +
+ ))} +
+ ) : ( +
No sync history yet.
+ )} +
+ + )} +
+ ) +} + +// ============================================ +// CURRENT RATES DATA SYNC SECTION (Pickup-V2) +// ============================================ + +interface CurrentRatesSyncStatus { + last_successful_sync: { + completed_at: string | null + records_fetched: number | null + records_created: number | null + triggered_by: string | null + } | null + last_sync: { + started_at: string | null + completed_at: string | null + status: string | null + records_fetched: number | null + error_message: string | null + triggered_by: string | null + } | null + auto_sync: { + enabled: boolean + time: string + } + total_records: number + data_range: { + from: string | null + to: string | null + } + category_counts: Record +} + +const CurrentRatesDataSyncSection: React.FC = () => { + const queryClient = useQueryClient() + const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') + const [syncMessage, setSyncMessage] = useState('') + const [autoEnabled, setAutoEnabled] = useState(false) + const [syncTime, setSyncTime] = useState('05:20') + const [horizonDays, setHorizonDays] = useState('') // Empty = full 720-day run + + // Fetch sync status + const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({ + queryKey: ['current-rates-sync-status'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/current-rates/status') + if (!response.ok) throw new Error('Failed to fetch status') + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Fetch sync logs + const { data: logs, isLoading: logsLoading } = useQuery({ + queryKey: ['current-rates-sync-logs'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/current-rates/logs?limit=5') + if (!response.ok) return [] + return response.json() + }, + refetchInterval: syncStatus === 'syncing' ? 3000 : false, + }) + + // Update local state when status loads + React.useEffect(() => { + if (status?.auto_sync) { + setAutoEnabled(status.auto_sync.enabled) + setSyncTime(status.auto_sync.time || '05:20') + } + // Check if sync is currently running + if (status?.last_sync?.status === 'running') { + setSyncStatus('syncing') + } else if (syncStatus === 'syncing' && status?.last_sync?.status !== 'running') { + // Sync completed + setSyncStatus(status?.last_sync?.status === 'success' ? 'success' : 'error') + setSyncMessage(status?.last_sync?.status === 'success' + ? `Synced ${status?.last_sync?.records_fetched || 0} rates` + : status?.last_sync?.error_message || 'Sync failed') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + queryClient.invalidateQueries({ queryKey: ['current-rates-sync-status'] }) + queryClient.invalidateQueries({ queryKey: ['current-rates-sync-logs'] }) + } + }, [status, syncStatus, queryClient]) + + // Trigger sync + const handleSync = async () => { + setSyncStatus('syncing') + setSyncMessage('') + try { + const body: Record = {} + if (horizonDays && parseInt(horizonDays) > 0) { + body.horizon_days = parseInt(horizonDays) + } + const response = await fetch('/forecasting/api/sync/current-rates/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body) + }) + const data = await response.json() + if (response.ok) { + setSyncMessage(data.message || 'Sync started...') + } else { + setSyncStatus('error') + setSyncMessage(data.detail || 'Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } catch { + setSyncStatus('error') + setSyncMessage('Failed to start sync') + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + } + + // Update auto sync config + const handleAutoConfigSave = async () => { + try { + const response = await fetch('/forecasting/api/sync/current-rates/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + enabled: autoEnabled, + sync_time: syncTime + }) + }) + if (response.ok) { + refetchStatus() + } + } catch (err) { + console.error('Failed to update config', err) + } + } + + // Cancel running sync + const handleCancelSync = async () => { + try { + const response = await fetch('/forecasting/api/sync/current-rates/cancel', { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setSyncStatus('idle') + setSyncMessage(data.message || 'Sync cancelled') + queryClient.invalidateQueries({ queryKey: ['current-rates-sync-status'] }) + queryClient.invalidateQueries({ queryKey: ['current-rates-sync-logs'] }) + setTimeout(() => { + setSyncMessage('') + }, 3000) + } + } catch { + console.error('Failed to cancel sync') + } + } + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString() + } + + const formatTrigger = (trigger: string | null) => { + if (!trigger) return '-' + if (trigger.startsWith('user:')) return trigger.replace('user:', '') + if (trigger === 'scheduler') return 'Auto' + return trigger + } + + return ( +
+

Current Rates Sync (Pickup-V2)

+

+ Fetches current rack rates from Newbook for revenue forecast upper bounds. + Used by Pickup-V2 model for confidence shading. +

+ + {statusLoading ? ( +
Loading sync status...
+ ) : ( + <> + {/* Status summary */} +
+
+ Total Rates + {status?.total_records?.toLocaleString() || 0} +
+
+ Date Range + + {status?.data_range?.from && status?.data_range?.to + ? `${status.data_range.from} → ${status.data_range.to}` + : 'No data'} + +
+
+ Last Sync + + {status?.last_successful_sync?.completed_at + ? formatDate(status.last_successful_sync.completed_at) + : 'Never'} + +
+
+ Auto Sync + + {status?.auto_sync?.enabled ? 'Enabled' : 'Disabled'} + +
+
+ + {/* Category breakdown if data exists */} + {status?.category_counts && Object.keys(status.category_counts).length > 0 && ( +
+ Categories: {Object.entries(status.category_counts).map(([cat, count]) => + `${cat}: ${count} days` + ).join(', ')} +
+ )} + + {/* Sync controls */} +
+ {/* Manual sync */} +
+

Manual Sync

+

+ Fetch rates for all categories. Leave days blank for full 720-day run. +

+
+ Days ahead: + setHorizonDays(e.target.value)} + placeholder="720" + min="1" + max="720" + disabled={syncStatus === 'syncing'} + style={{ + width: '80px', + padding: `${spacing.xs} ${spacing.sm}`, + border: `1px solid ${colors.border}`, + borderRadius: '4px', + fontSize: '0.85rem', + backgroundColor: colors.surface, + color: colors.text, + }} + /> +
+
+ + {syncStatus === 'syncing' && ( + + )} +
+
+ + {/* Auto sync config */} +
+

Automatic Sync

+

+ Enable scheduled daily sync. Fetches rates for next 365 days. +

+ +
+ Sync at + setSyncTime(e.target.value)} + style={styles.syncTimeInput} + disabled={!autoEnabled} + /> +
+ +
+
+ + {/* Sync message */} + {syncMessage && ( +
+ {syncMessage} +
+ )} + + {/* Recent sync logs */} +
+

Recent Syncs

+ {logsLoading ? ( +
Loading logs...
+ ) : logs && logs.length > 0 ? ( +
+ {logs.map((log) => ( +
+
+ + {log.status === 'running' ? '●' : log.status === 'success' ? '✓' : '✗'} + + {formatDate(log.started_at)} + + {log.records_fetched !== null ? `${log.records_fetched} rates` : ''} + +
+
+ {formatTrigger(log.triggered_by)} + {log.status === 'running' && ( + + )} +
+ {log.error_message && ( +
{log.error_message}
+ )} +
+ ))} +
+ ) : ( +
No sync history yet.
+ )} +
+ + )} +
+ ) +} + +const NewbookPage: React.FC = () => { + const [apiKey, setApiKey] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [region, setRegion] = useState('') + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') + const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle') + const [testMessage, setTestMessage] = useState('') + + // Fetch current settings + const { data: settings, isLoading } = useQuery({ + queryKey: ['newbook-settings'], + queryFn: async () => { + const response = await fetch('/forecasting/api/config/settings/newbook') + if (!response.ok) throw new Error('Failed to fetch settings') + return response.json() as Promise + }, + staleTime: 30000, + }) + + // Populate form when settings load + React.useEffect(() => { + if (settings) { + setUsername(settings.newbook_username || '') + setRegion(settings.newbook_region || '') + // Don't populate password/api_key - they're masked + } + }, [settings]) + + const handleSave = async () => { + setSaveStatus('saving') + try { + const response = await fetch('/forecasting/api/config/settings/newbook', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + newbook_api_key: apiKey || undefined, + newbook_username: username || undefined, + newbook_password: password || undefined, + newbook_region: region || undefined, + }) + }) + if (!response.ok) throw new Error('Failed to save') + setSaveStatus('success') + // Clear password fields after save + setApiKey('') + setPassword('') + setTimeout(() => setSaveStatus('idle'), 3000) + } catch { + setSaveStatus('error') + setTimeout(() => setSaveStatus('idle'), 3000) + } + } + + const handleTestConnection = async () => { + setTestStatus('testing') + setTestMessage('') + try { + const response = await fetch('/forecasting/api/config/settings/newbook/test', { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setTestStatus('success') + setTestMessage(data.message || 'Connection successful!') + } else { + setTestStatus('error') + setTestMessage(data.detail || 'Connection failed') + } + } catch { + setTestStatus('error') + setTestMessage('Connection failed') + } + setTimeout(() => { + setTestStatus('idle') + setTestMessage('') + }, 5000) + } + + if (isLoading) { + return ( +
+
Loading settings...
+
+ ) + } + + return ( +
+

Newbook Settings

+

Configure your Newbook API connection for hotel data synchronization.

+ +
+ {/* Left side - API Configuration */} +
+

API Configuration

+ +
+ + + + + + + + +
+ + +
+ + {testMessage && ( +
+ {testMessage} +
+ )} +
+
+ + {/* Right side - Connection Status */} +
+

Connection Status

+
+
+ API Key + + {settings?.newbook_api_key_set ? 'Configured' : 'Not set'} + +
+
+ Username + + {settings?.newbook_username || 'Not set'} + +
+
+ Password + + {settings?.newbook_password_set ? 'Configured' : 'Not set'} + +
+
+ Region + + {settings?.newbook_region || 'Not set'} + +
+
+
+
+ +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + +
+ ) +} + +// ============================================ +// RESOS SETTINGS PAGE +// ============================================ + +interface ResosSettings { + resos_api_key: string | null + resos_api_key_set: boolean +} + +interface ResosCustomField { + id: string + name: string + type: string + values?: string[] +} + +interface CustomFieldMapping { + custom_field_id: string + mapping_type: string +} + +interface ResosOpeningHour { + id: string + name: string + start_time: string + end_time: string +} + +interface OpeningHourMapping { + opening_hour_id: string + period_type: string + display_name?: string +} + +interface ManualBreakfastPeriod { + day_of_week: number + start_time: string + end_time: string + is_active: boolean +} + +const ResosPage: React.FC = () => { + return ( +
+

Resos Settings

+

Configure your Resos API connection and sync settings for restaurant reservation management.

+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + +
+ ) +} + +// ============================================ +// RESOS API CONFIGURATION SECTION +// ============================================ + +const ResosAPIConfigSection: React.FC = () => { + const [apiKey, setApiKey] = useState('') + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') + const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle') + const [testMessage, setTestMessage] = useState('') + + const { data: settings, isLoading } = useQuery({ + queryKey: ['resos-settings'], + queryFn: async () => { + const response = await fetch('/forecasting/api/config/settings/resos') + if (!response.ok) throw new Error('Failed to fetch settings') + return response.json() as Promise + }, + staleTime: 30000, + }) + + const handleSave = async () => { + setSaveStatus('saving') + try { + const response = await fetch('/forecasting/api/config/settings/resos', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resos_api_key: apiKey || undefined, + }) + }) + if (!response.ok) throw new Error('Failed to save') + setSaveStatus('success') + setApiKey('') + setTimeout(() => setSaveStatus('idle'), 3000) + } catch { + setSaveStatus('error') + setTimeout(() => setSaveStatus('idle'), 3000) + } + } + + const handleTestConnection = async () => { + setTestStatus('testing') + setTestMessage('') + try { + const response = await fetch('/forecasting/api/config/settings/resos/test', { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setTestStatus('success') + setTestMessage(data.message || 'Connection successful!') + } else { + setTestStatus('error') + setTestMessage(data.detail || 'Connection failed') + } + } catch { + setTestStatus('error') + setTestMessage('Connection failed') + } + setTimeout(() => { + setTestStatus('idle') + setTestMessage('') + }, 5000) + } + + if (isLoading) { + return
Loading settings...
+ } + + return ( +
+
+

API Configuration

+ +
+ + +
+ + +
+ + {testMessage && ( +
+ {testMessage} +
+ )} +
+
+ +
+

Connection Status

+
+
+ API Key + + {settings?.resos_api_key_set ? 'Configured' : 'Not set'} + +
+
+
+
+ ) +} + +// ============================================ +// RESOS CUSTOM FIELD MAPPING SECTION +// ============================================ + +const ResosCustomFieldMappingSection: React.FC = () => { + const queryClient = useQueryClient() + const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') + const [fetchMessage, setFetchMessage] = useState('') + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') + const [saveMessage, setSaveMessage] = useState('') + const [customFieldMapping, setCustomFieldMapping] = useState>({}) + + const { data: customFields, isLoading } = useQuery({ + queryKey: ['resos-custom-fields-list'], + queryFn: async () => { + const response = await fetch('/forecasting/api/resos/custom-fields') + if (!response.ok) return [] + return response.json() + }, + }) + + const { data: existingMappings } = useQuery({ + queryKey: ['resos-custom-field-mapping'], + queryFn: async () => { + const response = await fetch('/forecasting/api/resos/custom-field-mapping') + if (!response.ok) return [] + return response.json() + }, + }) + + React.useEffect(() => { + if (existingMappings) { + // Convert from array to simple mapping object: {mapping_type: field_id} + const mappingObj: Record = {} + existingMappings.forEach(m => { + mappingObj[m.mapping_type] = m.custom_field_id + }) + setCustomFieldMapping(mappingObj) + } + }, [existingMappings]) + + const handleFetch = async () => { + setFetchStatus('fetching') + setFetchMessage('') + try { + const response = await fetch('/forecasting/api/resos/custom-fields', { + method: 'GET', + }) + const data = await response.json() + if (response.ok) { + setFetchStatus('success') + setFetchMessage('Custom fields fetched successfully') + queryClient.invalidateQueries({ queryKey: ['resos-custom-fields-list'] }) + } else { + setFetchStatus('error') + setFetchMessage(data.detail || 'Failed to fetch custom fields') + } + } catch { + setFetchStatus('error') + setFetchMessage('Failed to fetch custom fields') + } + setTimeout(() => { + setFetchStatus('idle') + setFetchMessage('') + }, 5000) + } + + const handleSaveMappings = async () => { + setSaveStatus('saving') + setSaveMessage('') + try { + // Convert mapping object back to array format for API + const mappingsArray = Object.entries(customFieldMapping) + .filter(([_, fieldId]) => fieldId) // Only include non-empty mappings + .map(([mappingType, fieldId]) => ({ + custom_field_id: fieldId, + mapping_type: mappingType + })) + + const response = await fetch('/forecasting/api/resos/custom-field-mapping', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mappings: mappingsArray }) + }) + if (response.ok) { + setSaveStatus('success') + setSaveMessage('Mappings saved successfully') + queryClient.invalidateQueries({ queryKey: ['resos-custom-field-mapping'] }) + } else { + const data = await response.json() + setSaveStatus('error') + setSaveMessage(data.detail || 'Failed to save mappings') + } + } catch { + setSaveStatus('error') + setSaveMessage('Failed to save mappings') + } + setTimeout(() => { + setSaveStatus('idle') + setSaveMessage('') + }, 5000) + } + + // Define predefined mapping targets (like kitchen app) + const mappingTargets = [ + { key: 'booking_number', label: 'Hotel Booking #', hint: 'Hotel booking reference number from Resos custom field' }, + { key: 'hotel_guest', label: 'Hotel Guest', hint: 'Yes/No field indicating if diner is a hotel guest' }, + { key: 'dbb', label: 'DBB (Dinner B&B)', hint: 'Yes/No field indicating Dinner Bed & Breakfast package guests' }, + { key: 'package', label: 'Package', hint: 'Yes/No field indicating package deal bookings' }, + { key: 'group_exclude', label: 'Group/Exclude', hint: 'Free-text field for group codes and exclusions (e.g., "#12345,NOT-#56789")' }, + { key: 'allergies', label: 'Allergies', hint: 'Multi-select or text field with allergy information' }, + ] + + return ( +
+

Custom Field Mapping

+

+ Map Resos custom fields to booking data fields. Fetch custom fields first, then select which Resos field maps to each target. +

+ +
+ + {customFields && customFields.length > 0 && ( + {customFields.length} fields loaded + )} +
+ + {fetchMessage && ( +
+ {fetchMessage} +
+ )} + + {isLoading ? ( +
Loading custom fields...
+ ) : customFields && customFields.length > 0 ? ( + <> +
+ {mappingTargets.map((target) => ( +
+ + + + {target.hint} + +
+ ))} +
+ +
+ +
+ + {saveMessage && ( +
+ {saveMessage} +
+ )} + + ) : ( +
+ No custom fields loaded. Click "Fetch Custom Fields" to load from Resos. +
+ )} +
+ ) +} + +// ============================================ +// RESOS OPENING HOURS MAPPING SECTION +// ============================================ + +const ResosOpeningHoursMappingSection: React.FC = () => { + const queryClient = useQueryClient() + const [fetchStatus, setFetchStatus] = useState<'idle' | 'fetching' | 'success' | 'error'>('idle') + const [fetchMessage, setFetchMessage] = useState('') + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') + const [saveMessage, setSaveMessage] = useState('') + const [mappings, setMappings] = useState>({}) + + const { data: openingHours, isLoading } = useQuery({ + queryKey: ['resos-opening-hours-list'], + queryFn: async () => { + const response = await fetch('/forecasting/api/resos/opening-hours') + if (!response.ok) return [] + return response.json() + }, + }) + + const { data: existingMappings } = useQuery({ + queryKey: ['resos-opening-hours-mapping'], + queryFn: async () => { + const response = await fetch('/forecasting/api/resos/opening-hours-mapping') + if (!response.ok) return [] + return response.json() + }, + }) + + React.useEffect(() => { + if (existingMappings) { + const mappingObj: Record = {} + existingMappings.forEach(m => { + mappingObj[m.opening_hour_id] = m + }) + setMappings(mappingObj) + } + }, [existingMappings]) + + const handleFetch = async () => { + setFetchStatus('fetching') + setFetchMessage('') + try { + const response = await fetch('/forecasting/api/resos/opening-hours', { + method: 'GET', + }) + const data = await response.json() + if (response.ok) { + setFetchStatus('success') + setFetchMessage('Opening hours fetched successfully') + queryClient.invalidateQueries({ queryKey: ['resos-opening-hours-list'] }) + } else { + setFetchStatus('error') + setFetchMessage(data.detail || 'Failed to fetch opening hours') + } + } catch { + setFetchStatus('error') + setFetchMessage('Failed to fetch opening hours') + } + setTimeout(() => { + setFetchStatus('idle') + setFetchMessage('') + }, 5000) + } + + const handleMappingChange = (hourId: string, periodType: string) => { + setMappings(prev => ({ + ...prev, + [hourId]: { + opening_hour_id: hourId, + period_type: periodType, + display_name: prev[hourId]?.display_name + } + })) + } + + const handleDisplayNameChange = (hourId: string, displayName: string) => { + setMappings(prev => ({ + ...prev, + [hourId]: { + ...prev[hourId], + opening_hour_id: hourId, + period_type: prev[hourId]?.period_type || 'ignore', + display_name: displayName || undefined + } + })) + } + + const handleSaveMappings = async () => { + setSaveStatus('saving') + setSaveMessage('') + try { + const mappingsArray = Object.values(mappings).filter(m => m.period_type !== 'ignore') + const response = await fetch('/forecasting/api/resos/opening-hours-mapping', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ mappings: mappingsArray }) + }) + if (response.ok) { + setSaveStatus('success') + setSaveMessage('Mappings saved successfully') + queryClient.invalidateQueries({ queryKey: ['resos-opening-hours-mapping'] }) + } else { + const data = await response.json() + setSaveStatus('error') + setSaveMessage(data.detail || 'Failed to save mappings') + } + } catch { + setSaveStatus('error') + setSaveMessage('Failed to save mappings') + } + setTimeout(() => { + setSaveStatus('idle') + setSaveMessage('') + }, 5000) + } + + return ( +
+

Opening Hours Mapping

+

+ Map Resos opening hours to meal periods. Fetch opening hours first, then assign period types. +

+ +
+ + {openingHours && openingHours.length > 0 && ( + {openingHours.length} hours loaded + )} +
+ + {fetchMessage && ( +
+ {fetchMessage} +
+ )} + + {isLoading ? ( +
Loading opening hours...
+ ) : openingHours && openingHours.length > 0 ? ( + <> +
+ {openingHours.map((hour) => { + const mapping = mappings[hour.id] + const periodType = mapping?.period_type || 'ignore' + + return ( +
+
+
+ {hour.name} +
+
+ {hour.start_time} - {hour.end_time} +
+
+ + handleDisplayNameChange(hour.id, e.target.value)} + placeholder="Display name (optional)" + style={styles.input} + disabled={periodType === 'ignore'} + /> +
+ ) + })} +
+ +
+ +
+ + {saveMessage && ( +
+ {saveMessage} +
+ )} + + ) : ( +
+ No opening hours loaded. Click "Fetch Opening Hours" to load from Resos. +
+ )} +
+ ) +} + +// ============================================ +// RESOS MANUAL BREAKFAST CONFIGURATION SECTION +// ============================================ + +const ResosManualBreakfastSection: React.FC = () => { + const [enabled, setEnabled] = useState(false) + const [periods, setPeriods] = useState([ + { day_of_week: 1, start_time: '07:00', end_time: '10:00', is_active: true }, + { day_of_week: 2, start_time: '07:00', end_time: '10:00', is_active: true }, + { day_of_week: 3, start_time: '07:00', end_time: '10:00', is_active: true }, + { day_of_week: 4, start_time: '07:00', end_time: '10:00', is_active: true }, + { day_of_week: 5, start_time: '07:00', end_time: '10:00', is_active: true }, + { day_of_week: 6, start_time: '08:00', end_time: '11:00', is_active: true }, + { day_of_week: 0, start_time: '08:00', end_time: '11:00', is_active: true }, + ]) + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') + const [saveMessage, setSaveMessage] = useState('') + + const { data: existingPeriods, isLoading } = useQuery<{ enabled: boolean; periods: ManualBreakfastPeriod[] }>({ + queryKey: ['resos-manual-breakfast'], + queryFn: async () => { + const response = await fetch('/forecasting/api/resos/manual-breakfast-periods') + if (!response.ok) return { enabled: false, periods: [] } + return response.json() + }, + }) + + React.useEffect(() => { + if (existingPeriods && existingPeriods.periods.length > 0) { + setEnabled(existingPeriods.enabled) + setPeriods(existingPeriods.periods) + } + }, [existingPeriods]) + + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] + + const handlePeriodChange = (index: number, field: keyof ManualBreakfastPeriod, value: string | boolean) => { + const newPeriods = [...periods] + newPeriods[index] = { ...newPeriods[index], [field]: value } + setPeriods(newPeriods) + } + + const handleSave = async () => { + setSaveStatus('saving') + setSaveMessage('') + try { + const response = await fetch('/forecasting/api/resos/manual-breakfast-periods', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ enabled, periods }) + }) + if (response.ok) { + setSaveStatus('success') + setSaveMessage('Manual breakfast configuration saved successfully') + } else { + const data = await response.json() + setSaveStatus('error') + setSaveMessage(data.detail || 'Failed to save configuration') + } + } catch { + setSaveStatus('error') + setSaveMessage('Failed to save configuration') + } + setTimeout(() => { + setSaveStatus('idle') + setSaveMessage('') + }, 5000) + } + + if (isLoading) { + return
Loading manual breakfast configuration...
+ } + + return ( +
+

Manual Breakfast Configuration

+

+ Configure breakfast periods manually instead of using Resos opening hours. Useful for custom scheduling. +

+ + + + {enabled && ( + <> +
+ {periods.map((period, index) => ( +
+
+ {dayNames[period.day_of_week]} +
+ handlePeriodChange(index, 'start_time', e.target.value)} + style={styles.input} + disabled={!period.is_active} + /> + handlePeriodChange(index, 'end_time', e.target.value)} + style={styles.input} + disabled={!period.is_active} + /> + +
+ ))} +
+ +
+ +
+ + {saveMessage && ( +
+ {saveMessage} +
+ )} + + )} +
+ ) +} + +// ============================================ +// RESOS AVERAGE SPEND CONFIGURATION SECTION +// ============================================ + +const ResosAverageSpendSection: React.FC = () => { + const queryClient = useQueryClient() + const [breakfastFoodSpend, setBreakfastFoodSpend] = useState('') + const [breakfastDrinksSpend, setBreakfastDrinksSpend] = useState('') + const [lunchFoodSpend, setLunchFoodSpend] = useState('') + const [lunchDrinksSpend, setLunchDrinksSpend] = useState('') + const [dinnerFoodSpend, setDinnerFoodSpend] = useState('') + const [dinnerDrinksSpend, setDinnerDrinksSpend] = useState('') + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') + const [saveMessage, setSaveMessage] = useState('') + + const { data: spendSettings, isLoading } = useQuery({ + queryKey: ['resos-average-spend'], + queryFn: async () => { + const response = await fetch('/forecasting/api/resos/average-spend') + if (!response.ok) return null + return response.json() + }, + }) + + // Update local state when settings load + React.useEffect(() => { + if (spendSettings) { + setBreakfastFoodSpend(spendSettings.breakfast_food_spend?.toString() || '') + setBreakfastDrinksSpend(spendSettings.breakfast_drinks_spend?.toString() || '') + setLunchFoodSpend(spendSettings.lunch_food_spend?.toString() || '') + setLunchDrinksSpend(spendSettings.lunch_drinks_spend?.toString() || '') + setDinnerFoodSpend(spendSettings.dinner_food_spend?.toString() || '') + setDinnerDrinksSpend(spendSettings.dinner_drinks_spend?.toString() || '') + } + }, [spendSettings]) + + const handleSave = async () => { + setSaveStatus('saving') + setSaveMessage('') + try { + const response = await fetch('/forecasting/api/resos/average-spend', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + breakfast_food_spend: parseFloat(breakfastFoodSpend) || 0, + breakfast_drinks_spend: parseFloat(breakfastDrinksSpend) || 0, + lunch_food_spend: parseFloat(lunchFoodSpend) || 0, + lunch_drinks_spend: parseFloat(lunchDrinksSpend) || 0, + dinner_food_spend: parseFloat(dinnerFoodSpend) || 0, + dinner_drinks_spend: parseFloat(dinnerDrinksSpend) || 0 + }) + }) + if (response.ok) { + setSaveStatus('success') + setSaveMessage('Average spend settings saved successfully') + queryClient.invalidateQueries({ queryKey: ['resos-average-spend'] }) + } else { + const data = await response.json() + setSaveStatus('error') + setSaveMessage(data.detail || 'Failed to save settings') + } + } catch { + setSaveStatus('error') + setSaveMessage('Failed to save settings') + } + setTimeout(() => { + setSaveStatus('idle') + setSaveMessage('') + }, 3000) + } + + return ( +
+

Average Spend per Cover (Gross inc VAT)

+

+ Configure average spend values per cover for revenue forecasting. Enter gross amounts (including VAT) - the system will calculate net revenue at 20% VAT automatically. These are interim values until till integration provides live data. +

+ + {isLoading ? ( +
Loading settings...
+ ) : ( + <> +
+ {/* Breakfast Section */} +
+

+ Breakfast +

+
+ + +
+
+ + {/* Lunch Section */} +
+

+ Lunch +

+
+ + +
+
+ + {/* Dinner Section */} +
+

+ Dinner +

+
+ + +
+
+
+ +
+ +
+ + {saveMessage && ( +
+ {saveMessage} +
+ )} + + )} +
+ ) +} + +// ============================================ +// RESOS SYNC CONFIGURATION SECTION +// ============================================ + +const ResosSyncConfigSection: React.FC = () => { + const [autoSyncEnabled, setAutoSyncEnabled] = useState(false) + const [syncTime, setSyncTime] = useState('03:00') + const [fromDate, setFromDate] = useState('') + const [toDate, setToDate] = useState('') + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') + const [saveMessage, setSaveMessage] = useState('') + const [syncStatus, setSyncStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle') + const [syncMessage, setSyncMessage] = useState('') + + const { data: syncConfig, isLoading: configLoading } = useQuery({ + queryKey: ['resos-sync-config'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/resos-bookings/config') + if (!response.ok) return null + return response.json() + }, + }) + + const { data: lastSyncStatus, refetch: refetchStatus } = useQuery({ + queryKey: ['resos-sync-status'], + queryFn: async () => { + const response = await fetch('/forecasting/api/sync/resos-bookings/status') + if (!response.ok) return {} + return response.json() + }, + refetchInterval: 30000, + }) + + React.useEffect(() => { + if (syncConfig) { + setAutoSyncEnabled(syncConfig.auto_sync_enabled || false) + setSyncTime(syncConfig.sync_time || '03:00') + } + }, [syncConfig]) + + React.useEffect(() => { + const today = new Date() + const sevenDaysAgo = new Date(today) + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) + + setFromDate(sevenDaysAgo.toISOString().split('T')[0]) + setToDate(today.toISOString().split('T')[0]) + }, []) + + const handleSaveConfig = async () => { + setSaveStatus('saving') + setSaveMessage('') + try { + const response = await fetch('/forecasting/api/sync/resos-bookings/config', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + auto_sync_enabled: autoSyncEnabled, + sync_time: syncTime + }) + }) + if (response.ok) { + setSaveStatus('success') + setSaveMessage('Sync configuration saved successfully') + } else { + const data = await response.json() + setSaveStatus('error') + setSaveMessage(data.detail || 'Failed to save configuration') + } + } catch { + setSaveStatus('error') + setSaveMessage('Failed to save configuration') + } + setTimeout(() => { + setSaveStatus('idle') + setSaveMessage('') + }, 5000) + } + + const handleTriggerSync = async () => { + setSyncStatus('syncing') + setSyncMessage('') + try { + // Build query params with from_date and to_date + const params = new URLSearchParams() + if (fromDate) params.append('from_date', fromDate) + if (toDate) params.append('to_date', toDate) + + const response = await fetch(`/forecasting/api/sync/resos-bookings/sync?${params.toString()}`, { + method: 'POST', + }) + const data = await response.json() + if (response.ok) { + setSyncStatus('success') + setSyncMessage(data.message || 'Sync triggered successfully') + refetchStatus() + } else { + setSyncStatus('error') + setSyncMessage(data.detail || 'Failed to trigger sync') + } + } catch { + setSyncStatus('error') + setSyncMessage('Failed to trigger sync') + } + setTimeout(() => { + setSyncStatus('idle') + setSyncMessage('') + }, 5000) + } + + if (configLoading) { + return
Loading sync configuration...
+ } + + return ( +
+

Sync Configuration

+

+ Configure automatic synchronization or trigger manual syncs of booking data from Resos. +

+ +
+

+ Automatic Sync +

+ + + + {autoSyncEnabled && ( + + )} + +
+ +
+ + {saveMessage && ( +
+ {saveMessage} +
+ )} +
+ +
+ +
+

+ Manual Sync +

+ +
+ + +
+ +
+ +
+ + {syncMessage && ( +
+ {syncMessage} +
+ )} + + {lastSyncStatus && lastSyncStatus.last_sync && ( +
+

+ Last Sync Status +

+
+
+ Time: {lastSyncStatus.last_sync.completed_at + ? new Date(lastSyncStatus.last_sync.completed_at).toLocaleString() + : lastSyncStatus.last_sync.started_at + ? new Date(lastSyncStatus.last_sync.started_at).toLocaleString() + : 'N/A'} +
+ {lastSyncStatus.last_sync.status && ( +
+ Status:{' '} + + {lastSyncStatus.last_sync.status} + +
+ )} + {lastSyncStatus.last_sync.error_message && ( +
+ Message: {lastSyncStatus.last_sync.error_message} +
+ )} +
+
+ )} +
+
+ ) +} + +// ============================================ +// SPECIAL DATES PAGE +// ============================================ + +interface SpecialDate { + id: number + name: string + pattern_type: 'fixed' | 'nth_weekday' | 'relative_to_date' + fixed_month: number | null + fixed_day: number | null + nth_week: number | null + weekday: number | null + month: number | null + relative_to_month: number | null + relative_to_day: number | null + relative_weekday: number | null + relative_direction: string | null + duration_days: number + is_recurring: boolean + one_off_year: number | null + is_active: boolean + created_at: string +} + +interface ResolvedDate { + name: string + date: string + day_of_week: string +} + +const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] +const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'] +const NTH_OPTIONS = [ + { value: 1, label: 'First' }, + { value: 2, label: 'Second' }, + { value: 3, label: 'Third' }, + { value: 4, label: 'Fourth' }, + { value: -1, label: 'Last' }, +] + +const SpecialDatesPage: React.FC = () => { + const queryClient = useQueryClient() + const [showForm, setShowForm] = useState(false) + const [editingDate, setEditingDate] = useState(null) + const [previewYear, setPreviewYear] = useState(new Date().getFullYear()) + + // Form state + const [formData, setFormData] = useState({ + name: '', + pattern_type: 'fixed' as 'fixed' | 'nth_weekday' | 'relative_to_date', + fixed_month: 1, + fixed_day: 1, + nth_week: 1, + weekday: 0, + month: 1, + relative_to_month: 12, + relative_to_day: 25, + relative_weekday: 4, + relative_direction: 'before', + duration_days: 1, + is_recurring: true, + one_off_year: new Date().getFullYear(), + is_active: true, + }) + + // Fetch special dates + const { data: specialDates, isLoading } = useQuery({ + queryKey: ['special-dates'], + queryFn: async () => { + const response = await fetch('/forecasting/api/settings/special-dates') + if (!response.ok) return [] + return response.json() + }, + }) + + // Fetch preview for year + const { data: previewDates } = useQuery({ + queryKey: ['special-dates-preview', previewYear], + queryFn: async () => { + const response = await fetch(`/forecasting/api/settings/special-dates/preview?year=${previewYear}`) + if (!response.ok) return [] + return response.json() + }, + }) + + // Create mutation + const createMutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const response = await fetch('/forecasting/api/settings/special-dates', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data) + }) + if (!response.ok) throw new Error('Failed to create') + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['special-dates'] }) + queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) + setShowForm(false) + resetForm() + } + }) + + // Update mutation + const updateMutation = useMutation({ + mutationFn: async ({ id, data }: { id: number, data: typeof formData }) => { + const response = await fetch(`/forecasting/api/settings/special-dates/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data) + }) + if (!response.ok) throw new Error('Failed to update') + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['special-dates'] }) + queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) + setShowForm(false) + setEditingDate(null) + resetForm() + } + }) + + // Delete mutation + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + const response = await fetch(`/forecasting/api/settings/special-dates/${id}`, { + method: 'DELETE', + }) + if (!response.ok) throw new Error('Failed to delete') + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['special-dates'] }) + queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) + } + }) + + // Seed defaults mutation + const seedMutation = useMutation({ + mutationFn: async () => { + const response = await fetch('/forecasting/api/settings/special-dates/seed-defaults', { + method: 'POST', + }) + if (!response.ok) throw new Error('Failed to seed') + return response.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['special-dates'] }) + queryClient.invalidateQueries({ queryKey: ['special-dates-preview'] }) + } + }) + + const resetForm = () => { + setFormData({ + name: '', + pattern_type: 'fixed', + fixed_month: 1, + fixed_day: 1, + nth_week: 1, + weekday: 0, + month: 1, + relative_to_month: 12, + relative_to_day: 25, + relative_weekday: 4, + relative_direction: 'before', + duration_days: 1, + is_recurring: true, + one_off_year: new Date().getFullYear(), + is_active: true, + }) + } + + const handleEdit = (sd: SpecialDate) => { + setEditingDate(sd) + setFormData({ + name: sd.name, + pattern_type: sd.pattern_type, + fixed_month: sd.fixed_month || 1, + fixed_day: sd.fixed_day || 1, + nth_week: sd.nth_week || 1, + weekday: sd.weekday || 0, + month: sd.month || 1, + relative_to_month: sd.relative_to_month || 12, + relative_to_day: sd.relative_to_day || 25, + relative_weekday: sd.relative_weekday || 4, + relative_direction: sd.relative_direction || 'before', + duration_days: sd.duration_days || 1, + is_recurring: sd.is_recurring, + one_off_year: sd.one_off_year || new Date().getFullYear(), + is_active: sd.is_active, + }) + setShowForm(true) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (editingDate) { + updateMutation.mutate({ id: editingDate.id, data: formData }) + } else { + createMutation.mutate(formData) + } + } + + const getPatternDescription = (sd: SpecialDate): string => { + if (sd.pattern_type === 'fixed') { + return `${MONTHS[(sd.fixed_month || 1) - 1]} ${sd.fixed_day}` + } else if (sd.pattern_type === 'nth_weekday') { + const nth = NTH_OPTIONS.find(o => o.value === sd.nth_week)?.label || '' + return `${nth} ${WEEKDAYS[sd.weekday || 0]} of ${MONTHS[(sd.month || 1) - 1]}` + } else { + return `${WEEKDAYS[sd.relative_weekday || 0]} ${sd.relative_direction} ${MONTHS[(sd.relative_to_month || 1) - 1]} ${sd.relative_to_day}` + } + } + + return ( +
+
+
+

Special Dates

+

+ Configure custom holidays and events for Prophet forecasting +

+
+
+ {(!specialDates || specialDates.length === 0) && ( + + )} + +
+
+ + {/* Form Modal */} + {showForm && ( +
+

+ {editingDate ? 'Edit Special Date' : 'Add Special Date'} +

+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + style={styles.input} + placeholder="e.g., Valentine's Day" + required + /> +
+ +
+ + +
+ + {/* Fixed Date Fields */} + {formData.pattern_type === 'fixed' && ( + <> +
+ + +
+
+ + setFormData({ ...formData, fixed_day: parseInt(e.target.value) })} + style={styles.input} + /> +
+ + )} + + {/* Nth Weekday Fields */} + {formData.pattern_type === 'nth_weekday' && ( + <> +
+ + +
+
+ + +
+
+ + +
+ + )} + + {/* Relative to Date Fields */} + {formData.pattern_type === 'relative_to_date' && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + setFormData({ ...formData, relative_to_day: parseInt(e.target.value) })} + style={styles.input} + /> +
+ + )} + + {/* Common Fields */} +
+ + setFormData({ ...formData, duration_days: parseInt(e.target.value) })} + style={styles.input} + /> +
+ +
+ + +
+ + {!formData.is_recurring && ( +
+ + setFormData({ ...formData, one_off_year: parseInt(e.target.value) })} + style={styles.input} + /> +
+ )} + +
+ +
+
+ +
+ + +
+
+
+ )} + + {/* Existing Special Dates List */} +
+ + + + + + + + + + + + + {isLoading ? ( + + ) : specialDates && specialDates.length > 0 ? ( + specialDates.map((sd) => ( + + + + + + + + + )) + ) : ( + + )} + +
NamePatternDurationRecurrenceStatusActions
Loading...
{sd.name}{getPatternDescription(sd)}{sd.duration_days} day{sd.duration_days > 1 ? 's' : ''}{sd.is_recurring ? 'Every Year' : `${sd.one_off_year} only`} + + {sd.is_active ? 'Active' : 'Inactive'} + + +
+ + +
+
+ No special dates configured. Click "Seed Defaults" to add common dates. +
+
+ + {/* Preview Section */} +
+
+

Preview

+ +
+ +
+ {previewDates && previewDates.length > 0 ? ( + previewDates.map((pd, i) => ( +
+
{pd.name}
+
+ {pd.day_of_week} {pd.date} +
+
+ )) + ) : ( +

No dates to preview

+ )} +
+
+
+ ) +} + +// ============================================ +// DATABASE PAGE +// ============================================ + +const DatabasePage: React.FC = () => { + return ( +
+

Database Browser

+

+ Browse and manage the database using Adminer. Login with: Server: db, Username: forecast, Password: forecast_secret, Database: forecast_data +

+ +
+