Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
1
backend/api/__init__.py
Normal file
1
backend/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# API routers
|
||||
457
backend/api/accuracy.py
Normal file
457
backend/api/accuracy.py
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
"""
|
||||
Accuracy tracking API endpoints
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_accuracy_summary(
|
||||
from_date: date = Query(..., description="Start date"),
|
||||
to_date: date = Query(..., description="End date"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get model accuracy comparison over date range.
|
||||
Returns MAE, RMSE, MAPE for each model.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
metric_type,
|
||||
COUNT(*) as sample_count,
|
||||
-- Prophet metrics
|
||||
AVG(ABS(prophet_error)) as prophet_mae,
|
||||
SQRT(AVG(prophet_error * prophet_error)) as prophet_rmse,
|
||||
AVG(ABS(prophet_pct_error)) as prophet_mape,
|
||||
-- XGBoost metrics
|
||||
AVG(ABS(xgboost_error)) as xgboost_mae,
|
||||
SQRT(AVG(xgboost_error * xgboost_error)) as xgboost_rmse,
|
||||
AVG(ABS(xgboost_pct_error)) as xgboost_mape,
|
||||
-- CatBoost metrics
|
||||
AVG(ABS(catboost_error)) as catboost_mae,
|
||||
SQRT(AVG(catboost_error * catboost_error)) as catboost_rmse,
|
||||
AVG(ABS(catboost_pct_error)) as catboost_mape,
|
||||
-- Pickup metrics
|
||||
AVG(ABS(pickup_error)) as pickup_mae,
|
||||
SQRT(AVG(pickup_error * pickup_error)) as pickup_rmse,
|
||||
AVG(ABS(pickup_pct_error)) as pickup_mape,
|
||||
-- Best model distribution
|
||||
SUM(CASE WHEN best_model = 'prophet' THEN 1 ELSE 0 END) as prophet_wins,
|
||||
SUM(CASE WHEN best_model = 'xgboost' THEN 1 ELSE 0 END) as xgboost_wins,
|
||||
SUM(CASE WHEN best_model = 'catboost' THEN 1 ELSE 0 END) as catboost_wins,
|
||||
SUM(CASE WHEN best_model = 'pickup' THEN 1 ELSE 0 END) as pickup_wins
|
||||
FROM actual_vs_forecast
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
AND actual_value IS NOT NULL
|
||||
GROUP BY metric_type
|
||||
ORDER BY metric_type
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"metric_type": row.metric_type,
|
||||
"sample_count": row.sample_count,
|
||||
"prophet": {
|
||||
"mae": round(float(row.prophet_mae), 2) if row.prophet_mae else None,
|
||||
"rmse": round(float(row.prophet_rmse), 2) if row.prophet_rmse else None,
|
||||
"mape": round(float(row.prophet_mape), 2) if row.prophet_mape else None,
|
||||
"wins": row.prophet_wins
|
||||
},
|
||||
"xgboost": {
|
||||
"mae": round(float(row.xgboost_mae), 2) if row.xgboost_mae else None,
|
||||
"rmse": round(float(row.xgboost_rmse), 2) if row.xgboost_rmse else None,
|
||||
"mape": round(float(row.xgboost_mape), 2) if row.xgboost_mape else None,
|
||||
"wins": row.xgboost_wins
|
||||
},
|
||||
"catboost": {
|
||||
"mae": round(float(row.catboost_mae), 2) if row.catboost_mae else None,
|
||||
"rmse": round(float(row.catboost_rmse), 2) if row.catboost_rmse else None,
|
||||
"mape": round(float(row.catboost_mape), 2) if row.catboost_mape else None,
|
||||
"wins": row.catboost_wins
|
||||
},
|
||||
"pickup": {
|
||||
"mae": round(float(row.pickup_mae), 2) if row.pickup_mae else None,
|
||||
"rmse": round(float(row.pickup_rmse), 2) if row.pickup_rmse else None,
|
||||
"mape": round(float(row.pickup_mape), 2) if row.pickup_mape else None,
|
||||
"wins": row.pickup_wins
|
||||
}
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/model-weights")
|
||||
async def get_model_weights(
|
||||
metric_code: Optional[str] = Query(None, description="Metric code (if None, returns all metrics)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get MAPE-based model weights used for blending.
|
||||
Shows the actual MAPE scores from backtest data and calculated weights.
|
||||
This is what the blended_tuned_weighted service uses for weighting.
|
||||
"""
|
||||
# Map metric codes to forecast_snapshots metric codes
|
||||
metric_map = {
|
||||
'hotel_occupancy_pct': 'occupancy',
|
||||
'hotel_room_nights': 'rooms',
|
||||
'hotel_guests': 'guests',
|
||||
'hotel_arr': 'arr',
|
||||
'ave_guest_rate': 'ave_guest_rate',
|
||||
'net_accom': 'net_accom',
|
||||
'net_dry': 'net_dry',
|
||||
'net_wet': 'net_wet',
|
||||
'total_rev': 'total_rev',
|
||||
}
|
||||
|
||||
# Pace metrics use pickup model
|
||||
pace_metrics = ['hotel_occupancy_pct', 'hotel_room_nights']
|
||||
|
||||
# If specific metric requested, process just that one
|
||||
metrics_to_process = [metric_code] if metric_code else list(metric_map.keys())
|
||||
|
||||
results = []
|
||||
for metric in metrics_to_process:
|
||||
snapshot_metric = metric_map.get(metric, metric)
|
||||
is_pace_metric = metric in pace_metrics
|
||||
|
||||
# Query MAPE from forecast_snapshots
|
||||
models_to_query = ['prophet', 'xgboost', 'catboost']
|
||||
if is_pace_metric:
|
||||
models_to_query.append('pickup')
|
||||
|
||||
mape_scores = {}
|
||||
sample_counts = {}
|
||||
for model in models_to_query:
|
||||
query = text("""
|
||||
SELECT
|
||||
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape,
|
||||
COUNT(*) as sample_count
|
||||
FROM forecast_snapshots
|
||||
WHERE actual_value IS NOT NULL
|
||||
AND actual_value != 0
|
||||
AND forecast_value IS NOT NULL
|
||||
AND metric_code = :metric_code
|
||||
AND model = :model
|
||||
""")
|
||||
result = await db.execute(query, {"metric_code": snapshot_metric, "model": model})
|
||||
row = result.fetchone()
|
||||
|
||||
if row and row.mape is not None:
|
||||
mape_scores[model] = float(row.mape)
|
||||
sample_counts[model] = int(row.sample_count)
|
||||
else:
|
||||
mape_scores[model] = None
|
||||
sample_counts[model] = 0
|
||||
|
||||
# Calculate inverse-MAPE weights (same logic as blended_tuned_weighted)
|
||||
valid_mapes = {k: v for k, v in mape_scores.items() if v is not None}
|
||||
|
||||
if valid_mapes:
|
||||
# Calculate weights: lower MAPE = higher weight
|
||||
weights = {model: 1.0 / max(mape, 0.1) for model, mape in valid_mapes.items()}
|
||||
weight_sum = sum(weights.values())
|
||||
normalized_weights = {k: v / weight_sum for k, v in weights.items()}
|
||||
else:
|
||||
# Fall back to equal weights
|
||||
normalized_weights = {model: 1.0 / len(models_to_query) for model in models_to_query}
|
||||
|
||||
# Build response
|
||||
model_data = {}
|
||||
for model in models_to_query:
|
||||
model_data[model] = {
|
||||
"mape": round(mape_scores.get(model), 2) if mape_scores.get(model) is not None else None,
|
||||
"weight": round(normalized_weights.get(model, 0), 4),
|
||||
"sample_count": sample_counts.get(model, 0)
|
||||
}
|
||||
|
||||
results.append({
|
||||
"metric_code": metric,
|
||||
"snapshot_metric": snapshot_metric,
|
||||
"is_pace_metric": is_pace_metric,
|
||||
"models": model_data,
|
||||
"total_samples": sum(sample_counts.values())
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/by-model")
|
||||
async def get_accuracy_by_model(
|
||||
model: str = Query(..., description="Model: prophet, xgboost, catboost, pickup"),
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
metric_type: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get detailed accuracy for a specific model.
|
||||
"""
|
||||
column_map = {
|
||||
"prophet": ("prophet_forecast", "prophet_error", "prophet_pct_error"),
|
||||
"xgboost": ("xgboost_forecast", "xgboost_error", "xgboost_pct_error"),
|
||||
"catboost": ("catboost_forecast", "catboost_error", "catboost_pct_error"),
|
||||
"pickup": ("pickup_forecast", "pickup_error", "pickup_pct_error")
|
||||
}
|
||||
|
||||
if model not in column_map:
|
||||
raise ValueError(f"Invalid model: {model}")
|
||||
|
||||
forecast_col, error_col, pct_error_col = column_map[model]
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
date,
|
||||
metric_type,
|
||||
actual_value,
|
||||
{forecast_col} as forecast,
|
||||
{error_col} as error,
|
||||
{pct_error_col} as pct_error,
|
||||
best_model
|
||||
FROM actual_vs_forecast
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
AND actual_value IS NOT NULL
|
||||
"""
|
||||
params = {"from_date": from_date, "to_date": to_date}
|
||||
|
||||
if metric_type:
|
||||
query += " AND metric_type = :metric_type"
|
||||
params["metric_type"] = metric_type
|
||||
|
||||
query += " ORDER BY date, metric_type"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.date,
|
||||
"metric_type": row.metric_type,
|
||||
"actual": float(row.actual_value),
|
||||
"forecast": float(row.forecast) if row.forecast else None,
|
||||
"error": float(row.error) if row.error else None,
|
||||
"pct_error": float(row.pct_error) if row.pct_error else None,
|
||||
"was_best": row.best_model == model
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/best-model")
|
||||
async def get_best_model_analysis(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Analyze which model performs best by metric type and time period.
|
||||
"""
|
||||
query = """
|
||||
WITH model_performance AS (
|
||||
SELECT
|
||||
metric_type,
|
||||
DATE_TRUNC('week', date) as week,
|
||||
best_model,
|
||||
COUNT(*) as count
|
||||
FROM actual_vs_forecast
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
AND actual_value IS NOT NULL
|
||||
GROUP BY metric_type, DATE_TRUNC('week', date), best_model
|
||||
)
|
||||
SELECT
|
||||
metric_type,
|
||||
week,
|
||||
best_model,
|
||||
count,
|
||||
ROUND(count * 100.0 / SUM(count) OVER (PARTITION BY metric_type, week), 1) as pct
|
||||
FROM model_performance
|
||||
ORDER BY metric_type, week, count DESC
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"metric_type": row.metric_type,
|
||||
"week": row.week,
|
||||
"best_model": row.best_model,
|
||||
"count": row.count,
|
||||
"percentage": float(row.pct)
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/by-lead-time")
|
||||
async def get_accuracy_by_lead_time(
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
metric_code: Optional[str] = Query(None, description="Filter by metric code"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get accuracy aggregated by lead time brackets from backtest data.
|
||||
Returns MAPE for each model at different lead times (7, 14, 28, 60, 90 days).
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=365)
|
||||
if to_date is None:
|
||||
to_date = date.today()
|
||||
|
||||
# Define lead time brackets
|
||||
brackets = [
|
||||
(0, 7, "1 week"),
|
||||
(8, 14, "2 weeks"),
|
||||
(15, 28, "1 month"),
|
||||
(29, 60, "2 months"),
|
||||
(61, 90, "3 months"),
|
||||
(91, 180, "6 months"),
|
||||
(181, 365, "1 year")
|
||||
]
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
CASE
|
||||
WHEN days_out <= 7 THEN '1 week'
|
||||
WHEN days_out <= 14 THEN '2 weeks'
|
||||
WHEN days_out <= 28 THEN '1 month'
|
||||
WHEN days_out <= 60 THEN '2 months'
|
||||
WHEN days_out <= 90 THEN '3 months'
|
||||
WHEN days_out <= 180 THEN '6 months'
|
||||
ELSE '1 year'
|
||||
END as lead_time_label,
|
||||
CASE
|
||||
WHEN days_out <= 7 THEN 1
|
||||
WHEN days_out <= 14 THEN 2
|
||||
WHEN days_out <= 28 THEN 3
|
||||
WHEN days_out <= 60 THEN 4
|
||||
WHEN days_out <= 90 THEN 5
|
||||
WHEN days_out <= 180 THEN 6
|
||||
ELSE 7
|
||||
END as sort_order,
|
||||
model,
|
||||
metric_code,
|
||||
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0) * 100)) as mape,
|
||||
AVG(ABS(forecast_value - actual_value)) as mae,
|
||||
COUNT(*) as sample_count
|
||||
FROM forecast_snapshots
|
||||
WHERE target_date BETWEEN :from_date AND :to_date
|
||||
AND actual_value IS NOT NULL
|
||||
"""
|
||||
params = {"from_date": from_date, "to_date": to_date}
|
||||
|
||||
if metric_code:
|
||||
query += " AND metric_code = :metric_code"
|
||||
params["metric_code"] = metric_code
|
||||
|
||||
query += """
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN days_out <= 7 THEN '1 week'
|
||||
WHEN days_out <= 14 THEN '2 weeks'
|
||||
WHEN days_out <= 28 THEN '1 month'
|
||||
WHEN days_out <= 60 THEN '2 months'
|
||||
WHEN days_out <= 90 THEN '3 months'
|
||||
WHEN days_out <= 180 THEN '6 months'
|
||||
ELSE '1 year'
|
||||
END,
|
||||
CASE
|
||||
WHEN days_out <= 7 THEN 1
|
||||
WHEN days_out <= 14 THEN 2
|
||||
WHEN days_out <= 28 THEN 3
|
||||
WHEN days_out <= 60 THEN 4
|
||||
WHEN days_out <= 90 THEN 5
|
||||
WHEN days_out <= 180 THEN 6
|
||||
ELSE 7
|
||||
END,
|
||||
model,
|
||||
metric_code
|
||||
ORDER BY sort_order, model
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"lead_time": row.lead_time_label,
|
||||
"model": row.model,
|
||||
"metric_code": row.metric_code,
|
||||
"mape": round(float(row.mape), 2) if row.mape else None,
|
||||
"mae": round(float(row.mae), 2) if row.mae else None,
|
||||
"sample_count": row.sample_count
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/by-horizon")
|
||||
async def get_accuracy_by_horizon(
|
||||
horizon: int = Query(..., description="Lead time in days (7, 14, 28)"),
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get accuracy at different lead times from backtest data.
|
||||
Shows how forecast accuracy degrades as horizon increases.
|
||||
Uses forecast_snapshots table populated by backtests.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=90)
|
||||
if to_date is None:
|
||||
to_date = date.today()
|
||||
|
||||
# Query forecast_snapshots table (populated by backtests)
|
||||
query = """
|
||||
SELECT
|
||||
metric_code as forecast_type,
|
||||
days_out as horizon_days,
|
||||
model as model_type,
|
||||
AVG(ABS(forecast_value - actual_value)) as mae,
|
||||
AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0) * 100)) as mape,
|
||||
COUNT(*) as sample_count
|
||||
FROM forecast_snapshots
|
||||
WHERE target_date BETWEEN :from_date AND :to_date
|
||||
AND days_out = :horizon
|
||||
AND actual_value IS NOT NULL
|
||||
GROUP BY metric_code, days_out, model
|
||||
ORDER BY metric_code, model
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"horizon": horizon
|
||||
})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"forecast_type": row.forecast_type,
|
||||
"horizon_days": row.horizon_days,
|
||||
"model_type": row.model_type,
|
||||
"mae": round(float(row.mae), 2) if row.mae else None,
|
||||
"mape": round(float(row.mape), 2) if row.mape else None,
|
||||
"sample_count": row.sample_count
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
177
backend/api/ai_insights.py
Normal file
177
backend/api/ai_insights.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""
|
||||
AI Insights API Endpoints
|
||||
|
||||
Serves pre-computed daily insights to the dashboard and allows manual generation.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Rate limit: minimum minutes between manual generations
|
||||
MANUAL_RATE_LIMIT_MINUTES = 5
|
||||
|
||||
|
||||
@router.get("/latest")
|
||||
async def get_latest_insight(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get the most recent AI insight for the dashboard card."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, generated_at, insight_type, content, model,
|
||||
input_tokens, output_tokens, triggered_by
|
||||
FROM ai_insights
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"generated_at": row.generated_at.isoformat(),
|
||||
"insight_type": row.insight_type,
|
||||
"content": row.content,
|
||||
"model": row.model,
|
||||
"input_tokens": row.input_tokens,
|
||||
"output_tokens": row.output_tokens,
|
||||
"triggered_by": row.triggered_by,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_insight_history(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get historical AI insights with pagination."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, generated_at, insight_type, content, model,
|
||||
input_tokens, output_tokens, triggered_by
|
||||
FROM ai_insights
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"limit": limit, "offset": offset}
|
||||
)
|
||||
|
||||
count_result = await db.execute(text("SELECT COUNT(*) FROM ai_insights"))
|
||||
total = count_result.scalar()
|
||||
|
||||
insights = []
|
||||
for row in result.fetchall():
|
||||
insights.append({
|
||||
"id": row.id,
|
||||
"generated_at": row.generated_at.isoformat(),
|
||||
"insight_type": row.insight_type,
|
||||
"content": row.content,
|
||||
"model": row.model,
|
||||
"input_tokens": row.input_tokens,
|
||||
"output_tokens": row.output_tokens,
|
||||
"triggered_by": row.triggered_by,
|
||||
})
|
||||
|
||||
return {"insights": insights, "total": total}
|
||||
|
||||
|
||||
@router.get("/usage")
|
||||
async def get_usage_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get token usage statistics for the current month."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
COUNT(*) as generation_count,
|
||||
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
|
||||
COALESCE(SUM(output_tokens), 0) as total_output_tokens
|
||||
FROM ai_insights
|
||||
WHERE generated_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
""")
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
# Also get today's usage for budget display
|
||||
today_result = await db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(input_tokens + output_tokens), 0) as today_total
|
||||
FROM ai_insights
|
||||
WHERE generated_at >= CURRENT_DATE
|
||||
""")
|
||||
)
|
||||
today_row = today_result.fetchone()
|
||||
|
||||
return {
|
||||
"month": {
|
||||
"generations": row.generation_count,
|
||||
"input_tokens": row.total_input_tokens,
|
||||
"output_tokens": row.total_output_tokens,
|
||||
"total_tokens": row.total_input_tokens + row.total_output_tokens,
|
||||
},
|
||||
"today_tokens": today_row.today_total if today_row else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_insight_manual(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Manually trigger AI insight generation.
|
||||
Rate-limited to prevent accidental spam.
|
||||
"""
|
||||
# Check rate limit
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT generated_at FROM ai_insights
|
||||
WHERE triggered_by = 'manual'
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
)
|
||||
last_manual = result.fetchone()
|
||||
|
||||
if last_manual:
|
||||
elapsed = datetime.now(timezone.utc) - last_manual.generated_at.replace(tzinfo=timezone.utc)
|
||||
if elapsed.total_seconds() < MANUAL_RATE_LIMIT_MINUTES * 60:
|
||||
remaining = MANUAL_RATE_LIMIT_MINUTES - (elapsed.total_seconds() / 60)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Rate limited. Please wait {remaining:.0f} more minutes."
|
||||
)
|
||||
|
||||
# Run generation
|
||||
from jobs.ai_insights import generate_insight
|
||||
|
||||
result = await generate_insight(db, triggered_by="manual")
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"content": result["content"],
|
||||
"input_tokens": result["input_tokens"],
|
||||
"output_tokens": result["output_tokens"],
|
||||
"model": result["model"],
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error", "Failed to generate insight")
|
||||
)
|
||||
1580
backend/api/backtest.py
Normal file
1580
backend/api/backtest.py
Normal file
File diff suppressed because it is too large
Load diff
303
backend/api/backup.py
Normal file
303
backend/api/backup.py
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
"""
|
||||
Backup and Restore API Endpoints
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pathlib import Path
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
from services.backup_service import BackupService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================
|
||||
# Note: All backup endpoints require authentication but not admin role,
|
||||
# consistent with other sensitive operations in the application
|
||||
|
||||
|
||||
# ============================================
|
||||
# API ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_backup_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get backup configuration settings
|
||||
|
||||
Returns backup frequency, retention count, destination, and last backup status
|
||||
"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
await service.ensure_backup_table_exists()
|
||||
settings = await service.get_backup_settings()
|
||||
return settings
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get backup settings: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def update_backup_settings(
|
||||
updates: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update backup configuration settings
|
||||
|
||||
Body: {
|
||||
frequency?: 'manual' | 'daily' | 'weekly' | 'monthly',
|
||||
retention_count?: number,
|
||||
destination?: 'local',
|
||||
time?: 'HH:MM'
|
||||
}
|
||||
"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
success = await service.update_backup_settings(updates)
|
||||
|
||||
if success:
|
||||
return {"message": "Settings updated successfully"}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail="Failed to update settings")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update backup settings: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_backup(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger manual backup creation
|
||||
|
||||
Creates a ZIP file containing database dump, JSON export, and all data files.
|
||||
Returns backup ID and status.
|
||||
"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
await service.ensure_backup_table_exists()
|
||||
|
||||
username = current_user.get('username', 'unknown')
|
||||
success, message, backup_id = await service.create_backup(
|
||||
backup_type='manual',
|
||||
created_by=username
|
||||
)
|
||||
|
||||
if success:
|
||||
return {
|
||||
"success": True,
|
||||
"message": message,
|
||||
"backup_id": backup_id
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backup creation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_backup_history(
|
||||
limit: int = 50,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List all backups, newest first
|
||||
|
||||
Query params:
|
||||
- limit: Maximum number of backups to return (default 50)
|
||||
"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
await service.ensure_backup_table_exists()
|
||||
backups = await service.list_backups(limit=limit)
|
||||
return backups
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get backup history: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{backup_id}")
|
||||
async def get_backup(
|
||||
backup_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get a specific backup by ID"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
backup = await service.get_backup(backup_id)
|
||||
|
||||
if not backup:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
|
||||
return backup
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get backup: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{backup_id}/download")
|
||||
async def download_backup(
|
||||
backup_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Download a backup file
|
||||
|
||||
Returns the backup ZIP file for download
|
||||
"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
backup = await service.get_backup(backup_id)
|
||||
|
||||
if not backup:
|
||||
raise HTTPException(status_code=404, detail="Backup not found")
|
||||
|
||||
if backup['status'] != 'success':
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot download backup that did not complete successfully"
|
||||
)
|
||||
|
||||
file_path = Path(backup['file_path'])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Backup file not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=backup['filename'],
|
||||
media_type='application/zip'
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download backup: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{backup_id}/restore")
|
||||
async def restore_backup(
|
||||
backup_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Restore from an existing backup
|
||||
|
||||
WARNING: This will overwrite the current database with the backup data.
|
||||
All current data will be replaced.
|
||||
"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
success, message = await service.restore_from_backup(backup_id)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": message}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Restore failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/upload-restore")
|
||||
async def upload_and_restore(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Upload and restore from a backup file
|
||||
|
||||
Upload a backup ZIP file and restore database and files from it.
|
||||
|
||||
WARNING: This will overwrite the current database with the backup data.
|
||||
"""
|
||||
try:
|
||||
# Validate file type
|
||||
if not file.filename or not file.filename.endswith('.zip'):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Only ZIP files are accepted"
|
||||
)
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
|
||||
# Validate file size (max 5GB)
|
||||
max_size = 5 * 1024 * 1024 * 1024 # 5GB
|
||||
if len(file_content) > max_size:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large (max {max_size / 1024 / 1024 / 1024}GB)"
|
||||
)
|
||||
|
||||
# Restore from uploaded file
|
||||
service = BackupService(db)
|
||||
success, message = await service.restore_from_upload(
|
||||
file_content=file_content,
|
||||
filename=file.filename
|
||||
)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": message}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Upload restore failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{backup_id}")
|
||||
async def delete_backup(
|
||||
backup_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Delete a backup file and record
|
||||
|
||||
Removes the backup file from disk and deletes the database record.
|
||||
"""
|
||||
try:
|
||||
service = BackupService(db)
|
||||
success, message = await service.delete_backup(backup_id)
|
||||
|
||||
if success:
|
||||
return {"success": True, "message": message}
|
||||
else:
|
||||
raise HTTPException(status_code=500, detail=message)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete backup: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
598
backend/api/bookability.py
Normal file
598
backend/api/bookability.py
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
"""
|
||||
Bookability API endpoints
|
||||
Rate availability matrix and competitor rate comparison
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import date, datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
import json
|
||||
|
||||
from database import get_db, SyncSessionLocal
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# RESPONSE MODELS
|
||||
# ============================================
|
||||
|
||||
class CategoryInfo(BaseModel):
|
||||
category_id: str
|
||||
category_name: str
|
||||
room_count: int
|
||||
|
||||
|
||||
class TariffInfo(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
rate: Optional[float] = None
|
||||
average_nightly: Optional[float] = None
|
||||
available: bool
|
||||
message: str
|
||||
sort_order: int = 999
|
||||
min_stay: Optional[int] = None
|
||||
available_for_min_stay: Optional[bool] = None # True if available when queried with min_stay nights
|
||||
|
||||
|
||||
class OccupancyInfo(BaseModel):
|
||||
occupied: int = 0
|
||||
available: int = 0
|
||||
maintenance: int = 0
|
||||
|
||||
|
||||
class DateRateInfo(BaseModel):
|
||||
rate_gross: Optional[float] = None
|
||||
rate_net: Optional[float] = None
|
||||
tariffs: List[TariffInfo]
|
||||
tariff_count: int
|
||||
occupancy: Optional[OccupancyInfo] = None
|
||||
|
||||
|
||||
class RateMatrixResponse(BaseModel):
|
||||
categories: List[CategoryInfo]
|
||||
dates: List[str]
|
||||
matrix: Dict[str, Dict[str, DateRateInfo]]
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATE MATRIX ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@router.get("/rate-matrix", response_model=RateMatrixResponse)
|
||||
async def get_rate_matrix(
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
category_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get rate availability matrix for all tariffs across dates and categories.
|
||||
|
||||
Returns a matrix showing all available tariff options for each room category
|
||||
and date combination, including availability status and rates.
|
||||
|
||||
Args:
|
||||
from_date: Start date (YYYY-MM-DD), defaults to today
|
||||
to_date: End date (YYYY-MM-DD), defaults to today + 30 days
|
||||
category_id: Optional filter to specific category
|
||||
|
||||
Returns:
|
||||
RateMatrixResponse with categories, dates, and the matrix data
|
||||
"""
|
||||
# Default date range
|
||||
today = date.today()
|
||||
start = date.fromisoformat(from_date) if from_date else today
|
||||
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
|
||||
|
||||
# Validate date range
|
||||
if end < start:
|
||||
raise HTTPException(status_code=400, detail="to_date must be after from_date")
|
||||
if (end - start).days > 366:
|
||||
raise HTTPException(status_code=400, detail="Date range cannot exceed 366 days")
|
||||
|
||||
# Fetch categories
|
||||
cat_query = """
|
||||
SELECT site_id, site_name, room_count
|
||||
FROM newbook_room_categories
|
||||
WHERE is_included = true
|
||||
"""
|
||||
params: Dict[str, Any] = {}
|
||||
|
||||
if category_id:
|
||||
cat_query += " AND site_id = :category_id"
|
||||
params["category_id"] = category_id
|
||||
|
||||
cat_query += " ORDER BY display_order, site_name"
|
||||
|
||||
cat_result = await db.execute(text(cat_query), params)
|
||||
categories = [
|
||||
CategoryInfo(
|
||||
category_id=row.site_id,
|
||||
category_name=row.site_name,
|
||||
room_count=row.room_count or 0
|
||||
)
|
||||
for row in cat_result.fetchall()
|
||||
]
|
||||
|
||||
if not categories:
|
||||
return RateMatrixResponse(categories=[], dates=[], matrix={})
|
||||
|
||||
# Build date list
|
||||
dates = []
|
||||
current = start
|
||||
while current <= end:
|
||||
dates.append(current.isoformat())
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Fetch rates with tariffs_data (get latest version per category/date)
|
||||
rates_query = """
|
||||
SELECT DISTINCT ON (category_id, rate_date)
|
||||
category_id, rate_date, rate_gross, rate_net, tariffs_data, valid_from
|
||||
FROM newbook_current_rates
|
||||
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||
"""
|
||||
rates_params: Dict[str, Any] = {"from_date": start, "to_date": end}
|
||||
|
||||
if category_id:
|
||||
rates_query += " AND category_id = :category_id"
|
||||
rates_params["category_id"] = category_id
|
||||
|
||||
rates_query += " ORDER BY category_id, rate_date, valid_from DESC"
|
||||
|
||||
rates_result = await db.execute(text(rates_query), rates_params)
|
||||
rates_rows = rates_result.fetchall()
|
||||
|
||||
# Fetch occupancy data from newbook_occupancy_report_data
|
||||
occupancy_query = """
|
||||
SELECT category_id, date, occupied, available, maintenance
|
||||
FROM newbook_occupancy_report_data
|
||||
WHERE date >= :from_date AND date <= :to_date
|
||||
"""
|
||||
occupancy_params: Dict[str, Any] = {"from_date": start, "to_date": end}
|
||||
|
||||
if category_id:
|
||||
occupancy_query += " AND category_id = :category_id"
|
||||
occupancy_params["category_id"] = category_id
|
||||
|
||||
occupancy_result = await db.execute(text(occupancy_query), occupancy_params)
|
||||
occupancy_rows = occupancy_result.fetchall()
|
||||
|
||||
# Build occupancy lookup: category_id -> date -> OccupancyInfo
|
||||
occupancy_map: Dict[str, Dict[str, OccupancyInfo]] = {}
|
||||
for row in occupancy_rows:
|
||||
cat_id = row.category_id
|
||||
occ_date = row.date.isoformat()
|
||||
if cat_id not in occupancy_map:
|
||||
occupancy_map[cat_id] = {}
|
||||
occupancy_map[cat_id][occ_date] = OccupancyInfo(
|
||||
occupied=row.occupied or 0,
|
||||
available=row.available or 0,
|
||||
maintenance=row.maintenance or 0
|
||||
)
|
||||
|
||||
# Build matrix
|
||||
matrix: Dict[str, Dict[str, DateRateInfo]] = {}
|
||||
|
||||
# Initialize matrix with empty data for all categories and dates
|
||||
for cat in categories:
|
||||
matrix[cat.category_id] = {}
|
||||
for date_str in dates:
|
||||
# Get occupancy for this category/date if available
|
||||
occ = occupancy_map.get(cat.category_id, {}).get(date_str)
|
||||
matrix[cat.category_id][date_str] = DateRateInfo(
|
||||
rate_gross=None,
|
||||
rate_net=None,
|
||||
tariffs=[],
|
||||
tariff_count=0,
|
||||
occupancy=occ
|
||||
)
|
||||
|
||||
# Populate matrix with actual data
|
||||
for row in rates_rows:
|
||||
cat_id = row.category_id
|
||||
rate_date = row.rate_date.isoformat()
|
||||
|
||||
if cat_id not in matrix or rate_date not in matrix[cat_id]:
|
||||
continue
|
||||
|
||||
# Parse tariffs_data
|
||||
tariffs_data = row.tariffs_data or {}
|
||||
if isinstance(tariffs_data, str):
|
||||
try:
|
||||
tariffs_data = json.loads(tariffs_data)
|
||||
except json.JSONDecodeError:
|
||||
tariffs_data = {}
|
||||
|
||||
# Build tariff list
|
||||
tariffs_list = []
|
||||
raw_tariffs = tariffs_data.get('tariffs', [])
|
||||
|
||||
for idx, tariff in enumerate(raw_tariffs):
|
||||
tariffs_list.append(TariffInfo(
|
||||
name=tariff.get('name', 'Unknown'),
|
||||
description=tariff.get('description'),
|
||||
rate=tariff.get('rate'),
|
||||
average_nightly=tariff.get('average_nightly'),
|
||||
available=tariff.get('success', False),
|
||||
message=tariff.get('message', ''),
|
||||
sort_order=tariff.get('sort_order', idx),
|
||||
min_stay=tariff.get('min_stay'),
|
||||
available_for_min_stay=tariff.get('available_for_min_stay')
|
||||
))
|
||||
|
||||
# Preserve existing occupancy data
|
||||
existing_occ = matrix[cat_id][rate_date].occupancy
|
||||
|
||||
matrix[cat_id][rate_date] = DateRateInfo(
|
||||
rate_gross=float(row.rate_gross) if row.rate_gross else None,
|
||||
rate_net=float(row.rate_net) if row.rate_net else None,
|
||||
tariffs=tariffs_list,
|
||||
tariff_count=tariffs_data.get('tariff_count', len(tariffs_list)),
|
||||
occupancy=existing_occ
|
||||
)
|
||||
|
||||
return RateMatrixResponse(
|
||||
categories=categories,
|
||||
dates=dates,
|
||||
matrix=matrix
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATE MATRIX SUMMARY (lightweight endpoint)
|
||||
# ============================================
|
||||
|
||||
@router.get("/rate-matrix/summary")
|
||||
async def get_rate_matrix_summary(
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get a summary of rate availability issues.
|
||||
|
||||
Returns counts of unavailable tariffs by category and date for quick
|
||||
identification of potential bookability problems.
|
||||
"""
|
||||
today = date.today()
|
||||
start = date.fromisoformat(from_date) if from_date else today
|
||||
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
|
||||
|
||||
# Query rates with issues (get latest version per category/date)
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (category_id, rate_date)
|
||||
category_id,
|
||||
rate_date,
|
||||
tariffs_data
|
||||
FROM newbook_current_rates
|
||||
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||
AND tariffs_data IS NOT NULL
|
||||
ORDER BY category_id, rate_date, valid_from DESC
|
||||
"""),
|
||||
{"from_date": start, "to_date": end}
|
||||
)
|
||||
|
||||
issues = []
|
||||
for row in result.fetchall():
|
||||
tariffs_data = row.tariffs_data or {}
|
||||
if isinstance(tariffs_data, str):
|
||||
try:
|
||||
tariffs_data = json.loads(tariffs_data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
tariffs = tariffs_data.get('tariffs', [])
|
||||
unavailable = [t for t in tariffs if not t.get('success', False)]
|
||||
|
||||
if unavailable:
|
||||
issues.append({
|
||||
"category_id": row.category_id,
|
||||
"date": row.rate_date.isoformat(),
|
||||
"unavailable_count": len(unavailable),
|
||||
"unavailable_tariffs": [t.get('name') for t in unavailable],
|
||||
"messages": [t.get('message') for t in unavailable if t.get('message')]
|
||||
})
|
||||
|
||||
return {
|
||||
"from_date": start.isoformat(),
|
||||
"to_date": end.isoformat(),
|
||||
"total_issues": len(issues),
|
||||
"issues": issues
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATE HISTORY ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@router.get("/rate-history/{category_id}/{rate_date}")
|
||||
async def get_rate_history(
|
||||
category_id: str,
|
||||
rate_date: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get rate change history for a specific category and date.
|
||||
|
||||
Returns all rate snapshots showing how rates evolved over time.
|
||||
Useful for understanding when rates changed and by how much.
|
||||
"""
|
||||
try:
|
||||
target_date = date.fromisoformat(rate_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
id,
|
||||
rate_gross,
|
||||
rate_net,
|
||||
tariffs_data,
|
||||
valid_from,
|
||||
last_verified_at
|
||||
FROM newbook_current_rates
|
||||
WHERE category_id = :category_id AND rate_date = :rate_date
|
||||
ORDER BY valid_from DESC
|
||||
"""),
|
||||
{"category_id": category_id, "rate_date": target_date}
|
||||
)
|
||||
|
||||
history = []
|
||||
for row in result.fetchall():
|
||||
tariffs_data = row.tariffs_data or {}
|
||||
if isinstance(tariffs_data, str):
|
||||
try:
|
||||
tariffs_data = json.loads(tariffs_data)
|
||||
except json.JSONDecodeError:
|
||||
tariffs_data = {}
|
||||
|
||||
tariffs = tariffs_data.get('tariffs', [])
|
||||
|
||||
history.append({
|
||||
"id": row.id,
|
||||
"rate_gross": float(row.rate_gross) if row.rate_gross else None,
|
||||
"rate_net": float(row.rate_net) if row.rate_net else None,
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||
"last_verified_at": row.last_verified_at.isoformat() if row.last_verified_at else None,
|
||||
"tariff_count": len(tariffs),
|
||||
"tariffs_available": sum(1 for t in tariffs if t.get('success', False)),
|
||||
"tariffs_unavailable": sum(1 for t in tariffs if not t.get('success', False)),
|
||||
"tariffs": [
|
||||
{
|
||||
"name": t.get('name'),
|
||||
"rate": t.get('rate'),
|
||||
"available": t.get('success', False),
|
||||
"message": t.get('message', ''),
|
||||
"min_stay": t.get('min_stay')
|
||||
}
|
||||
for t in tariffs
|
||||
]
|
||||
})
|
||||
|
||||
return {
|
||||
"category_id": category_id,
|
||||
"rate_date": rate_date,
|
||||
"version_count": len(history),
|
||||
"history": history
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATE CHANGES SUMMARY
|
||||
# ============================================
|
||||
|
||||
@router.get("/rate-changes")
|
||||
async def get_rate_changes(
|
||||
days: int = 7,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get summary of rate changes in the last N days.
|
||||
|
||||
Shows which rates changed and when, useful for tracking pricing strategy changes.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
|
||||
# Find dates with multiple versions (indicating changes)
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
category_id,
|
||||
rate_date,
|
||||
COUNT(*) as version_count,
|
||||
MIN(valid_from) as first_version,
|
||||
MAX(valid_from) as latest_version
|
||||
FROM newbook_current_rates
|
||||
WHERE valid_from >= :cutoff
|
||||
GROUP BY category_id, rate_date
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY MAX(valid_from) DESC
|
||||
LIMIT 100
|
||||
"""),
|
||||
{"cutoff": cutoff}
|
||||
)
|
||||
|
||||
changes = []
|
||||
for row in result.fetchall():
|
||||
changes.append({
|
||||
"category_id": row.category_id,
|
||||
"rate_date": row.rate_date.isoformat(),
|
||||
"version_count": row.version_count,
|
||||
"first_version": row.first_version.isoformat() if row.first_version else None,
|
||||
"latest_version": row.latest_version.isoformat() if row.latest_version else None
|
||||
})
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"total_changes": len(changes),
|
||||
"changes": changes
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# FETCH RATES TRIGGER (manual refresh)
|
||||
# ============================================
|
||||
|
||||
@router.post("/refresh-rates")
|
||||
async def refresh_rates(
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
category_id: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger a manual refresh of current rates from Newbook.
|
||||
|
||||
This runs the rate fetch job for the specified date range.
|
||||
Note: This can be slow as it respects Newbook API rate limits.
|
||||
"""
|
||||
from jobs.fetch_current_rates import run_fetch_current_rates
|
||||
|
||||
# For now, just run the standard fetch
|
||||
# TODO: Add support for custom date range and category filter
|
||||
try:
|
||||
await run_fetch_current_rates()
|
||||
return {"status": "success", "message": "Rates refresh completed"}
|
||||
except Exception as e:
|
||||
logger.error(f"Rates refresh failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Rates refresh failed: {str(e)}")
|
||||
|
||||
|
||||
# ============================================
|
||||
# SINGLE-DATE RATE REFRESH
|
||||
# ============================================
|
||||
|
||||
def _refresh_date_sync(rate_date: date):
|
||||
"""
|
||||
Fetch rates for a single date from Newbook and save to DB.
|
||||
Runs synchronously in a background task.
|
||||
"""
|
||||
import asyncio
|
||||
from decimal import Decimal
|
||||
from services.newbook_rates_client import NewbookRatesClient
|
||||
from jobs.fetch_current_rates import save_rate_snapshot
|
||||
|
||||
db = SyncSessionLocal()
|
||||
try:
|
||||
# Get config
|
||||
config_result = db.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value FROM system_config
|
||||
WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate')
|
||||
""")
|
||||
)
|
||||
config = {row.config_key: row.config_value for row in config_result.fetchall()}
|
||||
|
||||
if not all(k in config for k in ['newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region']):
|
||||
logger.error("Newbook credentials not configured for single-date refresh")
|
||||
return
|
||||
|
||||
vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20'))
|
||||
|
||||
# Get included categories
|
||||
cat_result = db.execute(
|
||||
text("SELECT site_id FROM newbook_room_categories WHERE is_included = true")
|
||||
)
|
||||
included_categories = set(row.site_id for row in cat_result.fetchall())
|
||||
|
||||
client = NewbookRatesClient(
|
||||
api_key=config['newbook_api_key'],
|
||||
username=config['newbook_username'],
|
||||
password=config['newbook_password'],
|
||||
region=config['newbook_region'],
|
||||
vat_rate=vat_rate
|
||||
)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
async def _fetch():
|
||||
async with client:
|
||||
# Single-night query for this date (all categories)
|
||||
category_rates = await client._fetch_all_categories_batch(
|
||||
rate_date, guests_adults=2, guests_children=0
|
||||
)
|
||||
|
||||
# Check for min_stay tariffs needing multi-night verification
|
||||
dates_by_nights: Dict[int, list] = {}
|
||||
for cat_id, rates in category_rates.items():
|
||||
if cat_id not in included_categories:
|
||||
continue
|
||||
for rate in rates:
|
||||
for tariff in rate.get('tariffs_data', {}).get('tariffs', []):
|
||||
min_stay = tariff.get('min_stay')
|
||||
if min_stay and min_stay > 1 and not tariff.get('success', False):
|
||||
if min_stay not in dates_by_nights:
|
||||
dates_by_nights[min_stay] = []
|
||||
if rate_date not in dates_by_nights[min_stay]:
|
||||
dates_by_nights[min_stay].append(rate_date)
|
||||
|
||||
# Run multi-night verification if needed
|
||||
if dates_by_nights:
|
||||
multi_results = await client.get_multi_night_availability(dates_by_nights)
|
||||
for cat_id, rates in category_rates.items():
|
||||
if cat_id not in included_categories:
|
||||
continue
|
||||
for rate in rates:
|
||||
if rate_date in multi_results:
|
||||
cat_avail = multi_results[rate_date].get(cat_id, {})
|
||||
for tariff in rate.get('tariffs_data', {}).get('tariffs', []):
|
||||
if tariff.get('min_stay') and tariff['min_stay'] > 1:
|
||||
tariff['available_for_min_stay'] = cat_avail.get(tariff.get('name', ''), False)
|
||||
|
||||
# Save snapshots
|
||||
inserted = 0
|
||||
for cat_id, rates in category_rates.items():
|
||||
if cat_id not in included_categories:
|
||||
continue
|
||||
for rate in rates:
|
||||
result = save_rate_snapshot(db, cat_id, rate['date'], rate)
|
||||
if result == 'inserted':
|
||||
inserted += 1
|
||||
|
||||
return inserted
|
||||
|
||||
inserted = loop.run_until_complete(_fetch())
|
||||
db.commit()
|
||||
logger.info(f"Single-date refresh for {rate_date}: {inserted} new snapshots")
|
||||
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Single-date refresh failed for {rate_date}: {e}", exc_info=True)
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/refresh-date/{rate_date}")
|
||||
async def refresh_single_date(
|
||||
rate_date: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger a refresh of rates for a single date from Newbook.
|
||||
Runs in background - returns immediately.
|
||||
"""
|
||||
try:
|
||||
target_date = date.fromisoformat(rate_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
background_tasks.add_task(_refresh_date_sync, target_date)
|
||||
return {"status": "queued", "date": rate_date, "message": f"Refreshing rates for {rate_date}"}
|
||||
559
backend/api/budget.py
Normal file
559
backend/api/budget.py
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
"""
|
||||
Budget API endpoints
|
||||
"""
|
||||
import io
|
||||
import re
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Optional, List
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mapping from spreadsheet row labels to budget_type values
|
||||
BUDGET_TYPE_MAPPING = {
|
||||
'accom': 'net_accom',
|
||||
'accommodation': 'net_accom',
|
||||
'acc': 'net_accom',
|
||||
'dry': 'net_dry',
|
||||
'food': 'net_dry',
|
||||
'wet': 'net_wet',
|
||||
'beverage': 'net_wet',
|
||||
'beverages': 'net_wet',
|
||||
}
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MonthlyBudgetCreate(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
budget_type: str
|
||||
budget_value: float
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MonthlyBudgetResponse(BaseModel):
|
||||
id: int
|
||||
year: int
|
||||
month: int
|
||||
budget_type: str
|
||||
budget_value: float
|
||||
notes: Optional[str]
|
||||
|
||||
|
||||
@router.get("/monthly")
|
||||
async def get_monthly_budgets(
|
||||
year: int = Query(..., description="Year to get budgets for"),
|
||||
budget_type: Optional[str] = Query(None, description="Filter by budget type"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get monthly budgets for a year.
|
||||
"""
|
||||
query = """
|
||||
SELECT id, year, month, budget_type, budget_value, notes, created_at, updated_at
|
||||
FROM monthly_budgets
|
||||
WHERE year = :year
|
||||
"""
|
||||
params = {"year": year}
|
||||
|
||||
if budget_type:
|
||||
query += " AND budget_type = :budget_type"
|
||||
params["budget_type"] = budget_type
|
||||
|
||||
query += " ORDER BY month, budget_type"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"year": row.year,
|
||||
"month": row.month,
|
||||
"budget_type": row.budget_type,
|
||||
"budget_value": float(row.budget_value),
|
||||
"notes": row.notes,
|
||||
"created_at": row.created_at,
|
||||
"updated_at": row.updated_at
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/monthly")
|
||||
async def create_or_update_monthly_budget(
|
||||
budget: MonthlyBudgetCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Create or update a monthly budget.
|
||||
"""
|
||||
query = """
|
||||
INSERT INTO monthly_budgets (year, month, budget_type, budget_value, notes, updated_at)
|
||||
VALUES (:year, :month, :budget_type, :budget_value, :notes, NOW())
|
||||
ON CONFLICT (year, month, budget_type)
|
||||
DO UPDATE SET budget_value = :budget_value, notes = :notes, updated_at = NOW()
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"year": budget.year,
|
||||
"month": budget.month,
|
||||
"budget_type": budget.budget_type,
|
||||
"budget_value": budget.budget_value,
|
||||
"notes": budget.notes
|
||||
})
|
||||
await db.commit()
|
||||
|
||||
row = result.fetchone()
|
||||
return {"id": row.id, "status": "saved", **budget.model_dump()}
|
||||
|
||||
|
||||
@router.get("/daily")
|
||||
async def get_daily_budgets(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
budget_type: Optional[str] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get daily distributed budgets for a date range.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
date,
|
||||
budget_type,
|
||||
budget_value,
|
||||
distribution_method,
|
||||
prior_year_pct
|
||||
FROM daily_budgets
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
"""
|
||||
params = {"from_date": from_date, "to_date": to_date}
|
||||
|
||||
if budget_type:
|
||||
query += " AND budget_type = :budget_type"
|
||||
params["budget_type"] = budget_type
|
||||
|
||||
query += " ORDER BY date, budget_type"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.date,
|
||||
"budget_type": row.budget_type,
|
||||
"budget_value": float(row.budget_value),
|
||||
"distribution_method": row.distribution_method,
|
||||
"prior_year_pct": float(row.prior_year_pct) if row.prior_year_pct else None
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/distribute")
|
||||
async def distribute_monthly_budget(
|
||||
year: int = Query(...),
|
||||
month: int = Query(...),
|
||||
budget_type: Optional[str] = Query(None, description="Budget type to distribute, or all if not specified"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Distribute monthly budget to daily values using prior year patterns.
|
||||
"""
|
||||
from services.forecasting.budget_service import distribute_budget
|
||||
|
||||
result = await distribute_budget(db, year, month, budget_type)
|
||||
|
||||
return {
|
||||
"status": "distributed",
|
||||
"year": year,
|
||||
"month": month,
|
||||
"budget_type": budget_type or "all",
|
||||
"days_distributed": result.get("days_distributed", 0)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/variance")
|
||||
async def get_budget_variance(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get forecast vs budget vs actual variance for date range.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
db.date,
|
||||
db.budget_type,
|
||||
db.budget_value,
|
||||
f.predicted_value as forecast_value,
|
||||
dm.actual_value,
|
||||
(f.predicted_value - db.budget_value) as forecast_vs_budget,
|
||||
CASE WHEN db.budget_value != 0 THEN
|
||||
ROUND(((f.predicted_value - db.budget_value) / db.budget_value * 100)::numeric, 2)
|
||||
END as forecast_vs_budget_pct,
|
||||
CASE WHEN dm.actual_value IS NOT NULL THEN
|
||||
(dm.actual_value - db.budget_value)
|
||||
END as actual_vs_budget,
|
||||
CASE WHEN dm.actual_value IS NOT NULL AND db.budget_value != 0 THEN
|
||||
ROUND(((dm.actual_value - db.budget_value) / db.budget_value * 100)::numeric, 2)
|
||||
END as actual_vs_budget_pct
|
||||
FROM daily_budgets db
|
||||
LEFT JOIN forecasts f ON db.date = f.forecast_date
|
||||
AND db.budget_type = f.forecast_type
|
||||
AND f.model_type = 'prophet'
|
||||
LEFT JOIN daily_metrics dm ON db.date = dm.date
|
||||
AND db.budget_type = dm.metric_code
|
||||
WHERE db.date BETWEEN :from_date AND :to_date
|
||||
ORDER BY db.date, db.budget_type
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.date,
|
||||
"budget_type": row.budget_type,
|
||||
"budget": float(row.budget_value) if row.budget_value else None,
|
||||
"forecast": float(row.forecast_value) if row.forecast_value else None,
|
||||
"actual": float(row.actual_value) if row.actual_value else None,
|
||||
"forecast_vs_budget": float(row.forecast_vs_budget) if row.forecast_vs_budget else None,
|
||||
"forecast_vs_budget_pct": float(row.forecast_vs_budget_pct) if row.forecast_vs_budget_pct else None,
|
||||
"actual_vs_budget": float(row.actual_vs_budget) if row.actual_vs_budget else None,
|
||||
"actual_vs_budget_pct": float(row.actual_vs_budget_pct) if row.actual_vs_budget_pct else None
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def parse_month_header(header) -> Optional[tuple]:
|
||||
"""
|
||||
Parse month header in various formats to (year, month).
|
||||
|
||||
Supported formats:
|
||||
- datetime/Timestamp objects (from Excel date cells)
|
||||
- mm/yy (01/25, 12/26)
|
||||
- mm-yy (01-25, 12-26)
|
||||
- mmm/yy (Jan/25, Dec-26)
|
||||
- mmm yy (Jan 25, Dec 26)
|
||||
- yyyy-mm (2025-01)
|
||||
- dd/mm/yyyy or mm/dd/yyyy (will use first of month)
|
||||
- Full month names (January 2025)
|
||||
|
||||
Returns None if cannot parse.
|
||||
"""
|
||||
if header is None:
|
||||
return None
|
||||
|
||||
# Handle pandas NaT or NaN
|
||||
if pd.isna(header):
|
||||
return None
|
||||
|
||||
# Handle datetime objects (from Excel date columns)
|
||||
if isinstance(header, (datetime, date)):
|
||||
return (header.year, header.month)
|
||||
|
||||
# Handle pandas Timestamp
|
||||
if hasattr(header, 'year') and hasattr(header, 'month'):
|
||||
try:
|
||||
return (int(header.year), int(header.month))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Convert to string for text parsing
|
||||
if not isinstance(header, str):
|
||||
header = str(header)
|
||||
|
||||
header = header.strip()
|
||||
if not header:
|
||||
return None
|
||||
|
||||
# Try mm/yy or mm-yy format
|
||||
match = re.match(r'^(\d{1,2})[/\-](\d{2,4})$', header)
|
||||
if match:
|
||||
month = int(match.group(1))
|
||||
year = int(match.group(2))
|
||||
if year < 100:
|
||||
year = 2000 + year if year < 50 else 1900 + year
|
||||
if 1 <= month <= 12:
|
||||
return (year, month)
|
||||
|
||||
# Try yyyy-mm format
|
||||
match = re.match(r'^(\d{4})[/\-](\d{1,2})$', header)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
month = int(match.group(2))
|
||||
if 1 <= month <= 12:
|
||||
return (year, month)
|
||||
|
||||
# Try dd/mm/yyyy or yyyy-mm-dd format (use year/month, ignore day)
|
||||
match = re.match(r'^(\d{1,2})[/\-](\d{1,2})[/\-](\d{4})$', header)
|
||||
if match:
|
||||
# Assume dd/mm/yyyy
|
||||
day = int(match.group(1))
|
||||
month = int(match.group(2))
|
||||
year = int(match.group(3))
|
||||
if 1 <= month <= 12:
|
||||
return (year, month)
|
||||
|
||||
match = re.match(r'^(\d{4})[/\-](\d{1,2})[/\-](\d{1,2})$', header)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
month = int(match.group(2))
|
||||
if 1 <= month <= 12:
|
||||
return (year, month)
|
||||
|
||||
# Try month name formats (Jan/25, Jan-25, Jan 25, Jan25)
|
||||
month_names = {
|
||||
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, 'may': 5, 'jun': 6,
|
||||
'jul': 7, 'aug': 8, 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12,
|
||||
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'june': 6,
|
||||
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12
|
||||
}
|
||||
match = re.match(r'^([a-zA-Z]+)[/\-\s]?(\d{2,4})$', header, re.IGNORECASE)
|
||||
if match:
|
||||
month_str = match.group(1).lower()
|
||||
year = int(match.group(2))
|
||||
if year < 100:
|
||||
year = 2000 + year if year < 50 else 1900 + year
|
||||
if month_str in month_names:
|
||||
return (year, month_names[month_str])
|
||||
|
||||
# Try "2025 January" or "2025-January" format
|
||||
match = re.match(r'^(\d{4})[/\-\s]?([a-zA-Z]+)$', header, re.IGNORECASE)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
month_str = match.group(2).lower()
|
||||
if month_str in month_names:
|
||||
return (year, month_names[month_str])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def clean_numeric_value(value) -> Optional[float]:
|
||||
"""
|
||||
Clean a value that might contain currency symbols, commas, etc.
|
||||
Returns None if the value cannot be converted to a number.
|
||||
"""
|
||||
if value is None or (isinstance(value, float) and pd.isna(value)):
|
||||
return None
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
|
||||
if isinstance(value, str):
|
||||
# Remove currency symbols, commas, spaces
|
||||
cleaned = re.sub(r'[£$€,\s]', '', value.strip())
|
||||
if cleaned == '' or cleaned == '-':
|
||||
return None
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_budget_spreadsheet(
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Upload budget spreadsheet (CSV/Excel) in format:
|
||||
|
||||
month | 01/25 | 02/25 | 03/25 | ...
|
||||
accom | 150000 | 145000 | 160000 | ...
|
||||
dry | 45000 | 42000 | 48000 | ...
|
||||
wet | 35000 | 32000 | 38000 | ...
|
||||
|
||||
Returns summary of records created/updated.
|
||||
"""
|
||||
# Validate file type
|
||||
filename = file.filename.lower()
|
||||
if not (filename.endswith('.csv') or filename.endswith('.xlsx') or filename.endswith('.xls')):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid file type. Please upload a CSV or Excel file (.csv, .xlsx, .xls)"
|
||||
)
|
||||
|
||||
# Read file content
|
||||
content = await file.read()
|
||||
|
||||
try:
|
||||
# Parse file based on type
|
||||
if filename.endswith('.csv'):
|
||||
df = pd.read_csv(io.BytesIO(content), header=None)
|
||||
else:
|
||||
df = pd.read_excel(io.BytesIO(content), header=None)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse budget file: {e}")
|
||||
raise HTTPException(status_code=400, detail=f"Failed to parse file: {str(e)}")
|
||||
|
||||
if df.empty:
|
||||
raise HTTPException(status_code=400, detail="File is empty")
|
||||
|
||||
# Parse the spreadsheet structure
|
||||
# First row should contain month headers (skip first column which is the label column)
|
||||
# Subsequent rows contain budget type label and values
|
||||
|
||||
records_created = 0
|
||||
records_updated = 0
|
||||
errors = []
|
||||
|
||||
# Get month headers from first row (skip first column)
|
||||
month_headers = df.iloc[0, 1:].tolist()
|
||||
parsed_months = []
|
||||
|
||||
logger.info(f"Found {len(month_headers)} column headers")
|
||||
for idx, header in enumerate(month_headers):
|
||||
logger.debug(f"Header {idx}: {header} (type: {type(header).__name__})")
|
||||
parsed = parse_month_header(header) # Pass raw value, parser handles types
|
||||
if parsed:
|
||||
parsed_months.append((idx + 1, parsed)) # Store column index and (year, month)
|
||||
logger.debug(f" -> Parsed as {parsed[0]}-{parsed[1]:02d}")
|
||||
else:
|
||||
if header is not None and not pd.isna(header) and str(header).strip():
|
||||
errors.append(f"Could not parse month header: '{header}' (type: {type(header).__name__})")
|
||||
|
||||
if not parsed_months:
|
||||
# Log what we received for debugging
|
||||
sample_headers = month_headers[:5] if len(month_headers) > 5 else month_headers
|
||||
logger.error(f"No valid month headers found. Sample headers: {sample_headers}")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"No valid month headers found. Got: {sample_headers}. Expected formats: mm/yy, Jan-25, 2025-01, or Excel dates"
|
||||
)
|
||||
|
||||
# Process budget rows (skip first header row)
|
||||
for row_idx in range(1, len(df)):
|
||||
row = df.iloc[row_idx]
|
||||
row_label = str(row.iloc[0]).lower().strip() if row.iloc[0] else ''
|
||||
|
||||
# Map row label to budget_type
|
||||
budget_type = BUDGET_TYPE_MAPPING.get(row_label)
|
||||
if not budget_type:
|
||||
if row_label and row_label not in ['month', 'total', '']:
|
||||
errors.append(f"Unknown budget type: '{row_label}'")
|
||||
continue
|
||||
|
||||
# Process each month column
|
||||
for col_idx, (year, month) in parsed_months:
|
||||
value = clean_numeric_value(row.iloc[col_idx])
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
# Upsert the budget value
|
||||
try:
|
||||
result = await db.execute(
|
||||
text("""
|
||||
INSERT INTO monthly_budgets (year, month, budget_type, budget_value, updated_at)
|
||||
VALUES (:year, :month, :budget_type, :budget_value, NOW())
|
||||
ON CONFLICT (year, month, budget_type)
|
||||
DO UPDATE SET budget_value = :budget_value, updated_at = NOW()
|
||||
RETURNING (xmax = 0) as inserted
|
||||
"""),
|
||||
{
|
||||
"year": year,
|
||||
"month": month,
|
||||
"budget_type": budget_type,
|
||||
"budget_value": value
|
||||
}
|
||||
)
|
||||
row_result = result.fetchone()
|
||||
if row_result and row_result.inserted:
|
||||
records_created += 1
|
||||
else:
|
||||
records_updated += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to save {budget_type} for {month:02d}/{year}: {str(e)}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Budget upload complete: {records_created} created, {records_updated} updated")
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"filename": file.filename,
|
||||
"records_created": records_created,
|
||||
"records_updated": records_updated,
|
||||
"total_records": records_created + records_updated,
|
||||
"errors": errors if errors else None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
async def download_budget_template(
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Download empty budget template Excel file.
|
||||
Pre-fills month headers for current and next year.
|
||||
"""
|
||||
# Generate month headers for current year and next year
|
||||
current_year = datetime.now().year
|
||||
months = []
|
||||
|
||||
for year in [current_year, current_year + 1]:
|
||||
for month in range(1, 13):
|
||||
months.append(f"{month:02d}/{year % 100:02d}")
|
||||
|
||||
# Create DataFrame with template structure
|
||||
data = {
|
||||
'Type': ['accom', 'dry', 'wet']
|
||||
}
|
||||
|
||||
# Add empty columns for each month
|
||||
for month_header in months:
|
||||
data[month_header] = ['', '', '']
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Write to Excel
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, sheet_name='Budget', index=False)
|
||||
|
||||
# Auto-adjust column widths
|
||||
worksheet = writer.sheets['Budget']
|
||||
for column in worksheet.columns:
|
||||
max_length = 0
|
||||
column_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
worksheet.column_dimensions[column_letter].width = max(max_length + 2, 10)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=budget_template_{current_year}.xlsx"
|
||||
}
|
||||
)
|
||||
940
backend/api/competitor_rates.py
Normal file
940
backend/api/competitor_rates.py
Normal file
|
|
@ -0,0 +1,940 @@
|
|||
"""
|
||||
Competitor Rates API endpoints
|
||||
Booking.com rate scraping, hotel management, and competitor comparison
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import date, datetime, timedelta
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
|
||||
from database import get_db, SyncSessionLocal
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# REQUEST/RESPONSE MODELS
|
||||
# ============================================
|
||||
|
||||
class ScrapeRequest(BaseModel):
|
||||
from_date: str
|
||||
to_date: Optional[str] = None
|
||||
|
||||
|
||||
class LocationConfigRequest(BaseModel):
|
||||
location_name: str
|
||||
pages_to_scrape: int = 2
|
||||
adults: int = 2
|
||||
|
||||
|
||||
class HotelTierUpdate(BaseModel):
|
||||
tier: str # 'own', 'competitor', 'market'
|
||||
display_order: Optional[int] = None
|
||||
|
||||
|
||||
class HotelResponse(BaseModel):
|
||||
id: int
|
||||
booking_com_id: str
|
||||
name: str
|
||||
booking_com_url: Optional[str]
|
||||
star_rating: Optional[float]
|
||||
review_score: Optional[float]
|
||||
review_count: Optional[int]
|
||||
tier: str
|
||||
display_order: int
|
||||
notes: Optional[str]
|
||||
first_seen_at: Optional[datetime]
|
||||
last_seen_at: Optional[datetime]
|
||||
|
||||
|
||||
class RateResponse(BaseModel):
|
||||
rate_date: str
|
||||
hotel_id: int
|
||||
hotel_name: str
|
||||
tier: str
|
||||
star_rating: Optional[float]
|
||||
review_score: Optional[float]
|
||||
availability_status: str
|
||||
rate_gross: Optional[float]
|
||||
room_type: Optional[str]
|
||||
breakfast_included: Optional[bool]
|
||||
free_cancellation: Optional[bool]
|
||||
no_prepayment: Optional[bool]
|
||||
rooms_left: Optional[int]
|
||||
scraped_at: Optional[datetime]
|
||||
|
||||
|
||||
class ScraperStatusResponse(BaseModel):
|
||||
enabled: bool
|
||||
paused: bool
|
||||
pause_until: Optional[str]
|
||||
backend: str
|
||||
location_configured: bool
|
||||
location_name: Optional[str]
|
||||
last_scrape: Optional[dict]
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCRAPER STATUS & CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
@router.get("/status", response_model=ScraperStatusResponse)
|
||||
async def get_scraper_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get current scraper status and configuration."""
|
||||
# Get config values
|
||||
config_result = await db.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value FROM system_config
|
||||
WHERE config_key IN (
|
||||
'booking_scraper_enabled',
|
||||
'booking_scraper_paused',
|
||||
'booking_scraper_pause_until',
|
||||
'booking_scraper_backend'
|
||||
)
|
||||
""")
|
||||
)
|
||||
config = {row.config_key: row.config_value for row in config_result.fetchall()}
|
||||
|
||||
# Get location config
|
||||
location_result = await db.execute(
|
||||
text("SELECT location_name FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
|
||||
)
|
||||
location_row = location_result.fetchone()
|
||||
|
||||
# Get last scrape info
|
||||
last_scrape_result = await db.execute(
|
||||
text("""
|
||||
SELECT batch_id, scrape_type, started_at, completed_at, status,
|
||||
hotels_found, rates_scraped, error_message
|
||||
FROM booking_scrape_log
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
)
|
||||
last_scrape_row = last_scrape_result.fetchone()
|
||||
last_scrape = None
|
||||
if last_scrape_row:
|
||||
last_scrape = {
|
||||
'batch_id': str(last_scrape_row.batch_id),
|
||||
'scrape_type': last_scrape_row.scrape_type,
|
||||
'started_at': last_scrape_row.started_at.isoformat() if last_scrape_row.started_at else None,
|
||||
'completed_at': last_scrape_row.completed_at.isoformat() if last_scrape_row.completed_at else None,
|
||||
'status': last_scrape_row.status,
|
||||
'hotels_found': last_scrape_row.hotels_found,
|
||||
'rates_scraped': last_scrape_row.rates_scraped,
|
||||
'error_message': last_scrape_row.error_message,
|
||||
}
|
||||
|
||||
return ScraperStatusResponse(
|
||||
enabled=config.get('booking_scraper_enabled', 'false') == 'true',
|
||||
paused=config.get('booking_scraper_paused', 'false') == 'true',
|
||||
pause_until=config.get('booking_scraper_pause_until'),
|
||||
backend=config.get('booking_scraper_backend', 'playwright_local'),
|
||||
location_configured=location_row is not None,
|
||||
location_name=location_row.location_name if location_row else None,
|
||||
last_scrape=last_scrape
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config/location")
|
||||
async def set_location_config(
|
||||
config: LocationConfigRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Set the location to scrape for competitor rates."""
|
||||
# Deactivate existing configs
|
||||
await db.execute(
|
||||
text("UPDATE booking_scrape_config SET is_active = FALSE")
|
||||
)
|
||||
|
||||
# Insert new config
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO booking_scrape_config (location_name, pages_to_scrape, adults, is_active)
|
||||
VALUES (:location, :pages, :adults, TRUE)
|
||||
"""),
|
||||
{'location': config.location_name, 'pages': config.pages_to_scrape, 'adults': config.adults}
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "location": config.location_name}
|
||||
|
||||
|
||||
@router.post("/config/enable")
|
||||
async def enable_scraper(
|
||||
enabled: bool = True,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Enable or disable the booking.com scraper."""
|
||||
await db.execute(
|
||||
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_enabled'"),
|
||||
{'val': 'true' if enabled else 'false'}
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "success", "enabled": enabled}
|
||||
|
||||
|
||||
@router.post("/config/unpause")
|
||||
async def unpause_scraper(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Manually unpause the scraper (clears blocking pause)."""
|
||||
await db.execute(
|
||||
text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'")
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "success", "message": "Scraper unpaused"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# MANUAL SCRAPE TRIGGER
|
||||
# ============================================
|
||||
|
||||
def run_scrape_sync(from_date: date, to_date: date):
|
||||
"""Run scrape in sync context for background task."""
|
||||
import asyncio
|
||||
from services.booking_scraper import run_manual_scrape, cleanup_stale_batches
|
||||
|
||||
db = SyncSessionLocal()
|
||||
try:
|
||||
# Clean up any stale batches before starting
|
||||
cleanup_stale_batches(db, max_age_minutes=60)
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(run_manual_scrape(db, from_date, to_date))
|
||||
logger.info(f"Background scrape completed: {result}")
|
||||
finally:
|
||||
loop.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Background scrape failed: {e}", exc_info=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/scrape")
|
||||
async def trigger_manual_scrape(
|
||||
request: ScrapeRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger a manual scrape for the specified date range.
|
||||
|
||||
Runs in background - check /status for progress.
|
||||
"""
|
||||
try:
|
||||
from_date = date.fromisoformat(request.from_date)
|
||||
to_date = date.fromisoformat(request.to_date) if request.to_date else from_date
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
|
||||
|
||||
if to_date < from_date:
|
||||
raise HTTPException(status_code=400, detail="to_date must be after from_date")
|
||||
|
||||
if (to_date - from_date).days > 30:
|
||||
raise HTTPException(status_code=400, detail="Date range cannot exceed 30 days for manual scrape")
|
||||
|
||||
# Check if location is configured
|
||||
location_result = await db.execute(
|
||||
text("SELECT id FROM booking_scrape_config WHERE is_active = TRUE LIMIT 1")
|
||||
)
|
||||
if not location_result.fetchone():
|
||||
raise HTTPException(status_code=400, detail="No scrape location configured. Set location first.")
|
||||
|
||||
# Check if paused
|
||||
paused_result = await db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'")
|
||||
)
|
||||
paused_row = paused_result.fetchone()
|
||||
if paused_row and paused_row.config_value == 'true':
|
||||
raise HTTPException(status_code=400, detail="Scraper is currently paused. Use /unpause first or wait for cooldown.")
|
||||
|
||||
# Start background task
|
||||
background_tasks.add_task(run_scrape_sync, from_date, to_date)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"from_date": from_date.isoformat(),
|
||||
"to_date": to_date.isoformat(),
|
||||
"message": "Scrape started in background. Check /status for progress."
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# HOTELS MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
@router.get("/hotels", response_model=List[HotelResponse])
|
||||
async def list_hotels(
|
||||
tier: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List all discovered hotels.
|
||||
|
||||
Filter by tier: 'own', 'competitor', 'market', or None for all.
|
||||
"""
|
||||
query = """
|
||||
SELECT id, booking_com_id, name, booking_com_url,
|
||||
star_rating, review_score, review_count,
|
||||
tier, display_order, notes, first_seen_at, last_seen_at
|
||||
FROM booking_com_hotels
|
||||
WHERE is_active = TRUE
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if tier:
|
||||
if tier not in ('own', 'competitor', 'market'):
|
||||
raise HTTPException(status_code=400, detail="Invalid tier. Must be 'own', 'competitor', or 'market'")
|
||||
query += " AND tier = :tier"
|
||||
params['tier'] = tier
|
||||
|
||||
query += " ORDER BY display_order, name"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
|
||||
return [
|
||||
HotelResponse(
|
||||
id=row.id,
|
||||
booking_com_id=row.booking_com_id or '',
|
||||
name=row.name,
|
||||
booking_com_url=row.booking_com_url,
|
||||
star_rating=float(row.star_rating) if row.star_rating else None,
|
||||
review_score=float(row.review_score) if row.review_score else None,
|
||||
review_count=row.review_count,
|
||||
tier=row.tier,
|
||||
display_order=row.display_order,
|
||||
notes=row.notes,
|
||||
first_seen_at=row.first_seen_at,
|
||||
last_seen_at=row.last_seen_at
|
||||
)
|
||||
for row in result.fetchall()
|
||||
]
|
||||
|
||||
|
||||
@router.put("/hotels/{hotel_id}/tier")
|
||||
async def update_hotel_tier(
|
||||
hotel_id: int,
|
||||
update: HotelTierUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update a hotel's tier and display order.
|
||||
|
||||
Tiers:
|
||||
- 'own': Your hotel (for parity checking)
|
||||
- 'competitor': Main competitors (full tracking)
|
||||
- 'market': Other hotels (context only)
|
||||
"""
|
||||
if update.tier not in ('own', 'competitor', 'market'):
|
||||
raise HTTPException(status_code=400, detail="Invalid tier")
|
||||
|
||||
# If setting as 'own', clear any existing 'own' hotel
|
||||
if update.tier == 'own':
|
||||
await db.execute(
|
||||
text("UPDATE booking_com_hotels SET tier = 'market' WHERE tier = 'own'")
|
||||
)
|
||||
|
||||
# Update the hotel
|
||||
set_clause = "tier = :tier"
|
||||
params = {'hotel_id': hotel_id, 'tier': update.tier}
|
||||
|
||||
if update.display_order is not None:
|
||||
set_clause += ", display_order = :order"
|
||||
params['order'] = update.display_order
|
||||
|
||||
result = await db.execute(
|
||||
text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id RETURNING id"),
|
||||
params
|
||||
)
|
||||
|
||||
if not result.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# If this is now the own hotel, update system config
|
||||
if update.tier == 'own':
|
||||
await db.execute(
|
||||
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_own_hotel_id'"),
|
||||
{'val': str(hotel_id)}
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"status": "success", "hotel_id": hotel_id, "tier": update.tier}
|
||||
|
||||
|
||||
@router.put("/hotels/{hotel_id}/notes")
|
||||
async def update_hotel_notes(
|
||||
hotel_id: int,
|
||||
notes: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update notes for a hotel."""
|
||||
result = await db.execute(
|
||||
text("UPDATE booking_com_hotels SET notes = :notes WHERE id = :hotel_id RETURNING id"),
|
||||
{'hotel_id': hotel_id, 'notes': notes}
|
||||
)
|
||||
|
||||
if not result.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# COMPETITOR RATES MATRIX
|
||||
# ============================================
|
||||
|
||||
@router.get("/matrix")
|
||||
async def get_competitor_matrix(
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
include_market: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get rate comparison matrix for competitors.
|
||||
|
||||
Returns rates for own hotel and competitors, organized by date.
|
||||
Set include_market=true to also include market tier hotels.
|
||||
"""
|
||||
today = date.today()
|
||||
start = date.fromisoformat(from_date) if from_date else today
|
||||
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
|
||||
|
||||
if end < start:
|
||||
raise HTTPException(status_code=400, detail="to_date must be after from_date")
|
||||
if (end - start).days > 90:
|
||||
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
|
||||
|
||||
tier_filter = "h.tier IN ('own', 'competitor')"
|
||||
if include_market:
|
||||
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
|
||||
|
||||
# Get hotels
|
||||
hotels_result = await db.execute(
|
||||
text(f"""
|
||||
SELECT id, name, tier, display_order, star_rating, review_score, booking_com_url
|
||||
FROM booking_com_hotels
|
||||
WHERE is_active = TRUE AND {tier_filter.replace('h.', '')}
|
||||
ORDER BY display_order, name
|
||||
""")
|
||||
)
|
||||
hotels = [dict(row._mapping) for row in hotels_result.fetchall()]
|
||||
|
||||
# Get latest rates using the view
|
||||
rates_result = await db.execute(
|
||||
text(f"""
|
||||
SELECT DISTINCT ON (r.hotel_id, r.rate_date)
|
||||
r.hotel_id,
|
||||
r.rate_date,
|
||||
r.availability_status,
|
||||
r.rate_gross,
|
||||
r.room_type,
|
||||
r.breakfast_included,
|
||||
r.free_cancellation,
|
||||
r.no_prepayment,
|
||||
r.rooms_left,
|
||||
r.scraped_at
|
||||
FROM booking_com_rates r
|
||||
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||
WHERE {tier_filter}
|
||||
AND h.is_active = TRUE
|
||||
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
|
||||
ORDER BY r.hotel_id, r.rate_date, r.scraped_at DESC
|
||||
"""),
|
||||
{'from_date': start, 'to_date': end}
|
||||
)
|
||||
|
||||
# Build matrix: hotel_id -> date -> rate data
|
||||
rates_by_hotel: Dict[int, Dict[str, dict]] = {}
|
||||
for row in rates_result.fetchall():
|
||||
hotel_id = row.hotel_id
|
||||
rate_date = row.rate_date.isoformat()
|
||||
|
||||
if hotel_id not in rates_by_hotel:
|
||||
rates_by_hotel[hotel_id] = {}
|
||||
|
||||
rates_by_hotel[hotel_id][rate_date] = {
|
||||
'availability_status': row.availability_status,
|
||||
'rate_gross': float(row.rate_gross) if row.rate_gross else None,
|
||||
'room_type': row.room_type,
|
||||
'breakfast_included': row.breakfast_included,
|
||||
'free_cancellation': row.free_cancellation,
|
||||
'no_prepayment': row.no_prepayment,
|
||||
'rooms_left': row.rooms_left,
|
||||
'scraped_at': row.scraped_at.isoformat() if row.scraped_at else None,
|
||||
}
|
||||
|
||||
# Build date list
|
||||
dates = []
|
||||
current = start
|
||||
while current <= end:
|
||||
dates.append(current.isoformat())
|
||||
current += timedelta(days=1)
|
||||
|
||||
return {
|
||||
'from_date': start.isoformat(),
|
||||
'to_date': end.isoformat(),
|
||||
'dates': dates,
|
||||
'hotels': hotels,
|
||||
'rates': rates_by_hotel
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATE PARITY (OWN HOTEL VS NEWBOOK)
|
||||
# ============================================
|
||||
|
||||
@router.get("/parity")
|
||||
async def get_rate_parity(
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get rate parity comparison between booking.com and Newbook rates.
|
||||
|
||||
Compares scraped booking.com rates for own hotel against Newbook current rates.
|
||||
"""
|
||||
today = date.today()
|
||||
start = date.fromisoformat(from_date) if from_date else today
|
||||
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
|
||||
|
||||
# Get own hotel's booking.com rates
|
||||
booking_rates_result = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (r.rate_date)
|
||||
r.rate_date,
|
||||
r.rate_gross as booking_rate,
|
||||
r.availability_status,
|
||||
r.room_type as booking_room_type,
|
||||
r.scraped_at
|
||||
FROM booking_com_rates r
|
||||
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||
WHERE h.tier = 'own'
|
||||
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
|
||||
ORDER BY r.rate_date, r.scraped_at DESC
|
||||
"""),
|
||||
{'from_date': start, 'to_date': end}
|
||||
)
|
||||
booking_rates = {row.rate_date: dict(row._mapping) for row in booking_rates_result.fetchall()}
|
||||
|
||||
# Get Newbook rates (best rate per date across categories)
|
||||
newbook_rates_result = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (rate_date)
|
||||
rate_date,
|
||||
rate_gross as newbook_rate,
|
||||
category_id
|
||||
FROM newbook_current_rates
|
||||
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||
ORDER BY rate_date, valid_from DESC
|
||||
"""),
|
||||
{'from_date': start, 'to_date': end}
|
||||
)
|
||||
newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()}
|
||||
|
||||
# Compare rates
|
||||
parity_issues = []
|
||||
all_dates = set(booking_rates.keys()) | set(newbook_rates.keys())
|
||||
|
||||
for rate_date in sorted(all_dates):
|
||||
booking = booking_rates.get(rate_date)
|
||||
newbook = newbook_rates.get(rate_date)
|
||||
|
||||
if not booking or not newbook:
|
||||
continue
|
||||
|
||||
booking_rate = booking.get('booking_rate')
|
||||
newbook_rate = newbook.get('newbook_rate')
|
||||
|
||||
if not booking_rate or not newbook_rate:
|
||||
continue
|
||||
|
||||
diff_pct = ((float(booking_rate) - float(newbook_rate)) / float(newbook_rate)) * 100
|
||||
|
||||
if abs(diff_pct) > 1: # More than 1% difference
|
||||
parity_issues.append({
|
||||
'rate_date': rate_date.isoformat(),
|
||||
'booking_rate': float(booking_rate),
|
||||
'newbook_rate': float(newbook_rate),
|
||||
'difference_pct': round(diff_pct, 2),
|
||||
'alert_type': 'higher' if diff_pct > 0 else 'lower',
|
||||
'booking_room_type': booking.get('booking_room_type'),
|
||||
'availability_status': booking.get('availability_status'),
|
||||
})
|
||||
|
||||
return {
|
||||
'from_date': start.isoformat(),
|
||||
'to_date': end.isoformat(),
|
||||
'issues_count': len(parity_issues),
|
||||
'issues': parity_issues
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# PARITY ALERTS
|
||||
# ============================================
|
||||
|
||||
@router.get("/parity/alerts")
|
||||
async def get_parity_alerts(
|
||||
status: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get rate parity alerts."""
|
||||
query = """
|
||||
SELECT id, rate_date, room_category, newbook_rate, booking_com_rate,
|
||||
difference_pct, alert_type, alert_status, created_at,
|
||||
acknowledged_at, acknowledged_by, notes
|
||||
FROM rate_parity_alerts
|
||||
"""
|
||||
params = {}
|
||||
|
||||
if status:
|
||||
query += " WHERE alert_status = :status"
|
||||
params['status'] = status
|
||||
|
||||
query += " ORDER BY rate_date DESC, created_at DESC LIMIT 100"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
|
||||
return [
|
||||
{
|
||||
'id': row.id,
|
||||
'rate_date': row.rate_date.isoformat(),
|
||||
'room_category': row.room_category,
|
||||
'newbook_rate': float(row.newbook_rate) if row.newbook_rate else None,
|
||||
'booking_com_rate': float(row.booking_com_rate) if row.booking_com_rate else None,
|
||||
'difference_pct': float(row.difference_pct) if row.difference_pct else None,
|
||||
'alert_type': row.alert_type,
|
||||
'alert_status': row.alert_status,
|
||||
'created_at': row.created_at.isoformat() if row.created_at else None,
|
||||
'acknowledged_at': row.acknowledged_at.isoformat() if row.acknowledged_at else None,
|
||||
'acknowledged_by': row.acknowledged_by,
|
||||
'notes': row.notes,
|
||||
}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
|
||||
|
||||
@router.put("/parity/alerts/{alert_id}/acknowledge")
|
||||
async def acknowledge_parity_alert(
|
||||
alert_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Acknowledge a parity alert."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
UPDATE rate_parity_alerts
|
||||
SET alert_status = 'acknowledged',
|
||||
acknowledged_at = NOW(),
|
||||
acknowledged_by = :username
|
||||
WHERE id = :alert_id
|
||||
RETURNING id
|
||||
"""),
|
||||
{'alert_id': alert_id, 'username': current_user.get('username', 'unknown')}
|
||||
)
|
||||
|
||||
if not result.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# QUEUE STATUS
|
||||
# ============================================
|
||||
|
||||
@router.get("/queue-status")
|
||||
async def get_queue_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get current scrape queue status."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as count,
|
||||
MIN(rate_date) as earliest_date,
|
||||
MAX(rate_date) as latest_date
|
||||
FROM booking_scrape_queue
|
||||
GROUP BY status
|
||||
""")
|
||||
)
|
||||
status_counts = {row.status: {
|
||||
'count': row.count,
|
||||
'earliest': row.earliest_date.isoformat() if row.earliest_date else None,
|
||||
'latest': row.latest_date.isoformat() if row.latest_date else None,
|
||||
} for row in result.fetchall()}
|
||||
|
||||
# Get retry items (failed but under max_attempts)
|
||||
retry_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM booking_scrape_queue
|
||||
WHERE status = 'pending' AND attempts > 0
|
||||
""")
|
||||
)
|
||||
retry_count = retry_result.fetchone().count
|
||||
|
||||
return {
|
||||
'statuses': status_counts,
|
||||
'retries_pending': retry_count,
|
||||
'total_pending': status_counts.get('pending', {}).get('count', 0),
|
||||
'total_completed': status_counts.get('completed', {}).get('count', 0),
|
||||
'total_failed': status_counts.get('failed', {}).get('count', 0),
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCHEDULE INFO
|
||||
# ============================================
|
||||
|
||||
@router.get("/schedule-info")
|
||||
async def get_schedule_info(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get information about the scraping schedule."""
|
||||
# Get configured time
|
||||
time_result = await db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_daily_time'")
|
||||
)
|
||||
time_row = time_result.fetchone()
|
||||
daily_time = time_row.config_value if time_row and time_row.config_value else '05:30'
|
||||
|
||||
# Calculate what today's schedule would look like
|
||||
from jobs.scrape_booking_rates import get_high_priority_dates, get_medium_priority_dates, get_low_priority_dates
|
||||
high = get_high_priority_dates()
|
||||
medium = get_medium_priority_dates()
|
||||
low = get_low_priority_dates()
|
||||
|
||||
today = date.today()
|
||||
weekday_name = today.strftime('%A')
|
||||
|
||||
return {
|
||||
'daily_time': daily_time,
|
||||
'today': today.isoformat(),
|
||||
'weekday': weekday_name,
|
||||
'tiers': {
|
||||
'high': {
|
||||
'description': 'Next 30 days (scraped first)',
|
||||
'dates_today': len(high),
|
||||
'range': f'{high[0].isoformat()} to {high[-1].isoformat()}' if high else None,
|
||||
},
|
||||
'medium': {
|
||||
'description': 'Days 31-180 (scraped after high priority)',
|
||||
'dates_today': len(medium),
|
||||
'range': f'{medium[0].isoformat()} to {medium[-1].isoformat()}' if medium else None,
|
||||
},
|
||||
'low': {
|
||||
'description': 'Days 181-365 (scraped last, or until rate limit)',
|
||||
'dates_today': len(low),
|
||||
'range': f'{low[0].isoformat()} to {low[-1].isoformat()}' if low else None,
|
||||
},
|
||||
},
|
||||
'total_dates_today': len(set(high + medium + low)),
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCRAPE COVERAGE (365-day view)
|
||||
# ============================================
|
||||
|
||||
@router.get("/scrape-coverage")
|
||||
async def get_scrape_coverage(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get 365-day scrape coverage showing last scraped time
|
||||
and next expected scrape for every date.
|
||||
"""
|
||||
today = date.today()
|
||||
end = today + timedelta(days=365)
|
||||
|
||||
# Get latest scraped_at per date (across all hotels)
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT rate_date, MAX(scraped_at) as last_scraped
|
||||
FROM booking_com_rates
|
||||
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||
GROUP BY rate_date
|
||||
"""),
|
||||
{'from_date': today, 'to_date': end}
|
||||
)
|
||||
scraped_map = {row.rate_date: row.last_scraped for row in result.fetchall()}
|
||||
|
||||
# Compute tier and next scrape for each date
|
||||
from jobs.scrape_booking_rates import compute_next_scrape_for_date
|
||||
|
||||
coverage = []
|
||||
for offset in range(366):
|
||||
d = today + timedelta(days=offset)
|
||||
tier, next_scrape = compute_next_scrape_for_date(d)
|
||||
last_scraped = scraped_map.get(d)
|
||||
|
||||
coverage.append({
|
||||
'date': d.isoformat(),
|
||||
'tier': tier,
|
||||
'last_scraped': last_scraped.isoformat() if last_scraped else None,
|
||||
'next_expected': next_scrape.isoformat() if next_scrape else None,
|
||||
})
|
||||
|
||||
return {
|
||||
'today': today.isoformat(),
|
||||
'coverage': coverage,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# BOOKING.COM AVAILABILITY CHECK (for Bookability page)
|
||||
# ============================================
|
||||
|
||||
@router.get("/booking-availability")
|
||||
async def get_booking_availability(
|
||||
from_date: Optional[str] = None,
|
||||
to_date: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Check own hotel's availability on booking.com.
|
||||
|
||||
Returns a simple summary: for each date in the range, whether the own hotel
|
||||
appears available on booking.com based on the latest scrape data.
|
||||
"""
|
||||
today = date.today()
|
||||
start = date.fromisoformat(from_date) if from_date else today
|
||||
end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30)
|
||||
|
||||
# Get own hotel's latest scraped availability
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT DISTINCT ON (r.rate_date)
|
||||
r.rate_date,
|
||||
r.availability_status,
|
||||
r.rate_gross,
|
||||
r.scraped_at
|
||||
FROM booking_com_rates r
|
||||
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||
WHERE h.tier = 'own'
|
||||
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
|
||||
ORDER BY r.rate_date, r.scraped_at DESC
|
||||
"""),
|
||||
{'from_date': start, 'to_date': end}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
'has_own_hotel': False,
|
||||
'dates_checked': 0,
|
||||
'dates_available': 0,
|
||||
'dates_sold_out': 0,
|
||||
'dates_no_data': 0,
|
||||
'latest_scrape': None,
|
||||
'dates': {},
|
||||
}
|
||||
|
||||
dates_map = {}
|
||||
dates_available = 0
|
||||
dates_sold_out = 0
|
||||
dates_no_data = 0
|
||||
latest_scrape = None
|
||||
|
||||
for row in rows:
|
||||
status = row.availability_status
|
||||
dates_map[row.rate_date.isoformat()] = {
|
||||
'status': status,
|
||||
'rate': float(row.rate_gross) if row.rate_gross else None,
|
||||
}
|
||||
if status == 'available':
|
||||
dates_available += 1
|
||||
elif status == 'sold_out':
|
||||
dates_sold_out += 1
|
||||
else:
|
||||
dates_no_data += 1
|
||||
|
||||
if row.scraped_at and (not latest_scrape or row.scraped_at > latest_scrape):
|
||||
latest_scrape = row.scraped_at
|
||||
|
||||
return {
|
||||
'has_own_hotel': True,
|
||||
'dates_checked': len(rows),
|
||||
'dates_available': dates_available,
|
||||
'dates_sold_out': dates_sold_out,
|
||||
'dates_no_data': dates_no_data,
|
||||
'latest_scrape': latest_scrape.isoformat() if latest_scrape else None,
|
||||
'dates': dates_map,
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCRAPE HISTORY
|
||||
# ============================================
|
||||
|
||||
@router.get("/scrape-history")
|
||||
async def get_scrape_history(
|
||||
limit: int = 20,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent scrape batch history."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT batch_id, scrape_type, started_at, completed_at, status,
|
||||
dates_queued, dates_completed, dates_failed,
|
||||
hotels_found, rates_scraped, error_message,
|
||||
blocked_at, resume_after
|
||||
FROM booking_scrape_log
|
||||
ORDER BY started_at DESC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{'limit': limit}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'batch_id': str(row.batch_id),
|
||||
'scrape_type': row.scrape_type,
|
||||
'started_at': row.started_at.isoformat() if row.started_at else None,
|
||||
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
|
||||
'status': row.status,
|
||||
'dates_queued': row.dates_queued,
|
||||
'dates_completed': row.dates_completed,
|
||||
'dates_failed': row.dates_failed,
|
||||
'hotels_found': row.hotels_found,
|
||||
'rates_scraped': row.rates_scraped,
|
||||
'error_message': row.error_message,
|
||||
'blocked_at': row.blocked_at.isoformat() if row.blocked_at else None,
|
||||
'resume_after': row.resume_after.isoformat() if row.resume_after else None,
|
||||
}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
1293
backend/api/config.py
Normal file
1293
backend/api/config.py
Normal file
File diff suppressed because it is too large
Load diff
300
backend/api/crossref.py
Normal file
300
backend/api/crossref.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""
|
||||
Cross-Reference Validation API endpoints
|
||||
Validate that related forecasts align with each other
|
||||
"""
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/check")
|
||||
async def run_cross_reference_check(
|
||||
check_date: date = Query(..., description="Date to run cross-reference checks for"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Run all cross-reference checks for a specific date.
|
||||
Returns validation results showing if forecasts are internally consistent.
|
||||
"""
|
||||
# Get all active cross-reference configurations
|
||||
config_query = """
|
||||
SELECT check_name, check_category, formula, compares_to, tolerance_pct, input_metrics
|
||||
FROM cross_reference_config
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY display_order
|
||||
"""
|
||||
|
||||
config_result = await db.execute(text(config_query))
|
||||
configs = config_result.fetchall()
|
||||
|
||||
# Get all forecast values for the date
|
||||
forecast_query = """
|
||||
SELECT forecast_type, predicted_value
|
||||
FROM forecasts
|
||||
WHERE forecast_date = :check_date
|
||||
AND model_type = 'prophet'
|
||||
"""
|
||||
|
||||
forecast_result = await db.execute(text(forecast_query), {"check_date": check_date})
|
||||
forecasts = {row.forecast_type: float(row.predicted_value) for row in forecast_result.fetchall()}
|
||||
|
||||
results = []
|
||||
for config in configs:
|
||||
# For now, return placeholder results
|
||||
# In production, would evaluate formula against forecasts
|
||||
compares_to_value = forecasts.get(config.compares_to)
|
||||
|
||||
results.append({
|
||||
"check_name": config.check_name,
|
||||
"check_category": config.check_category,
|
||||
"formula": config.formula,
|
||||
"compares_to": config.compares_to,
|
||||
"forecasted_value": compares_to_value,
|
||||
"calculated_value": None, # Would be calculated from formula
|
||||
"difference": None,
|
||||
"difference_pct": None,
|
||||
"tolerance_pct": float(config.tolerance_pct),
|
||||
"status": "ok" # Would be evaluated based on tolerance
|
||||
})
|
||||
|
||||
return {
|
||||
"check_date": check_date,
|
||||
"results": results,
|
||||
"alignment_score": 100 # Would be calculated
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report")
|
||||
async def get_cross_reference_report(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get cross-reference validation report for date range.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
forecast_date,
|
||||
check_name,
|
||||
check_category,
|
||||
calculated_value,
|
||||
forecasted_value,
|
||||
difference,
|
||||
difference_pct,
|
||||
tolerance_pct,
|
||||
status
|
||||
FROM forecast_cross_reference
|
||||
WHERE forecast_date BETWEEN :from_date AND :to_date
|
||||
ORDER BY forecast_date, check_category, check_name
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.forecast_date,
|
||||
"check_name": row.check_name,
|
||||
"category": row.check_category,
|
||||
"calculated": float(row.calculated_value) if row.calculated_value else None,
|
||||
"forecasted": float(row.forecasted_value) if row.forecasted_value else None,
|
||||
"difference": float(row.difference) if row.difference else None,
|
||||
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
|
||||
"tolerance_pct": float(row.tolerance_pct),
|
||||
"status": row.status
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/discrepancies")
|
||||
async def get_discrepancies(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get list of dates with cross-reference discrepancies.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
forecast_date,
|
||||
check_name,
|
||||
check_category,
|
||||
difference_pct,
|
||||
tolerance_pct,
|
||||
possible_causes,
|
||||
recommendation
|
||||
FROM forecast_cross_reference
|
||||
WHERE forecast_date BETWEEN :from_date AND :to_date
|
||||
AND status = 'discrepancy'
|
||||
ORDER BY ABS(difference_pct) DESC
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.forecast_date,
|
||||
"check_name": row.check_name,
|
||||
"category": row.check_category,
|
||||
"difference_pct": float(row.difference_pct) if row.difference_pct else None,
|
||||
"tolerance_pct": float(row.tolerance_pct),
|
||||
"possible_causes": row.possible_causes,
|
||||
"recommendation": row.recommendation
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/alignment-score")
|
||||
async def get_alignment_score(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get overall alignment score for date range.
|
||||
Higher score = more internally consistent forecasts.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) as total_checks,
|
||||
SUM(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) as passed,
|
||||
SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warnings,
|
||||
SUM(CASE WHEN status = 'discrepancy' THEN 1 ELSE 0 END) as discrepancies
|
||||
FROM forecast_cross_reference
|
||||
WHERE forecast_date BETWEEN :from_date AND :to_date
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
row = result.fetchone()
|
||||
|
||||
if not row or row.total_checks == 0:
|
||||
return {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"alignment_score": 100,
|
||||
"total_checks": 0,
|
||||
"passed": 0,
|
||||
"warnings": 0,
|
||||
"discrepancies": 0
|
||||
}
|
||||
|
||||
# Score: 100 * (passed / total), with warnings counting as 0.5
|
||||
score = ((row.passed + row.warnings * 0.5) / row.total_checks) * 100
|
||||
|
||||
return {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"alignment_score": round(score, 1),
|
||||
"total_checks": row.total_checks,
|
||||
"passed": row.passed,
|
||||
"warnings": row.warnings,
|
||||
"discrepancies": row.discrepancies
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_crossref_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get cross-reference check configuration.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
check_name,
|
||||
check_category,
|
||||
description,
|
||||
formula,
|
||||
compares_to,
|
||||
tolerance_pct,
|
||||
input_metrics,
|
||||
is_correlation_check,
|
||||
expected_correlation,
|
||||
is_active,
|
||||
display_order
|
||||
FROM cross_reference_config
|
||||
ORDER BY display_order
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query))
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"check_name": row.check_name,
|
||||
"category": row.check_category,
|
||||
"description": row.description,
|
||||
"formula": row.formula,
|
||||
"compares_to": row.compares_to,
|
||||
"tolerance_pct": float(row.tolerance_pct),
|
||||
"input_metrics": row.input_metrics,
|
||||
"is_correlation_check": row.is_correlation_check,
|
||||
"expected_correlation": float(row.expected_correlation) if row.expected_correlation else None,
|
||||
"is_active": row.is_active
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.put("/config/{check_name}")
|
||||
async def update_crossref_config(
|
||||
check_name: str,
|
||||
tolerance_pct: Optional[float] = Query(None),
|
||||
is_active: Optional[bool] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Update tolerance or active status for a cross-reference check.
|
||||
"""
|
||||
updates = []
|
||||
params = {"check_name": check_name}
|
||||
|
||||
if tolerance_pct is not None:
|
||||
updates.append("tolerance_pct = :tolerance_pct")
|
||||
params["tolerance_pct"] = tolerance_pct
|
||||
|
||||
if is_active is not None:
|
||||
updates.append("is_active = :is_active")
|
||||
params["is_active"] = is_active
|
||||
|
||||
if not updates:
|
||||
return {"status": "no_changes", "check_name": check_name}
|
||||
|
||||
query = f"""
|
||||
UPDATE cross_reference_config
|
||||
SET {', '.join(updates)}
|
||||
WHERE check_name = :check_name
|
||||
RETURNING check_name
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
await db.commit()
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Check not found: {check_name}")
|
||||
|
||||
return {
|
||||
"status": "updated",
|
||||
"check_name": check_name,
|
||||
"updates": {k: v for k, v in params.items() if k != "check_name"}
|
||||
}
|
||||
292
backend/api/evolution.py
Normal file
292
backend/api/evolution.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""
|
||||
Forecast Evolution API endpoints
|
||||
Track how forecasts change over time as dates approach
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/date")
|
||||
async def get_forecast_evolution_for_date(
|
||||
forecast_date: date = Query(..., description="The date to see evolution for"),
|
||||
forecast_type: str = Query(..., description="Metric code"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get full forecast history for a specific date.
|
||||
Shows how predictions changed as the date approached.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
fh.generated_at,
|
||||
fh.model_type,
|
||||
fh.predicted_value,
|
||||
fh.lower_bound,
|
||||
fh.upper_bound,
|
||||
fh.horizon_days,
|
||||
fh.change_amount,
|
||||
fh.change_pct,
|
||||
fh.change_reason,
|
||||
dm.actual_value
|
||||
FROM forecast_history fh
|
||||
LEFT JOIN daily_metrics dm ON fh.forecast_date = dm.date AND fh.forecast_type = dm.metric_code
|
||||
WHERE fh.forecast_date = :forecast_date
|
||||
AND fh.forecast_type = :forecast_type
|
||||
ORDER BY fh.generated_at, fh.model_type
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type
|
||||
})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"generated_at": row.generated_at,
|
||||
"model_type": row.model_type,
|
||||
"predicted_value": float(row.predicted_value),
|
||||
"lower_bound": float(row.lower_bound) if row.lower_bound else None,
|
||||
"upper_bound": float(row.upper_bound) if row.upper_bound else None,
|
||||
"horizon_days": row.horizon_days,
|
||||
"change_amount": float(row.change_amount) if row.change_amount else None,
|
||||
"change_pct": float(row.change_pct) if row.change_pct else None,
|
||||
"change_reason": row.change_reason,
|
||||
"actual_value": float(row.actual_value) if row.actual_value else None
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/chart-data")
|
||||
async def get_evolution_chart_data(
|
||||
forecast_date: date = Query(...),
|
||||
forecast_type: str = Query(...),
|
||||
model: str = Query("prophet", description="Model: prophet, xgboost, pickup"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get evolution data formatted for charting.
|
||||
Returns time series of forecast values as date approached.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
DATE(fh.generated_at) as update_date,
|
||||
fh.horizon_days,
|
||||
fh.predicted_value,
|
||||
fh.lower_bound,
|
||||
fh.upper_bound,
|
||||
dm.actual_value
|
||||
FROM forecast_history fh
|
||||
LEFT JOIN daily_metrics dm ON fh.forecast_date = dm.date AND fh.forecast_type = dm.metric_code
|
||||
WHERE fh.forecast_date = :forecast_date
|
||||
AND fh.forecast_type = :forecast_type
|
||||
AND fh.model_type = :model
|
||||
ORDER BY fh.generated_at
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": model
|
||||
})
|
||||
rows = result.fetchall()
|
||||
|
||||
actual_value = None
|
||||
chart_data = []
|
||||
|
||||
for row in rows:
|
||||
# Use 'is not None' - 0 is valid actual data (e.g., 0 covers on closed day)
|
||||
if row.actual_value is not None:
|
||||
actual_value = float(row.actual_value)
|
||||
chart_data.append({
|
||||
"update_date": row.update_date,
|
||||
"horizon_days": row.horizon_days,
|
||||
"predicted_value": float(row.predicted_value),
|
||||
"lower_bound": float(row.lower_bound) if row.lower_bound else None,
|
||||
"upper_bound": float(row.upper_bound) if row.upper_bound else None
|
||||
})
|
||||
|
||||
return {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": model,
|
||||
"actual_value": actual_value,
|
||||
"data_points": chart_data
|
||||
}
|
||||
|
||||
|
||||
@router.get("/changes")
|
||||
async def get_forecast_changes(
|
||||
forecast_date: date = Query(...),
|
||||
forecast_type: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get list of all changes with reasons for a specific forecast.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
changed_at,
|
||||
model_type,
|
||||
old_value,
|
||||
new_value,
|
||||
change_amount,
|
||||
change_pct,
|
||||
change_category,
|
||||
change_reason,
|
||||
bookings_added,
|
||||
bookings_cancelled,
|
||||
covers_change,
|
||||
days_out,
|
||||
otb_at_change
|
||||
FROM forecast_change_log
|
||||
WHERE forecast_date = :forecast_date
|
||||
AND forecast_type = :forecast_type
|
||||
ORDER BY changed_at DESC
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type
|
||||
})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"changed_at": row.changed_at,
|
||||
"model_type": row.model_type,
|
||||
"old_value": float(row.old_value) if row.old_value else None,
|
||||
"new_value": float(row.new_value) if row.new_value else None,
|
||||
"change_amount": float(row.change_amount) if row.change_amount else None,
|
||||
"change_pct": float(row.change_pct) if row.change_pct else None,
|
||||
"change_category": row.change_category,
|
||||
"change_reason": row.change_reason,
|
||||
"bookings_added": row.bookings_added,
|
||||
"bookings_cancelled": row.bookings_cancelled,
|
||||
"covers_change": row.covers_change,
|
||||
"days_out": row.days_out,
|
||||
"otb_at_change": float(row.otb_at_change) if row.otb_at_change is not None else None
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/convergence")
|
||||
async def get_forecast_convergence(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Analyze how quickly forecasts converge to actuals.
|
||||
Shows forecast accuracy at different lead times.
|
||||
"""
|
||||
query = """
|
||||
WITH convergence_data AS (
|
||||
SELECT
|
||||
fh.forecast_type,
|
||||
fh.model_type,
|
||||
fh.horizon_days,
|
||||
ABS(fh.predicted_value - dm.actual_value) as abs_error,
|
||||
ABS((fh.predicted_value - dm.actual_value) / NULLIF(dm.actual_value, 0) * 100) as pct_error
|
||||
FROM forecast_history fh
|
||||
JOIN daily_metrics dm ON fh.forecast_date = dm.date AND fh.forecast_type = dm.metric_code
|
||||
WHERE fh.forecast_date BETWEEN :from_date AND :to_date
|
||||
AND dm.actual_value IS NOT NULL
|
||||
)
|
||||
SELECT
|
||||
forecast_type,
|
||||
model_type,
|
||||
CASE
|
||||
WHEN horizon_days <= 7 THEN '0-7 days'
|
||||
WHEN horizon_days <= 14 THEN '8-14 days'
|
||||
WHEN horizon_days <= 21 THEN '15-21 days'
|
||||
WHEN horizon_days <= 28 THEN '22-28 days'
|
||||
ELSE '29+ days'
|
||||
END as horizon_bucket,
|
||||
AVG(abs_error) as avg_error,
|
||||
AVG(pct_error) as avg_pct_error,
|
||||
COUNT(*) as sample_count
|
||||
FROM convergence_data
|
||||
GROUP BY forecast_type, model_type,
|
||||
CASE
|
||||
WHEN horizon_days <= 7 THEN '0-7 days'
|
||||
WHEN horizon_days <= 14 THEN '8-14 days'
|
||||
WHEN horizon_days <= 21 THEN '15-21 days'
|
||||
WHEN horizon_days <= 28 THEN '22-28 days'
|
||||
ELSE '29+ days'
|
||||
END
|
||||
ORDER BY forecast_type, model_type, horizon_bucket
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"forecast_type": row.forecast_type,
|
||||
"model_type": row.model_type,
|
||||
"horizon_bucket": row.horizon_bucket,
|
||||
"avg_error": round(float(row.avg_error), 2) if row.avg_error else None,
|
||||
"avg_pct_error": round(float(row.avg_pct_error), 2) if row.avg_pct_error else None,
|
||||
"sample_count": row.sample_count
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/volatility")
|
||||
async def get_forecast_volatility(
|
||||
from_date: date = Query(...),
|
||||
to_date: date = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Identify dates with high forecast volatility.
|
||||
Shows which dates had the most forecast changes.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
forecast_date,
|
||||
forecast_type,
|
||||
COUNT(*) as change_count,
|
||||
MAX(ABS(change_amount)) as max_change,
|
||||
SUM(ABS(change_amount)) as total_change,
|
||||
array_agg(DISTINCT change_category) as change_categories
|
||||
FROM forecast_change_log
|
||||
WHERE forecast_date BETWEEN :from_date AND :to_date
|
||||
GROUP BY forecast_date, forecast_type
|
||||
HAVING COUNT(*) > 3 OR MAX(ABS(change_pct)) > 10
|
||||
ORDER BY total_change DESC
|
||||
LIMIT 20
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"forecast_date": row.forecast_date,
|
||||
"forecast_type": row.forecast_type,
|
||||
"change_count": row.change_count,
|
||||
"max_change": float(row.max_change) if row.max_change else None,
|
||||
"total_change": float(row.total_change) if row.total_change else None,
|
||||
"change_categories": row.change_categories
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
328
backend/api/explain.py
Normal file
328
backend/api/explain.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
"""
|
||||
Model Explainability API endpoints
|
||||
Explain why forecasts have specific values
|
||||
"""
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/forecast")
|
||||
async def explain_forecast(
|
||||
forecast_date: date = Query(...),
|
||||
forecast_type: str = Query(...),
|
||||
model: str = Query("prophet", description="Model: prophet, xgboost, pickup"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get full breakdown of how a forecast was calculated.
|
||||
Returns different explanations based on model type.
|
||||
"""
|
||||
# Get the forecast value
|
||||
forecast_query = """
|
||||
SELECT predicted_value, lower_bound, upper_bound, generated_at
|
||||
FROM forecasts
|
||||
WHERE forecast_date = :forecast_date
|
||||
AND forecast_type = :forecast_type
|
||||
AND model_type = :model
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = await db.execute(text(forecast_query), {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": model
|
||||
})
|
||||
forecast = result.fetchone()
|
||||
|
||||
if not forecast:
|
||||
return {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": model,
|
||||
"error": "No forecast found"
|
||||
}
|
||||
|
||||
# Get model-specific explanation
|
||||
if model == "prophet":
|
||||
return await _get_prophet_explanation(db, forecast_date, forecast_type, forecast)
|
||||
elif model == "xgboost":
|
||||
return await _get_xgboost_explanation(db, forecast_date, forecast_type, forecast)
|
||||
elif model == "pickup":
|
||||
return await _get_pickup_explanation(db, forecast_date, forecast_type, forecast)
|
||||
else:
|
||||
return {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": model,
|
||||
"predicted_value": float(forecast.predicted_value),
|
||||
"explanation": "No detailed explanation available for this model"
|
||||
}
|
||||
|
||||
|
||||
async def _get_prophet_explanation(db, forecast_date, forecast_type, forecast):
|
||||
"""Get Prophet model decomposition explanation"""
|
||||
query = """
|
||||
SELECT
|
||||
trend,
|
||||
yearly_seasonality,
|
||||
weekly_seasonality,
|
||||
daily_seasonality,
|
||||
holiday_effects,
|
||||
regressor_effects
|
||||
FROM prophet_decomposition
|
||||
WHERE forecast_date = :forecast_date
|
||||
AND forecast_type = :forecast_type
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type
|
||||
})
|
||||
decomp = result.fetchone()
|
||||
|
||||
explanation = {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": "prophet",
|
||||
"predicted_value": float(forecast.predicted_value),
|
||||
"lower_bound": float(forecast.lower_bound) if forecast.lower_bound else None,
|
||||
"upper_bound": float(forecast.upper_bound) if forecast.upper_bound else None,
|
||||
"generated_at": forecast.generated_at
|
||||
}
|
||||
|
||||
if decomp:
|
||||
components = []
|
||||
if decomp.trend:
|
||||
components.append({
|
||||
"name": "Base trend",
|
||||
"value": float(decomp.trend),
|
||||
"description": "Long-term average trend"
|
||||
})
|
||||
if decomp.yearly_seasonality:
|
||||
components.append({
|
||||
"name": "Yearly seasonality",
|
||||
"value": float(decomp.yearly_seasonality),
|
||||
"description": "Annual pattern (e.g., summer peak, winter low)"
|
||||
})
|
||||
if decomp.weekly_seasonality:
|
||||
components.append({
|
||||
"name": "Weekly seasonality",
|
||||
"value": float(decomp.weekly_seasonality),
|
||||
"description": "Day-of-week pattern (e.g., weekend higher)"
|
||||
})
|
||||
if decomp.holiday_effects:
|
||||
for holiday, effect in decomp.holiday_effects.items():
|
||||
components.append({
|
||||
"name": f"Holiday: {holiday}",
|
||||
"value": float(effect),
|
||||
"description": f"Effect of {holiday}"
|
||||
})
|
||||
|
||||
explanation["components"] = components
|
||||
explanation["breakdown"] = {
|
||||
"trend": float(decomp.trend) if decomp.trend else 0,
|
||||
"yearly": float(decomp.yearly_seasonality) if decomp.yearly_seasonality else 0,
|
||||
"weekly": float(decomp.weekly_seasonality) if decomp.weekly_seasonality else 0,
|
||||
"holidays": decomp.holiday_effects
|
||||
}
|
||||
|
||||
return explanation
|
||||
|
||||
|
||||
async def _get_xgboost_explanation(db, forecast_date, forecast_type, forecast):
|
||||
"""Get XGBoost SHAP explanation"""
|
||||
query = """
|
||||
SELECT
|
||||
base_value,
|
||||
feature_values,
|
||||
shap_values,
|
||||
top_positive,
|
||||
top_negative
|
||||
FROM xgboost_explanations
|
||||
WHERE forecast_date = :forecast_date
|
||||
AND forecast_type = :forecast_type
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type
|
||||
})
|
||||
shap = result.fetchone()
|
||||
|
||||
explanation = {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": "xgboost",
|
||||
"predicted_value": float(forecast.predicted_value),
|
||||
"generated_at": forecast.generated_at
|
||||
}
|
||||
|
||||
if shap:
|
||||
explanation["base_value"] = float(shap.base_value) if shap.base_value else None
|
||||
explanation["feature_values"] = shap.feature_values
|
||||
explanation["shap_values"] = shap.shap_values
|
||||
explanation["top_drivers"] = {
|
||||
"positive": shap.top_positive or [],
|
||||
"negative": shap.top_negative or []
|
||||
}
|
||||
|
||||
# Build human-readable summary
|
||||
summary_parts = []
|
||||
if shap.top_positive:
|
||||
for item in shap.top_positive[:3]:
|
||||
if isinstance(item, dict):
|
||||
summary_parts.append(f"{item.get('feature', 'Unknown')} (+{item.get('contribution', 0):.1f})")
|
||||
if shap.top_negative:
|
||||
for item in shap.top_negative[:2]:
|
||||
if isinstance(item, dict):
|
||||
summary_parts.append(f"{item.get('feature', 'Unknown')} ({item.get('contribution', 0):.1f})")
|
||||
|
||||
explanation["summary"] = f"Main drivers: {', '.join(summary_parts)}" if summary_parts else None
|
||||
|
||||
return explanation
|
||||
|
||||
|
||||
async def _get_pickup_explanation(db, forecast_date, forecast_type, forecast):
|
||||
"""Get Pickup model explanation"""
|
||||
query = """
|
||||
SELECT
|
||||
current_otb,
|
||||
days_out,
|
||||
comparison_date,
|
||||
comparison_otb,
|
||||
comparison_final,
|
||||
pickup_curve_pct,
|
||||
pickup_curve_stddev,
|
||||
pace_vs_prior_pct,
|
||||
projection_method,
|
||||
projected_value,
|
||||
confidence_note
|
||||
FROM pickup_explanations
|
||||
WHERE forecast_date = :forecast_date
|
||||
AND forecast_type = :forecast_type
|
||||
ORDER BY generated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type
|
||||
})
|
||||
pickup = result.fetchone()
|
||||
|
||||
explanation = {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": forecast_type,
|
||||
"model": "pickup",
|
||||
"predicted_value": float(forecast.predicted_value),
|
||||
"generated_at": forecast.generated_at
|
||||
}
|
||||
|
||||
if pickup:
|
||||
explanation["current_state"] = {
|
||||
"on_the_books": float(pickup.current_otb) if pickup.current_otb is not None else None,
|
||||
"days_out": pickup.days_out
|
||||
}
|
||||
explanation["comparison"] = {
|
||||
"date": pickup.comparison_date,
|
||||
"otb_at_same_lead_time": float(pickup.comparison_otb) if pickup.comparison_otb is not None else None,
|
||||
"final_actual": float(pickup.comparison_final) if pickup.comparison_final is not None else None
|
||||
}
|
||||
explanation["pickup_curve"] = {
|
||||
"avg_pct_of_final": float(pickup.pickup_curve_pct) if pickup.pickup_curve_pct is not None else None,
|
||||
"std_dev": float(pickup.pickup_curve_stddev) if pickup.pickup_curve_stddev is not None else None
|
||||
}
|
||||
explanation["pace_analysis"] = {
|
||||
"vs_prior_year_pct": float(pickup.pace_vs_prior_pct) if pickup.pace_vs_prior_pct is not None else None,
|
||||
"projection_method": pickup.projection_method,
|
||||
"projected_final": float(pickup.projected_value) if pickup.projected_value else None
|
||||
}
|
||||
explanation["confidence_note"] = pickup.confidence_note
|
||||
|
||||
# Build summary
|
||||
pace_str = ""
|
||||
if pickup.pace_vs_prior_pct:
|
||||
if pickup.pace_vs_prior_pct > 0:
|
||||
pace_str = f"{pickup.pace_vs_prior_pct:.1f}% ahead of last year's pace"
|
||||
else:
|
||||
pace_str = f"{abs(pickup.pace_vs_prior_pct):.1f}% behind last year's pace"
|
||||
|
||||
explanation["summary"] = f"At {pickup.days_out} days out, {pace_str}" if pace_str else None
|
||||
|
||||
return explanation
|
||||
|
||||
|
||||
@router.get("/prophet")
|
||||
async def get_prophet_decomposition(
|
||||
forecast_date: date = Query(...),
|
||||
forecast_type: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get Prophet decomposition (trend, seasonality, holidays) for a forecast.
|
||||
"""
|
||||
return await _get_prophet_explanation(
|
||||
db, forecast_date, forecast_type,
|
||||
type('obj', (object,), {
|
||||
'predicted_value': 0,
|
||||
'lower_bound': None,
|
||||
'upper_bound': None,
|
||||
'generated_at': None
|
||||
})()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/xgboost")
|
||||
async def get_xgboost_shap(
|
||||
forecast_date: date = Query(...),
|
||||
forecast_type: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get XGBoost SHAP values and feature contributions for a forecast.
|
||||
"""
|
||||
return await _get_xgboost_explanation(
|
||||
db, forecast_date, forecast_type,
|
||||
type('obj', (object,), {
|
||||
'predicted_value': 0,
|
||||
'generated_at': None
|
||||
})()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pickup")
|
||||
async def get_pickup_breakdown(
|
||||
forecast_date: date = Query(...),
|
||||
forecast_type: str = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get Pickup model calculation breakdown for a forecast.
|
||||
"""
|
||||
return await _get_pickup_explanation(
|
||||
db, forecast_date, forecast_type,
|
||||
type('obj', (object,), {
|
||||
'predicted_value': 0,
|
||||
'generated_at': None
|
||||
})()
|
||||
)
|
||||
|
||||
|
||||
237
backend/api/export.py
Normal file
237
backend/api/export.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""
|
||||
Export API endpoints for Excel/CSV downloads
|
||||
"""
|
||||
import io
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
import pandas as pd
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/excel")
|
||||
async def export_excel(
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Download Excel workbook with multiple sheets:
|
||||
- Daily Forecast (all models)
|
||||
- Weekly Summary
|
||||
- Budget Comparison
|
||||
- Model Accuracy
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today()
|
||||
if to_date is None:
|
||||
to_date = from_date + timedelta(days=28)
|
||||
|
||||
# Create Excel writer
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
# Daily forecasts sheet
|
||||
daily_query = """
|
||||
SELECT
|
||||
f.forecast_date as "Date",
|
||||
f.forecast_type as "Metric",
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as "Prophet",
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.lower_bound END) as "Prophet Lower",
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.upper_bound END) as "Prophet Upper",
|
||||
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as "XGBoost",
|
||||
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as "Pickup",
|
||||
db.budget_value as "Budget"
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
GROUP BY f.forecast_date, f.forecast_type, db.budget_value
|
||||
ORDER BY f.forecast_date, f.forecast_type
|
||||
"""
|
||||
result = await db.execute(text(daily_query), {"from_date": from_date, "to_date": to_date})
|
||||
daily_df = pd.DataFrame(result.fetchall())
|
||||
if not daily_df.empty:
|
||||
daily_df.to_excel(writer, sheet_name='Daily Forecast', index=False)
|
||||
|
||||
# Weekly summary sheet
|
||||
weekly_query = """
|
||||
SELECT
|
||||
DATE_TRUNC('week', f.forecast_date) as "Week Start",
|
||||
f.forecast_type as "Metric",
|
||||
AVG(f.predicted_value) as "Avg Forecast",
|
||||
SUM(f.predicted_value) as "Total Forecast",
|
||||
AVG(db.budget_value) as "Avg Budget",
|
||||
SUM(db.budget_value) as "Total Budget"
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
AND f.model_type = 'prophet'
|
||||
GROUP BY DATE_TRUNC('week', f.forecast_date), f.forecast_type
|
||||
ORDER BY "Week Start", f.forecast_type
|
||||
"""
|
||||
result = await db.execute(text(weekly_query), {"from_date": from_date, "to_date": to_date})
|
||||
weekly_df = pd.DataFrame(result.fetchall())
|
||||
if not weekly_df.empty:
|
||||
weekly_df.to_excel(writer, sheet_name='Weekly Summary', index=False)
|
||||
|
||||
# Budget variance sheet
|
||||
variance_query = """
|
||||
SELECT
|
||||
f.forecast_date as "Date",
|
||||
f.forecast_type as "Metric",
|
||||
f.predicted_value as "Forecast",
|
||||
db.budget_value as "Budget",
|
||||
(f.predicted_value - db.budget_value) as "Variance",
|
||||
CASE
|
||||
WHEN db.budget_value != 0 THEN
|
||||
ROUND(((f.predicted_value - db.budget_value) / db.budget_value * 100)::numeric, 1)
|
||||
ELSE NULL
|
||||
END as "Variance %"
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
AND f.model_type = 'prophet'
|
||||
ORDER BY f.forecast_date, f.forecast_type
|
||||
"""
|
||||
result = await db.execute(text(variance_query), {"from_date": from_date, "to_date": to_date})
|
||||
variance_df = pd.DataFrame(result.fetchall())
|
||||
if not variance_df.empty:
|
||||
variance_df.to_excel(writer, sheet_name='Budget Variance', index=False)
|
||||
|
||||
# Accuracy sheet (historical)
|
||||
accuracy_query = """
|
||||
SELECT
|
||||
date as "Date",
|
||||
metric_type as "Metric",
|
||||
actual_value as "Actual",
|
||||
prophet_forecast as "Prophet",
|
||||
xgboost_forecast as "XGBoost",
|
||||
pickup_forecast as "Pickup",
|
||||
best_model as "Best Model"
|
||||
FROM actual_vs_forecast
|
||||
WHERE date BETWEEN :from_date - INTERVAL '30 days' AND :from_date
|
||||
ORDER BY date, metric_type
|
||||
"""
|
||||
result = await db.execute(text(accuracy_query), {"from_date": from_date, "to_date": to_date})
|
||||
accuracy_df = pd.DataFrame(result.fetchall())
|
||||
if not accuracy_df.empty:
|
||||
accuracy_df.to_excel(writer, sheet_name='Historical Accuracy', index=False)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
filename = f"forecast_{from_date}_{to_date}.xlsx"
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/csv/{metric}")
|
||||
async def export_csv(
|
||||
metric: str,
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Download CSV for a specific metric.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today()
|
||||
if to_date is None:
|
||||
to_date = from_date + timedelta(days=28)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
f.forecast_date,
|
||||
f.model_type,
|
||||
f.predicted_value,
|
||||
f.lower_bound,
|
||||
f.upper_bound,
|
||||
dm.actual_value,
|
||||
db.budget_value
|
||||
FROM forecasts f
|
||||
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
|
||||
LEFT JOIN daily_budgets db ON f.forecast_date = db.date AND f.forecast_type = db.budget_type
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
AND f.forecast_type = :metric
|
||||
ORDER BY f.forecast_date, f.model_type
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"metric": metric
|
||||
})
|
||||
|
||||
df = pd.DataFrame(result.fetchall())
|
||||
|
||||
output = io.StringIO()
|
||||
df.to_csv(output, index=False)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"{metric}_{from_date}_{to_date}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/model-comparison")
|
||||
async def export_model_comparison(
|
||||
from_date: Optional[date] = Query(None),
|
||||
to_date: Optional[date] = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Export model comparison data for all metrics.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today()
|
||||
if to_date is None:
|
||||
to_date = from_date + timedelta(days=28)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
f.forecast_date,
|
||||
f.forecast_type,
|
||||
fm.metric_name,
|
||||
MAX(CASE WHEN f.model_type = 'prophet' THEN f.predicted_value END) as prophet,
|
||||
MAX(CASE WHEN f.model_type = 'xgboost' THEN f.predicted_value END) as xgboost,
|
||||
MAX(CASE WHEN f.model_type = 'pickup' THEN f.predicted_value END) as pickup,
|
||||
dm.actual_value
|
||||
FROM forecasts f
|
||||
LEFT JOIN forecast_metrics fm ON f.forecast_type = fm.metric_code
|
||||
LEFT JOIN daily_metrics dm ON f.forecast_date = dm.date AND f.forecast_type = dm.metric_code
|
||||
WHERE f.forecast_date BETWEEN :from_date AND :to_date
|
||||
GROUP BY f.forecast_date, f.forecast_type, fm.metric_name, dm.actual_value
|
||||
ORDER BY f.forecast_date, f.forecast_type
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
df = pd.DataFrame(result.fetchall())
|
||||
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, sheet_name='Model Comparison', index=False)
|
||||
|
||||
output.seek(0)
|
||||
|
||||
filename = f"model_comparison_{from_date}_{to_date}.xlsx"
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
3727
backend/api/forecast.py
Normal file
3727
backend/api/forecast.py
Normal file
File diff suppressed because it is too large
Load diff
221
backend/api/historical.py
Normal file
221
backend/api/historical.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"""
|
||||
Historical data API endpoints
|
||||
Provides access to aggregated actual data from daily_occupancy and daily_covers
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/occupancy")
|
||||
async def get_occupancy_data(
|
||||
from_date: Optional[date] = Query(None, description="Start date"),
|
||||
to_date: Optional[date] = Query(None, description="End date"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get historical occupancy data from daily_occupancy table.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=30)
|
||||
if to_date is None:
|
||||
to_date = date.today()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
date,
|
||||
total_rooms,
|
||||
occupied_rooms,
|
||||
occupancy_pct,
|
||||
total_guests,
|
||||
total_adults,
|
||||
total_children,
|
||||
total_infants,
|
||||
arrival_count,
|
||||
room_revenue,
|
||||
adr,
|
||||
revpar,
|
||||
agr,
|
||||
breakfast_allocation_qty,
|
||||
dinner_allocation_qty,
|
||||
by_room_type,
|
||||
revenue_by_room_type
|
||||
FROM daily_occupancy
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
ORDER BY date
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.date,
|
||||
"total_rooms": row.total_rooms,
|
||||
"occupied_rooms": row.occupied_rooms,
|
||||
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct else 0,
|
||||
"total_guests": row.total_guests,
|
||||
"total_adults": row.total_adults,
|
||||
"total_children": row.total_children,
|
||||
"total_infants": row.total_infants,
|
||||
"arrival_count": row.arrival_count,
|
||||
"room_revenue": float(row.room_revenue) if row.room_revenue else 0,
|
||||
"adr": float(row.adr) if row.adr else 0,
|
||||
"revpar": float(row.revpar) if row.revpar else 0,
|
||||
"agr": float(row.agr) if row.agr else 0,
|
||||
"breakfast_allocation_qty": row.breakfast_allocation_qty,
|
||||
"dinner_allocation_qty": row.dinner_allocation_qty,
|
||||
"by_room_type": row.by_room_type,
|
||||
"revenue_by_room_type": row.revenue_by_room_type
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/covers")
|
||||
async def get_covers_data(
|
||||
from_date: Optional[date] = Query(None, description="Start date"),
|
||||
to_date: Optional[date] = Query(None, description="End date"),
|
||||
service_period: Optional[str] = Query(None, description="Filter by: lunch, dinner"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get historical covers data from daily_covers table.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=30)
|
||||
if to_date is None:
|
||||
to_date = date.today()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
date,
|
||||
service_period,
|
||||
total_bookings,
|
||||
total_covers,
|
||||
avg_party_size,
|
||||
hotel_guest_covers,
|
||||
external_covers,
|
||||
dbb_covers,
|
||||
package_covers,
|
||||
cancelled_bookings,
|
||||
cancelled_covers,
|
||||
no_show_bookings,
|
||||
no_show_covers,
|
||||
by_source
|
||||
FROM daily_covers
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
"""
|
||||
|
||||
params = {"from_date": from_date, "to_date": to_date}
|
||||
|
||||
if service_period:
|
||||
query += " AND service_period = :service_period"
|
||||
params["service_period"] = service_period
|
||||
|
||||
query += " ORDER BY date, service_period"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.date,
|
||||
"service_period": row.service_period,
|
||||
"total_bookings": row.total_bookings,
|
||||
"total_covers": row.total_covers,
|
||||
"avg_party_size": float(row.avg_party_size) if row.avg_party_size else 0,
|
||||
"hotel_guest_covers": row.hotel_guest_covers,
|
||||
"external_covers": row.external_covers,
|
||||
"dbb_covers": row.dbb_covers,
|
||||
"package_covers": row.package_covers,
|
||||
"cancelled_bookings": row.cancelled_bookings,
|
||||
"cancelled_covers": row.cancelled_covers,
|
||||
"no_show_bookings": row.no_show_bookings,
|
||||
"no_show_covers": row.no_show_covers,
|
||||
"by_source": row.by_source
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def get_daily_summary(
|
||||
from_date: Optional[date] = Query(None, description="Start date"),
|
||||
to_date: Optional[date] = Query(None, description="End date"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get combined daily summary with occupancy and covers data.
|
||||
"""
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=30)
|
||||
if to_date is None:
|
||||
to_date = date.today()
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
o.date,
|
||||
EXTRACT(DOW FROM o.date) as day_of_week,
|
||||
o.total_rooms,
|
||||
o.available_rooms,
|
||||
o.occupied_rooms,
|
||||
o.occupancy_pct,
|
||||
o.total_guests,
|
||||
o.room_revenue,
|
||||
o.adr,
|
||||
o.revpar,
|
||||
o.agr,
|
||||
o.arrival_count,
|
||||
o.breakfast_allocation_qty,
|
||||
o.dinner_allocation_qty,
|
||||
COALESCE(cl.total_covers, 0) as lunch_covers,
|
||||
COALESCE(cd.total_covers, 0) as dinner_covers,
|
||||
COALESCE(cl.total_bookings, 0) as lunch_bookings,
|
||||
COALESCE(cd.total_bookings, 0) as dinner_bookings
|
||||
FROM daily_occupancy o
|
||||
LEFT JOIN daily_covers cl ON o.date = cl.date AND cl.service_period = 'lunch'
|
||||
LEFT JOIN daily_covers cd ON o.date = cd.date AND cd.service_period = 'dinner'
|
||||
WHERE o.date BETWEEN :from_date AND :to_date
|
||||
ORDER BY o.date
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
day_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
return [
|
||||
{
|
||||
"date": row.date,
|
||||
"day_of_week": day_names[int(row.day_of_week)],
|
||||
"total_rooms": row.total_rooms,
|
||||
"available_rooms": row.available_rooms,
|
||||
"occupied_rooms": row.occupied_rooms,
|
||||
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct else 0,
|
||||
"total_guests": row.total_guests,
|
||||
"room_revenue": float(row.room_revenue) if row.room_revenue else 0,
|
||||
"adr": float(row.adr) if row.adr else 0,
|
||||
"revpar": float(row.revpar) if row.revpar else 0,
|
||||
"agr": float(row.agr) if row.agr else 0,
|
||||
"arrival_count": row.arrival_count,
|
||||
"breakfast_allocation_qty": row.breakfast_allocation_qty,
|
||||
"dinner_allocation_qty": row.dinner_allocation_qty,
|
||||
"lunch_covers": row.lunch_covers,
|
||||
"dinner_covers": row.dinner_covers,
|
||||
"lunch_bookings": row.lunch_bookings,
|
||||
"dinner_bookings": row.dinner_bookings
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
537
backend/api/public.py
Normal file
537
backend/api/public.py
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
"""
|
||||
Public API endpoints - accessible with API key authentication
|
||||
For external integrations like Kitchen Flash app
|
||||
"""
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
import logging
|
||||
|
||||
from database import get_db
|
||||
from auth import get_api_key_auth
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_prior_year_date(target_date: date) -> date:
|
||||
"""
|
||||
Get prior year date with 364-day offset for day-of-week alignment.
|
||||
52 weeks = 364 days, so Monday aligns with Monday.
|
||||
"""
|
||||
return target_date - timedelta(days=364)
|
||||
|
||||
|
||||
@router.get("/forecast/rooms")
|
||||
async def get_rooms_forecast(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
api_key: dict = Depends(get_api_key_auth)
|
||||
):
|
||||
"""
|
||||
Get forecasted room bookings for external applications.
|
||||
|
||||
Returns: OTB rooms, forecast rooms, occupancy %, prior year data
|
||||
"""
|
||||
from services.forecasting.pickup_v2_model import forecast_rooms_for_date
|
||||
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
end = start + timedelta(days=days - 1)
|
||||
today = date.today()
|
||||
|
||||
# Get total rooms for occupancy calculation
|
||||
result = await db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'total_rooms'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
total_rooms = int(row.config_value) if row and row.config_value else 30
|
||||
|
||||
data = []
|
||||
current = start
|
||||
while current <= end:
|
||||
lead_days = (current - today).days if current >= today else 0
|
||||
prior_date = get_prior_year_date(current)
|
||||
|
||||
try:
|
||||
if current >= today:
|
||||
# Future date - get forecast
|
||||
forecast = await forecast_rooms_for_date(
|
||||
db, current, lead_days, prior_date, 'hotel_room_nights'
|
||||
)
|
||||
otb_rooms = forecast.get('current_otb', 0) or 0
|
||||
pickup_rooms = forecast.get('expected_pickup', 0) or 0
|
||||
forecast_rooms = otb_rooms + pickup_rooms
|
||||
prior_otb = forecast.get('prior_year_otb', 0) or 0
|
||||
prior_final = forecast.get('prior_year_final', 0) or 0
|
||||
|
||||
# Get guest counts from stats
|
||||
stats_result = await db.execute(
|
||||
text("""
|
||||
SELECT booking_count, guests_count FROM newbook_bookings_stats
|
||||
WHERE date = :target_date
|
||||
"""),
|
||||
{"target_date": current}
|
||||
)
|
||||
stats_row = stats_result.fetchone()
|
||||
otb_guests = stats_row.guests_count if stats_row and stats_row.guests_count else 0
|
||||
|
||||
# Prior year guests for ratio
|
||||
prior_stats = await db.execute(
|
||||
text("""
|
||||
SELECT booking_count, guests_count FROM newbook_bookings_stats
|
||||
WHERE date = :prior_date
|
||||
"""),
|
||||
{"prior_date": prior_date}
|
||||
)
|
||||
prior_stats_row = prior_stats.fetchone()
|
||||
prior_guests = prior_stats_row.guests_count if prior_stats_row and prior_stats_row.guests_count else 0
|
||||
prior_rooms_actual = prior_stats_row.booking_count if prior_stats_row and prior_stats_row.booking_count else 0
|
||||
|
||||
# Calculate guests per room ratio (prior year -> current OTB -> default)
|
||||
if prior_rooms_actual > 0:
|
||||
guests_per_room = prior_guests / prior_rooms_actual
|
||||
elif otb_rooms > 0:
|
||||
guests_per_room = otb_guests / otb_rooms
|
||||
else:
|
||||
guests_per_room = 1.8
|
||||
|
||||
pickup_guests = round(pickup_rooms * guests_per_room)
|
||||
forecast_guests = otb_guests + pickup_guests
|
||||
else:
|
||||
# Past date - get actuals from stats
|
||||
stats_result = await db.execute(
|
||||
text("""
|
||||
SELECT booking_count, guests_count FROM newbook_bookings_stats
|
||||
WHERE date = :target_date
|
||||
"""),
|
||||
{"target_date": current}
|
||||
)
|
||||
stats_row = stats_result.fetchone()
|
||||
otb_rooms = stats_row.booking_count if stats_row else 0
|
||||
otb_guests = stats_row.guests_count if stats_row and stats_row.guests_count else 0
|
||||
forecast_rooms = otb_rooms
|
||||
forecast_guests = otb_guests
|
||||
pickup_rooms = 0
|
||||
pickup_guests = 0
|
||||
|
||||
# Prior year stats
|
||||
prior_result = await db.execute(
|
||||
text("""
|
||||
SELECT booking_count FROM newbook_bookings_stats
|
||||
WHERE date = :prior_date
|
||||
"""),
|
||||
{"prior_date": prior_date}
|
||||
)
|
||||
prior_row = prior_result.fetchone()
|
||||
prior_final = prior_row.booking_count if prior_row else 0
|
||||
prior_otb = prior_final
|
||||
|
||||
occupancy_pct = round((forecast_rooms / total_rooms) * 100, 1) if total_rooms > 0 else 0
|
||||
|
||||
data.append({
|
||||
"date": current.isoformat(),
|
||||
"day": current.strftime("%A"),
|
||||
"lead_days": lead_days,
|
||||
"otb_rooms": otb_rooms,
|
||||
"pickup_rooms": pickup_rooms,
|
||||
"forecast_rooms": forecast_rooms,
|
||||
"otb_guests": otb_guests,
|
||||
"pickup_guests": pickup_guests,
|
||||
"forecast_guests": forecast_guests,
|
||||
"occupancy_pct": occupancy_pct,
|
||||
"prior_year_otb": prior_otb,
|
||||
"prior_year_final": prior_final,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Error forecasting rooms for {current}: {e}")
|
||||
data.append({
|
||||
"date": current.isoformat(),
|
||||
"day": current.strftime("%A"),
|
||||
"lead_days": lead_days,
|
||||
"otb_rooms": 0,
|
||||
"pickup_rooms": 0,
|
||||
"forecast_rooms": 0,
|
||||
"otb_guests": 0,
|
||||
"pickup_guests": 0,
|
||||
"forecast_guests": 0,
|
||||
"occupancy_pct": 0,
|
||||
"prior_year_otb": 0,
|
||||
"prior_year_final": 0,
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
current += timedelta(days=1)
|
||||
|
||||
return {"data": data, "total_rooms": total_rooms}
|
||||
|
||||
|
||||
@router.get("/forecast/covers")
|
||||
async def get_covers_forecast(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
api_key: dict = Depends(get_api_key_auth)
|
||||
):
|
||||
"""
|
||||
Get forecasted restaurant covers by period for external applications.
|
||||
|
||||
Returns: OTB covers, forecast covers, prior year data for breakfast, lunch, dinner
|
||||
"""
|
||||
from services.forecasting.covers_model import forecast_covers_range
|
||||
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
end = start + timedelta(days=days - 1)
|
||||
today = date.today()
|
||||
|
||||
try:
|
||||
# Get covers forecast
|
||||
covers_data = await forecast_covers_range(db, start, end, include_details=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Covers forecast failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Forecast error: {str(e)}")
|
||||
|
||||
# Get prior year covers from stats
|
||||
prior_start = get_prior_year_date(start)
|
||||
prior_end = get_prior_year_date(end)
|
||||
|
||||
prior_result = await db.execute(
|
||||
text("""
|
||||
SELECT date, breakfast_covers, lunch_covers, dinner_covers
|
||||
FROM resos_bookings_stats
|
||||
WHERE date >= :start_date AND date <= :end_date
|
||||
"""),
|
||||
{"start_date": prior_start, "end_date": prior_end}
|
||||
)
|
||||
prior_rows = prior_result.fetchall()
|
||||
prior_by_date = {row.date: row for row in prior_rows}
|
||||
|
||||
data = []
|
||||
for day_data in covers_data.get("data", []):
|
||||
target_date = datetime.strptime(day_data["date"], "%Y-%m-%d").date()
|
||||
prior_date = get_prior_year_date(target_date)
|
||||
lead_days = (target_date - today).days if target_date >= today else 0
|
||||
|
||||
prior_row = prior_by_date.get(prior_date)
|
||||
prior_breakfast = prior_row.breakfast_covers if prior_row else 0
|
||||
prior_lunch = prior_row.lunch_covers if prior_row else 0
|
||||
prior_dinner = prior_row.dinner_covers if prior_row else 0
|
||||
|
||||
# For prior OTB, we don't have pace data, so use same as final for simplicity
|
||||
data.append({
|
||||
"date": day_data["date"],
|
||||
"day": day_data["day_of_week"],
|
||||
"lead_days": lead_days,
|
||||
"breakfast": {
|
||||
"otb": day_data["breakfast"]["otb"],
|
||||
"forecast": day_data["breakfast"]["forecast"],
|
||||
"prior_otb": prior_breakfast, # Same as final for prior year
|
||||
"prior_final": prior_breakfast
|
||||
},
|
||||
"lunch": {
|
||||
"otb": day_data["lunch"]["otb"],
|
||||
"forecast": day_data["lunch"]["forecast"],
|
||||
"prior_otb": prior_lunch,
|
||||
"prior_final": prior_lunch
|
||||
},
|
||||
"dinner": {
|
||||
"otb": day_data["dinner"]["otb"],
|
||||
"forecast": day_data["dinner"]["forecast"],
|
||||
"prior_otb": prior_dinner,
|
||||
"prior_final": prior_dinner
|
||||
},
|
||||
"total": {
|
||||
"otb": day_data["totals"]["otb"],
|
||||
"forecast": day_data["totals"]["forecast"],
|
||||
"prior_final": prior_breakfast + prior_lunch + prior_dinner
|
||||
}
|
||||
})
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.get("/forecast/revenue")
|
||||
async def get_revenue_forecast(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
days: int = Query(7, ge=1, le=365, description="Number of days to forecast"),
|
||||
type: str = Query("all", description="Revenue type: all, accom, dry, wet"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
api_key: dict = Depends(get_api_key_auth)
|
||||
):
|
||||
"""
|
||||
Get forecasted revenue by type for external applications.
|
||||
|
||||
Returns: OTB revenue, forecast revenue, prior year actuals, budget
|
||||
"""
|
||||
from services.forecasting.covers_model import forecast_covers_range
|
||||
from services.forecasting.pickup_v2_model import forecast_revenue_for_date
|
||||
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
end = start + timedelta(days=days - 1)
|
||||
today = date.today()
|
||||
VAT_RATE = 1.20
|
||||
|
||||
# Get spend settings for restaurant revenue
|
||||
spend_result = await db.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value
|
||||
FROM system_config
|
||||
WHERE config_key LIKE 'resos_%_spend'
|
||||
""")
|
||||
)
|
||||
spend_rows = spend_result.fetchall()
|
||||
spend_settings = {row.config_key: float(row.config_value or 0) for row in spend_rows}
|
||||
|
||||
def get_spend_by_period(period: str, revenue_type: str) -> float:
|
||||
if revenue_type == 'dry':
|
||||
return spend_settings.get(f'resos_{period}_food_spend', 0) / VAT_RATE
|
||||
elif revenue_type == 'wet':
|
||||
return spend_settings.get(f'resos_{period}_drinks_spend', 0) / VAT_RATE
|
||||
else:
|
||||
food = spend_settings.get(f'resos_{period}_food_spend', 0)
|
||||
drinks = spend_settings.get(f'resos_{period}_drinks_spend', 0)
|
||||
return (food + drinks) / VAT_RATE
|
||||
|
||||
# Get actual revenue
|
||||
actual_result = await db.execute(
|
||||
text("""
|
||||
SELECT date, accommodation, dry, wet
|
||||
FROM newbook_net_revenue_data
|
||||
WHERE date >= :start_date AND date <= :end_date
|
||||
"""),
|
||||
{"start_date": start, "end_date": end}
|
||||
)
|
||||
actual_by_date = {row.date: row for row in actual_result.fetchall()}
|
||||
|
||||
# Get prior year revenue
|
||||
prior_start = get_prior_year_date(start)
|
||||
prior_end = get_prior_year_date(end)
|
||||
prior_result = await db.execute(
|
||||
text("""
|
||||
SELECT date, accommodation, dry, wet
|
||||
FROM newbook_net_revenue_data
|
||||
WHERE date >= :start_date AND date <= :end_date
|
||||
"""),
|
||||
{"start_date": prior_start, "end_date": prior_end}
|
||||
)
|
||||
prior_by_date = {row.date: row for row in prior_result.fetchall()}
|
||||
|
||||
# Get budgets
|
||||
budget_types = []
|
||||
if type in ['all', 'accom']:
|
||||
budget_types.append('net_accom')
|
||||
if type in ['all', 'dry']:
|
||||
budget_types.append('net_dry')
|
||||
if type in ['all', 'wet']:
|
||||
budget_types.append('net_wet')
|
||||
if type == 'all':
|
||||
budget_types.append('total_rev')
|
||||
|
||||
budget_result = await db.execute(
|
||||
text("""
|
||||
SELECT date, budget_type, budget_value
|
||||
FROM daily_budgets
|
||||
WHERE date >= :start_date AND date <= :end_date
|
||||
AND budget_type = ANY(:types)
|
||||
"""),
|
||||
{"start_date": start, "end_date": end, "types": budget_types if budget_types else ['']}
|
||||
)
|
||||
budget_data = {}
|
||||
for row in budget_result.fetchall():
|
||||
if row.date not in budget_data:
|
||||
budget_data[row.date] = {}
|
||||
budget_data[row.date][row.budget_type] = float(row.budget_value or 0)
|
||||
|
||||
# Get covers forecast for restaurant revenue
|
||||
try:
|
||||
covers_data = await forecast_covers_range(db, start, end, include_details=False)
|
||||
covers_by_date = {c["date"]: c for c in covers_data.get("data", [])}
|
||||
except Exception as e:
|
||||
logger.warning(f"Covers forecast failed: {e}")
|
||||
covers_by_date = {}
|
||||
|
||||
data = []
|
||||
current = start
|
||||
while current <= end:
|
||||
is_past = current < today
|
||||
lead_days = (current - today).days if current >= today else 0
|
||||
prior_date = get_prior_year_date(current)
|
||||
|
||||
# Prior year actual
|
||||
prior_row = prior_by_date.get(prior_date)
|
||||
prior_accom = float(prior_row.accommodation) if prior_row and prior_row.accommodation else 0
|
||||
prior_dry = float(prior_row.dry) if prior_row and prior_row.dry else 0
|
||||
prior_wet = float(prior_row.wet) if prior_row and prior_row.wet else 0
|
||||
|
||||
# Budget
|
||||
day_budget = budget_data.get(current, {})
|
||||
budget_accom = day_budget.get('net_accom', 0)
|
||||
budget_dry = day_budget.get('net_dry', 0)
|
||||
budget_wet = day_budget.get('net_wet', 0)
|
||||
budget_total = day_budget.get('total_rev', budget_accom + budget_dry + budget_wet)
|
||||
|
||||
if is_past:
|
||||
# Past: use actual revenue
|
||||
actual_row = actual_by_date.get(current)
|
||||
accom_otb = float(actual_row.accommodation) if actual_row and actual_row.accommodation else 0
|
||||
dry_otb = float(actual_row.dry) if actual_row and actual_row.dry else 0
|
||||
wet_otb = float(actual_row.wet) if actual_row and actual_row.wet else 0
|
||||
accom_forecast = accom_otb
|
||||
dry_forecast = dry_otb
|
||||
wet_forecast = wet_otb
|
||||
accom_prior_otb = prior_accom
|
||||
else:
|
||||
# Future: forecast
|
||||
# Accommodation - use revenue forecast model
|
||||
try:
|
||||
accom_forecast_data = await forecast_revenue_for_date(
|
||||
db, current, lead_days, prior_date
|
||||
)
|
||||
accom_otb = accom_forecast_data.get('current_otb_rev', 0) or 0
|
||||
accom_pickup = accom_forecast_data.get('forecast_pickup_rev', 0) or 0
|
||||
accom_forecast = accom_otb + accom_pickup
|
||||
accom_prior_otb = accom_forecast_data.get('prior_year_otb_rev', 0) or 0
|
||||
except Exception:
|
||||
accom_otb = accom_forecast = accom_prior_otb = 0
|
||||
|
||||
# Restaurant
|
||||
day_covers = covers_by_date.get(current.isoformat())
|
||||
if day_covers:
|
||||
breakfast_otb = day_covers["breakfast"]["otb"]
|
||||
lunch_otb = day_covers["lunch"]["otb"]
|
||||
dinner_otb = day_covers["dinner"]["otb"]
|
||||
breakfast_forecast = day_covers["breakfast"]["forecast"]
|
||||
lunch_forecast = day_covers["lunch"]["forecast"]
|
||||
dinner_forecast = day_covers["dinner"]["forecast"]
|
||||
|
||||
dry_otb = (
|
||||
breakfast_otb * get_spend_by_period('breakfast', 'dry') +
|
||||
lunch_otb * get_spend_by_period('lunch', 'dry') +
|
||||
dinner_otb * get_spend_by_period('dinner', 'dry')
|
||||
)
|
||||
dry_forecast = (
|
||||
breakfast_forecast * get_spend_by_period('breakfast', 'dry') +
|
||||
lunch_forecast * get_spend_by_period('lunch', 'dry') +
|
||||
dinner_forecast * get_spend_by_period('dinner', 'dry')
|
||||
)
|
||||
wet_otb = (
|
||||
breakfast_otb * get_spend_by_period('breakfast', 'wet') +
|
||||
lunch_otb * get_spend_by_period('lunch', 'wet') +
|
||||
dinner_otb * get_spend_by_period('dinner', 'wet')
|
||||
)
|
||||
wet_forecast = (
|
||||
breakfast_forecast * get_spend_by_period('breakfast', 'wet') +
|
||||
lunch_forecast * get_spend_by_period('lunch', 'wet') +
|
||||
dinner_forecast * get_spend_by_period('dinner', 'wet')
|
||||
)
|
||||
else:
|
||||
dry_otb = dry_forecast = wet_otb = wet_forecast = 0
|
||||
|
||||
day_data = {
|
||||
"date": current.isoformat(),
|
||||
"day": current.strftime("%A"),
|
||||
"is_past": is_past,
|
||||
"lead_days": lead_days,
|
||||
}
|
||||
|
||||
if type in ['all', 'accom']:
|
||||
day_data["accom"] = {
|
||||
"otb": round(accom_otb, 2),
|
||||
"forecast": round(accom_forecast, 2),
|
||||
"prior_otb": round(accom_prior_otb, 2) if not is_past else round(prior_accom, 2),
|
||||
"prior_final": round(prior_accom, 2),
|
||||
"budget": round(budget_accom, 2)
|
||||
}
|
||||
|
||||
if type in ['all', 'dry']:
|
||||
day_data["dry"] = {
|
||||
"otb": round(dry_otb, 2),
|
||||
"forecast": round(dry_forecast, 2),
|
||||
"prior_final": round(prior_dry, 2),
|
||||
"budget": round(budget_dry, 2)
|
||||
}
|
||||
|
||||
if type in ['all', 'wet']:
|
||||
day_data["wet"] = {
|
||||
"otb": round(wet_otb, 2),
|
||||
"forecast": round(wet_forecast, 2),
|
||||
"prior_final": round(prior_wet, 2),
|
||||
"budget": round(budget_wet, 2)
|
||||
}
|
||||
|
||||
if type == 'all':
|
||||
total_otb = accom_otb + dry_otb + wet_otb
|
||||
total_forecast = accom_forecast + dry_forecast + wet_forecast
|
||||
total_prior = prior_accom + prior_dry + prior_wet
|
||||
day_data["total"] = {
|
||||
"otb": round(total_otb, 2),
|
||||
"forecast": round(total_forecast, 2),
|
||||
"prior_final": round(total_prior, 2),
|
||||
"budget": round(budget_total, 2)
|
||||
}
|
||||
|
||||
data.append(day_data)
|
||||
current += timedelta(days=1)
|
||||
|
||||
return {"data": data}
|
||||
|
||||
|
||||
@router.get("/forecast/spend-rates")
|
||||
async def get_spend_rates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
api_key: dict = Depends(get_api_key_auth)
|
||||
):
|
||||
"""
|
||||
Return spend-per-cover rates for each meal period.
|
||||
Values are gross (inc VAT) from system_config, plus the VAT rate.
|
||||
Kitchen app can divide by VAT rate to get net values.
|
||||
"""
|
||||
VAT_RATE = 1.20
|
||||
|
||||
spend_result = await db.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value
|
||||
FROM system_config
|
||||
WHERE config_key LIKE 'resos_%_spend'
|
||||
""")
|
||||
)
|
||||
spend_rows = spend_result.fetchall()
|
||||
spend_settings = {row.config_key: float(row.config_value or 0) for row in spend_rows}
|
||||
|
||||
return {
|
||||
"vat_rate": VAT_RATE,
|
||||
"periods": {
|
||||
"breakfast": {
|
||||
"food_spend_gross": spend_settings.get("resos_breakfast_food_spend", 0),
|
||||
"drinks_spend_gross": spend_settings.get("resos_breakfast_drinks_spend", 0),
|
||||
"food_spend_net": round(spend_settings.get("resos_breakfast_food_spend", 0) / VAT_RATE, 2),
|
||||
"drinks_spend_net": round(spend_settings.get("resos_breakfast_drinks_spend", 0) / VAT_RATE, 2),
|
||||
},
|
||||
"lunch": {
|
||||
"food_spend_gross": spend_settings.get("resos_lunch_food_spend", 0),
|
||||
"drinks_spend_gross": spend_settings.get("resos_lunch_drinks_spend", 0),
|
||||
"food_spend_net": round(spend_settings.get("resos_lunch_food_spend", 0) / VAT_RATE, 2),
|
||||
"drinks_spend_net": round(spend_settings.get("resos_lunch_drinks_spend", 0) / VAT_RATE, 2),
|
||||
},
|
||||
"dinner": {
|
||||
"food_spend_gross": spend_settings.get("resos_dinner_food_spend", 0),
|
||||
"drinks_spend_gross": spend_settings.get("resos_dinner_drinks_spend", 0),
|
||||
"food_spend_net": round(spend_settings.get("resos_dinner_food_spend", 0) / VAT_RATE, 2),
|
||||
"drinks_spend_net": round(spend_settings.get("resos_dinner_drinks_spend", 0) / VAT_RATE, 2),
|
||||
},
|
||||
}
|
||||
}
|
||||
1281
backend/api/reconciliation.py
Normal file
1281
backend/api/reconciliation.py
Normal file
File diff suppressed because it is too large
Load diff
927
backend/api/reports.py
Normal file
927
backend/api/reports.py
Normal file
|
|
@ -0,0 +1,927 @@
|
|||
"""
|
||||
Reports API endpoints
|
||||
Provide aggregated data for frontend reports and visualizations
|
||||
"""
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
import calendar
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================
|
||||
# RESPONSE MODELS
|
||||
# ============================================
|
||||
|
||||
class OccupancyDataPoint(BaseModel):
|
||||
date: str
|
||||
total_occupancy_pct: Optional[float] = None
|
||||
bookable_occupancy_pct: Optional[float] = None
|
||||
booking_count: int = 0
|
||||
rooms_count: int = 0
|
||||
bookable_count: int = 0
|
||||
|
||||
|
||||
class BookingsDataPoint(BaseModel):
|
||||
date: str
|
||||
booking_count: int = 0
|
||||
guests_count: int = 0
|
||||
rooms_count: int = 0
|
||||
|
||||
|
||||
class RatesDataPoint(BaseModel):
|
||||
date: str
|
||||
guest_rate_total: float = 0.0
|
||||
net_booking_rev_total: float = 0.0
|
||||
booking_count: int = 0
|
||||
avg_guest_rate: Optional[float] = None
|
||||
avg_net_rate: Optional[float] = None
|
||||
|
||||
|
||||
class RevenueDataPoint(BaseModel):
|
||||
date: str
|
||||
accommodation: float = 0.0
|
||||
dry: float = 0.0
|
||||
wet: float = 0.0
|
||||
total: float = 0.0
|
||||
|
||||
|
||||
# ============================================
|
||||
# OCCUPANCY REPORT ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@router.get("/occupancy", response_model=List[OccupancyDataPoint])
|
||||
async def get_occupancy_report(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get occupancy data for the specified date range with optional consolidation.
|
||||
|
||||
- **day**: Returns daily data points
|
||||
- **week**: Aggregates by week (Monday start)
|
||||
- **month**: Aggregates by month
|
||||
"""
|
||||
# Validate dates
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||||
|
||||
if consolidation not in ["day", "week", "month"]:
|
||||
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
|
||||
|
||||
# Build the query based on consolidation type
|
||||
# Use generate_series to include all dates in range, even those without data
|
||||
if consolidation == "day":
|
||||
query = text("""
|
||||
SELECT
|
||||
to_char(d, 'YYYY-MM-DD') as period_date,
|
||||
COALESCE(s.total_occupancy_pct, 0) as total_occupancy_pct,
|
||||
COALESCE(s.bookable_occupancy_pct, 0) as bookable_occupancy_pct,
|
||||
COALESCE(s.booking_count, 0) as booking_count,
|
||||
COALESCE(s.rooms_count, 0) as rooms_count,
|
||||
COALESCE(s.bookable_count, 0) as bookable_count
|
||||
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = d
|
||||
ORDER BY d
|
||||
""")
|
||||
elif consolidation == "week":
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
weekly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('week', d) AS date) as week_start,
|
||||
ROUND(CAST(AVG(COALESCE(s.total_occupancy_pct, 0)) AS numeric), 2) as total_occupancy_pct,
|
||||
ROUND(CAST(AVG(COALESCE(s.bookable_occupancy_pct, 0)) AS numeric), 2) as bookable_occupancy_pct,
|
||||
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
|
||||
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count,
|
||||
CAST(ROUND(AVG(COALESCE(s.bookable_count, 0))) AS integer) as bookable_count
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('week', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(week_start, 'YYYY-MM-DD') as period_date,
|
||||
total_occupancy_pct,
|
||||
bookable_occupancy_pct,
|
||||
booking_count,
|
||||
rooms_count,
|
||||
bookable_count
|
||||
FROM weekly_data
|
||||
ORDER BY week_start
|
||||
""")
|
||||
else: # month
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
monthly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('month', d) AS date) as month_start,
|
||||
ROUND(CAST(AVG(COALESCE(s.total_occupancy_pct, 0)) AS numeric), 2) as total_occupancy_pct,
|
||||
ROUND(CAST(AVG(COALESCE(s.bookable_occupancy_pct, 0)) AS numeric), 2) as bookable_occupancy_pct,
|
||||
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
|
||||
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count,
|
||||
CAST(ROUND(AVG(COALESCE(s.bookable_count, 0))) AS integer) as bookable_count
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('month', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(month_start, 'YYYY-MM-DD') as period_date,
|
||||
total_occupancy_pct,
|
||||
bookable_occupancy_pct,
|
||||
booking_count,
|
||||
rooms_count,
|
||||
bookable_count
|
||||
FROM monthly_data
|
||||
ORDER BY month_start
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"start_date": start, "end_date": end})
|
||||
rows = result.fetchall()
|
||||
|
||||
data_points = []
|
||||
for row in rows:
|
||||
data_points.append(OccupancyDataPoint(
|
||||
date=row.period_date,
|
||||
total_occupancy_pct=float(row.total_occupancy_pct) if row.total_occupancy_pct else None,
|
||||
bookable_occupancy_pct=float(row.bookable_occupancy_pct) if row.bookable_occupancy_pct else None,
|
||||
booking_count=row.booking_count or 0,
|
||||
rooms_count=row.rooms_count or 0,
|
||||
bookable_count=row.bookable_count or 0,
|
||||
))
|
||||
|
||||
return data_points
|
||||
|
||||
|
||||
# ============================================
|
||||
# BOOKINGS REPORT ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@router.get("/bookings", response_model=List[BookingsDataPoint])
|
||||
async def get_bookings_report(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get bookings and guests data for the specified date range with optional consolidation.
|
||||
"""
|
||||
# Validate dates
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||||
|
||||
if consolidation not in ["day", "week", "month"]:
|
||||
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
|
||||
|
||||
if consolidation == "day":
|
||||
query = text("""
|
||||
SELECT
|
||||
to_char(d, 'YYYY-MM-DD') as period_date,
|
||||
COALESCE(s.booking_count, 0) as booking_count,
|
||||
COALESCE(s.guests_count, 0) as guests_count,
|
||||
COALESCE(s.rooms_count, 0) as rooms_count
|
||||
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = d
|
||||
ORDER BY d
|
||||
""")
|
||||
elif consolidation == "week":
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
weekly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('week', d) AS date) as week_start,
|
||||
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
|
||||
CAST(SUM(COALESCE(s.guests_count, 0)) AS integer) as guests_count,
|
||||
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('week', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(week_start, 'YYYY-MM-DD') as period_date,
|
||||
booking_count,
|
||||
guests_count,
|
||||
rooms_count
|
||||
FROM weekly_data
|
||||
ORDER BY week_start
|
||||
""")
|
||||
else: # month
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
monthly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('month', d) AS date) as month_start,
|
||||
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count,
|
||||
CAST(SUM(COALESCE(s.guests_count, 0)) AS integer) as guests_count,
|
||||
CAST(ROUND(AVG(COALESCE(s.rooms_count, 0))) AS integer) as rooms_count
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('month', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(month_start, 'YYYY-MM-DD') as period_date,
|
||||
booking_count,
|
||||
guests_count,
|
||||
rooms_count
|
||||
FROM monthly_data
|
||||
ORDER BY month_start
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"start_date": start, "end_date": end})
|
||||
rows = result.fetchall()
|
||||
|
||||
data_points = []
|
||||
for row in rows:
|
||||
data_points.append(BookingsDataPoint(
|
||||
date=row.period_date,
|
||||
booking_count=row.booking_count or 0,
|
||||
guests_count=row.guests_count or 0,
|
||||
rooms_count=row.rooms_count or 0,
|
||||
))
|
||||
|
||||
return data_points
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATES REPORT ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@router.get("/rates", response_model=List[RatesDataPoint])
|
||||
async def get_rates_report(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get guest rates data (gross tariff / calculated amount) for the specified date range.
|
||||
"""
|
||||
# Validate dates
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||||
|
||||
if consolidation not in ["day", "week", "month"]:
|
||||
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
|
||||
|
||||
if consolidation == "day":
|
||||
query = text("""
|
||||
SELECT
|
||||
to_char(d, 'YYYY-MM-DD') as period_date,
|
||||
COALESCE(s.guest_rate_total, 0) as guest_rate_total,
|
||||
COALESCE(s.net_booking_rev_total, 0) as net_booking_rev_total,
|
||||
COALESCE(s.booking_count, 0) as booking_count
|
||||
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = d
|
||||
ORDER BY d
|
||||
""")
|
||||
elif consolidation == "week":
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
weekly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('week', d) AS date) as week_start,
|
||||
ROUND(CAST(SUM(COALESCE(s.guest_rate_total, 0)) AS numeric), 2) as guest_rate_total,
|
||||
ROUND(CAST(SUM(COALESCE(s.net_booking_rev_total, 0)) AS numeric), 2) as net_booking_rev_total,
|
||||
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('week', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(week_start, 'YYYY-MM-DD') as period_date,
|
||||
guest_rate_total,
|
||||
net_booking_rev_total,
|
||||
booking_count
|
||||
FROM weekly_data
|
||||
ORDER BY week_start
|
||||
""")
|
||||
else: # month
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
monthly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('month', d) AS date) as month_start,
|
||||
ROUND(CAST(SUM(COALESCE(s.guest_rate_total, 0)) AS numeric), 2) as guest_rate_total,
|
||||
ROUND(CAST(SUM(COALESCE(s.net_booking_rev_total, 0)) AS numeric), 2) as net_booking_rev_total,
|
||||
CAST(SUM(COALESCE(s.booking_count, 0)) AS integer) as booking_count
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('month', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(month_start, 'YYYY-MM-DD') as period_date,
|
||||
guest_rate_total,
|
||||
net_booking_rev_total,
|
||||
booking_count
|
||||
FROM monthly_data
|
||||
ORDER BY month_start
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"start_date": start, "end_date": end})
|
||||
rows = result.fetchall()
|
||||
|
||||
data_points = []
|
||||
for row in rows:
|
||||
guest_rate = float(row.guest_rate_total) if row.guest_rate_total else 0.0
|
||||
net_rate = float(row.net_booking_rev_total) if row.net_booking_rev_total else 0.0
|
||||
bookings = row.booking_count or 0
|
||||
|
||||
data_points.append(RatesDataPoint(
|
||||
date=row.period_date,
|
||||
guest_rate_total=guest_rate,
|
||||
net_booking_rev_total=net_rate,
|
||||
booking_count=bookings,
|
||||
avg_guest_rate=round(guest_rate / bookings, 2) if bookings > 0 else None,
|
||||
avg_net_rate=round(net_rate / bookings, 2) if bookings > 0 else None,
|
||||
))
|
||||
|
||||
return data_points
|
||||
|
||||
|
||||
# ============================================
|
||||
# REVENUE REPORT ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@router.get("/revenue", response_model=List[RevenueDataPoint])
|
||||
async def get_revenue_report(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get net revenue data (accommodation, dry, wet) for the specified date range.
|
||||
"""
|
||||
# Validate dates
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||||
|
||||
if consolidation not in ["day", "week", "month"]:
|
||||
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
|
||||
|
||||
if consolidation == "day":
|
||||
query = text("""
|
||||
SELECT
|
||||
to_char(d, 'YYYY-MM-DD') as period_date,
|
||||
COALESCE(r.accommodation, 0) as accommodation,
|
||||
COALESCE(r.dry, 0) as dry,
|
||||
COALESCE(r.wet, 0) as wet
|
||||
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
|
||||
LEFT JOIN newbook_net_revenue_data r ON r.date = d
|
||||
ORDER BY d
|
||||
""")
|
||||
elif consolidation == "week":
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
weekly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('week', d) AS date) as week_start,
|
||||
ROUND(CAST(SUM(COALESCE(r.accommodation, 0)) AS numeric), 2) as accommodation,
|
||||
ROUND(CAST(SUM(COALESCE(r.dry, 0)) AS numeric), 2) as dry,
|
||||
ROUND(CAST(SUM(COALESCE(r.wet, 0)) AS numeric), 2) as wet
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_net_revenue_data r ON r.date = dr.d
|
||||
GROUP BY date_trunc('week', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(week_start, 'YYYY-MM-DD') as period_date,
|
||||
accommodation,
|
||||
dry,
|
||||
wet
|
||||
FROM weekly_data
|
||||
ORDER BY week_start
|
||||
""")
|
||||
else: # month
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
monthly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('month', d) AS date) as month_start,
|
||||
ROUND(CAST(SUM(COALESCE(r.accommodation, 0)) AS numeric), 2) as accommodation,
|
||||
ROUND(CAST(SUM(COALESCE(r.dry, 0)) AS numeric), 2) as dry,
|
||||
ROUND(CAST(SUM(COALESCE(r.wet, 0)) AS numeric), 2) as wet
|
||||
FROM date_range dr
|
||||
LEFT JOIN newbook_net_revenue_data r ON r.date = dr.d
|
||||
GROUP BY date_trunc('month', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(month_start, 'YYYY-MM-DD') as period_date,
|
||||
accommodation,
|
||||
dry,
|
||||
wet
|
||||
FROM monthly_data
|
||||
ORDER BY month_start
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"start_date": start, "end_date": end})
|
||||
rows = result.fetchall()
|
||||
|
||||
data_points = []
|
||||
for row in rows:
|
||||
accom = float(row.accommodation) if row.accommodation else 0.0
|
||||
dry = float(row.dry) if row.dry else 0.0
|
||||
wet = float(row.wet) if row.wet else 0.0
|
||||
|
||||
data_points.append(RevenueDataPoint(
|
||||
date=row.period_date,
|
||||
accommodation=accom,
|
||||
dry=dry,
|
||||
wet=wet,
|
||||
total=round(accom + dry + wet, 2),
|
||||
))
|
||||
|
||||
return data_points
|
||||
|
||||
|
||||
# ============================================
|
||||
# RESTAURANT REPORTS
|
||||
# ============================================
|
||||
|
||||
class ResosBookingsDataPoint(BaseModel):
|
||||
date: str
|
||||
total_bookings: int = 0
|
||||
breakfast_bookings: int = 0
|
||||
lunch_bookings: int = 0
|
||||
afternoon_bookings: int = 0
|
||||
dinner_bookings: int = 0
|
||||
other_bookings: int = 0
|
||||
|
||||
|
||||
class ResosCoversDataPoint(BaseModel):
|
||||
date: str
|
||||
total_covers: int = 0
|
||||
breakfast_covers: int = 0
|
||||
lunch_covers: int = 0
|
||||
afternoon_covers: int = 0
|
||||
dinner_covers: int = 0
|
||||
other_covers: int = 0
|
||||
hotel_guest_covers: int = 0
|
||||
non_hotel_guest_covers: int = 0
|
||||
|
||||
|
||||
@router.get("/restaurant-bookings", response_model=List[ResosBookingsDataPoint])
|
||||
async def get_restaurant_bookings_report(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get restaurant bookings data for the specified date range with optional consolidation.
|
||||
"""
|
||||
# Validate dates
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||||
|
||||
if consolidation not in ["day", "week", "month"]:
|
||||
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
|
||||
|
||||
if consolidation == "day":
|
||||
query = text("""
|
||||
SELECT
|
||||
to_char(d, 'YYYY-MM-DD') as period_date,
|
||||
COALESCE(s.total_bookings, 0) as total_bookings,
|
||||
COALESCE(s.breakfast_bookings, 0) as breakfast_bookings,
|
||||
COALESCE(s.lunch_bookings, 0) as lunch_bookings,
|
||||
COALESCE(s.afternoon_bookings, 0) as afternoon_bookings,
|
||||
COALESCE(s.dinner_bookings, 0) as dinner_bookings,
|
||||
COALESCE(s.other_bookings, 0) as other_bookings
|
||||
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
|
||||
LEFT JOIN resos_bookings_stats s ON s.date = d
|
||||
ORDER BY d
|
||||
""")
|
||||
elif consolidation == "week":
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
weekly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('week', d) AS date) as week_start,
|
||||
CAST(SUM(COALESCE(s.total_bookings, 0)) AS integer) as total_bookings,
|
||||
CAST(SUM(COALESCE(s.breakfast_bookings, 0)) AS integer) as breakfast_bookings,
|
||||
CAST(SUM(COALESCE(s.lunch_bookings, 0)) AS integer) as lunch_bookings,
|
||||
CAST(SUM(COALESCE(s.afternoon_bookings, 0)) AS integer) as afternoon_bookings,
|
||||
CAST(SUM(COALESCE(s.dinner_bookings, 0)) AS integer) as dinner_bookings,
|
||||
CAST(SUM(COALESCE(s.other_bookings, 0)) AS integer) as other_bookings
|
||||
FROM date_range dr
|
||||
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('week', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(week_start, 'YYYY-MM-DD') as period_date,
|
||||
total_bookings, breakfast_bookings, lunch_bookings,
|
||||
afternoon_bookings, dinner_bookings, other_bookings
|
||||
FROM weekly_data
|
||||
ORDER BY week_start
|
||||
""")
|
||||
else: # month
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
monthly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('month', d) AS date) as month_start,
|
||||
CAST(SUM(COALESCE(s.total_bookings, 0)) AS integer) as total_bookings,
|
||||
CAST(SUM(COALESCE(s.breakfast_bookings, 0)) AS integer) as breakfast_bookings,
|
||||
CAST(SUM(COALESCE(s.lunch_bookings, 0)) AS integer) as lunch_bookings,
|
||||
CAST(SUM(COALESCE(s.afternoon_bookings, 0)) AS integer) as afternoon_bookings,
|
||||
CAST(SUM(COALESCE(s.dinner_bookings, 0)) AS integer) as dinner_bookings,
|
||||
CAST(SUM(COALESCE(s.other_bookings, 0)) AS integer) as other_bookings
|
||||
FROM date_range dr
|
||||
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('month', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(month_start, 'YYYY-MM-DD') as period_date,
|
||||
total_bookings, breakfast_bookings, lunch_bookings,
|
||||
afternoon_bookings, dinner_bookings, other_bookings
|
||||
FROM monthly_data
|
||||
ORDER BY month_start
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"start_date": start, "end_date": end})
|
||||
rows = result.fetchall()
|
||||
|
||||
data_points = []
|
||||
for row in rows:
|
||||
data_points.append(ResosBookingsDataPoint(
|
||||
date=row.period_date,
|
||||
total_bookings=row.total_bookings or 0,
|
||||
breakfast_bookings=row.breakfast_bookings or 0,
|
||||
lunch_bookings=row.lunch_bookings or 0,
|
||||
afternoon_bookings=row.afternoon_bookings or 0,
|
||||
dinner_bookings=row.dinner_bookings or 0,
|
||||
other_bookings=row.other_bookings or 0,
|
||||
))
|
||||
|
||||
return data_points
|
||||
|
||||
|
||||
@router.get("/restaurant-covers", response_model=List[ResosCoversDataPoint])
|
||||
async def get_restaurant_covers_report(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
consolidation: str = Query("day", description="Consolidation period: day, week, or month"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get restaurant covers (guests) data for the specified date range with optional consolidation.
|
||||
"""
|
||||
# Validate dates
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||||
|
||||
if consolidation not in ["day", "week", "month"]:
|
||||
raise HTTPException(status_code=400, detail="Consolidation must be 'day', 'week', or 'month'")
|
||||
|
||||
if consolidation == "day":
|
||||
query = text("""
|
||||
SELECT
|
||||
to_char(d, 'YYYY-MM-DD') as period_date,
|
||||
COALESCE(s.total_covers, 0) as total_covers,
|
||||
COALESCE(s.breakfast_covers, 0) as breakfast_covers,
|
||||
COALESCE(s.lunch_covers, 0) as lunch_covers,
|
||||
COALESCE(s.afternoon_covers, 0) as afternoon_covers,
|
||||
COALESCE(s.dinner_covers, 0) as dinner_covers,
|
||||
COALESCE(s.other_covers, 0) as other_covers,
|
||||
COALESCE(s.hotel_guest_covers, 0) as hotel_guest_covers,
|
||||
COALESCE(s.non_hotel_guest_covers, 0) as non_hotel_guest_covers
|
||||
FROM generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) d
|
||||
LEFT JOIN resos_bookings_stats s ON s.date = d
|
||||
ORDER BY d
|
||||
""")
|
||||
elif consolidation == "week":
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
weekly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('week', d) AS date) as week_start,
|
||||
CAST(SUM(COALESCE(s.total_covers, 0)) AS integer) as total_covers,
|
||||
CAST(SUM(COALESCE(s.breakfast_covers, 0)) AS integer) as breakfast_covers,
|
||||
CAST(SUM(COALESCE(s.lunch_covers, 0)) AS integer) as lunch_covers,
|
||||
CAST(SUM(COALESCE(s.afternoon_covers, 0)) AS integer) as afternoon_covers,
|
||||
CAST(SUM(COALESCE(s.dinner_covers, 0)) AS integer) as dinner_covers,
|
||||
CAST(SUM(COALESCE(s.other_covers, 0)) AS integer) as other_covers,
|
||||
CAST(SUM(COALESCE(s.hotel_guest_covers, 0)) AS integer) as hotel_guest_covers,
|
||||
CAST(SUM(COALESCE(s.non_hotel_guest_covers, 0)) AS integer) as non_hotel_guest_covers
|
||||
FROM date_range dr
|
||||
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('week', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(week_start, 'YYYY-MM-DD') as period_date,
|
||||
total_covers, breakfast_covers, lunch_covers,
|
||||
afternoon_covers, dinner_covers, other_covers,
|
||||
hotel_guest_covers, non_hotel_guest_covers
|
||||
FROM weekly_data
|
||||
ORDER BY week_start
|
||||
""")
|
||||
else: # month
|
||||
query = text("""
|
||||
WITH date_range AS (
|
||||
SELECT CAST(generate_series(CAST(:start_date AS date), CAST(:end_date AS date), CAST('1 day' AS interval)) AS date) as d
|
||||
),
|
||||
monthly_data AS (
|
||||
SELECT
|
||||
CAST(date_trunc('month', d) AS date) as month_start,
|
||||
CAST(SUM(COALESCE(s.total_covers, 0)) AS integer) as total_covers,
|
||||
CAST(SUM(COALESCE(s.breakfast_covers, 0)) AS integer) as breakfast_covers,
|
||||
CAST(SUM(COALESCE(s.lunch_covers, 0)) AS integer) as lunch_covers,
|
||||
CAST(SUM(COALESCE(s.afternoon_covers, 0)) AS integer) as afternoon_covers,
|
||||
CAST(SUM(COALESCE(s.dinner_covers, 0)) AS integer) as dinner_covers,
|
||||
CAST(SUM(COALESCE(s.other_covers, 0)) AS integer) as other_covers,
|
||||
CAST(SUM(COALESCE(s.hotel_guest_covers, 0)) AS integer) as hotel_guest_covers,
|
||||
CAST(SUM(COALESCE(s.non_hotel_guest_covers, 0)) AS integer) as non_hotel_guest_covers
|
||||
FROM date_range dr
|
||||
LEFT JOIN resos_bookings_stats s ON s.date = dr.d
|
||||
GROUP BY date_trunc('month', d)
|
||||
)
|
||||
SELECT
|
||||
to_char(month_start, 'YYYY-MM-DD') as period_date,
|
||||
total_covers, breakfast_covers, lunch_covers,
|
||||
afternoon_covers, dinner_covers, other_covers,
|
||||
hotel_guest_covers, non_hotel_guest_covers
|
||||
FROM monthly_data
|
||||
ORDER BY month_start
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"start_date": start, "end_date": end})
|
||||
rows = result.fetchall()
|
||||
|
||||
data_points = []
|
||||
for row in rows:
|
||||
data_points.append(ResosCoversDataPoint(
|
||||
date=row.period_date,
|
||||
total_covers=row.total_covers or 0,
|
||||
breakfast_covers=row.breakfast_covers or 0,
|
||||
lunch_covers=row.lunch_covers or 0,
|
||||
afternoon_covers=row.afternoon_covers or 0,
|
||||
dinner_covers=row.dinner_covers or 0,
|
||||
other_covers=row.other_covers or 0,
|
||||
hotel_guest_covers=row.hotel_guest_covers or 0,
|
||||
non_hotel_guest_covers=row.non_hotel_guest_covers or 0,
|
||||
))
|
||||
|
||||
return data_points
|
||||
|
||||
|
||||
# ============================================
|
||||
# 3D PICKUP VISUALIZATION ENDPOINT
|
||||
# ============================================
|
||||
|
||||
class Pickup3DResponse(BaseModel):
|
||||
"""Response model for 3D pickup visualization data"""
|
||||
start_date: str
|
||||
end_date: str
|
||||
metric: str
|
||||
consolidation: str
|
||||
arrival_dates: List[str] # X-axis: dates in range
|
||||
lead_times: List[int] # Y-axis: lead times (days out)
|
||||
surface_data: List[List[Optional[float]]] # Z-axis: [lead_time][arrival_date] values
|
||||
final_values: List[Optional[float]] # Final values (d0) for each arrival date
|
||||
|
||||
|
||||
@router.get("/pickup-3d", response_model=Pickup3DResponse)
|
||||
async def get_pickup_3d_data(
|
||||
start_date: str = Query(..., description="Start date (YYYY-MM-DD)"),
|
||||
end_date: str = Query(..., description="End date (YYYY-MM-DD)"),
|
||||
metric: str = Query("rooms", description="Metric: rooms or occupancy"),
|
||||
consolidation: str = Query("day", description="Consolidation: day or week"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get 3D pickup visualization data for a date range.
|
||||
|
||||
Returns booking pace data structured for a 3D surface plot:
|
||||
- X-axis: Arrival dates in the range
|
||||
- Y-axis: Lead time (days before arrival when booking count was recorded)
|
||||
- Z-axis: Room count or occupancy percentage
|
||||
|
||||
This visualizes how bookings accumulated over time for each arrival date.
|
||||
"""
|
||||
if metric not in ["rooms", "occupancy"]:
|
||||
raise HTTPException(status_code=400, detail="Metric must be 'rooms' or 'occupancy'")
|
||||
|
||||
if consolidation not in ["day", "week"]:
|
||||
raise HTTPException(status_code=400, detail="Consolidation must be 'day' or 'week'")
|
||||
|
||||
# Parse dates
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
if start > end:
|
||||
raise HTTPException(status_code=400, detail="Start date must be before end date")
|
||||
|
||||
# Define the lead times we want to show
|
||||
# We'll show a reasonable subset to keep the visualization manageable
|
||||
# Daily for d0-d30, weekly for d30-d90, monthly beyond that
|
||||
lead_time_columns = [
|
||||
# Daily (0-30 days)
|
||||
'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7',
|
||||
'd8', 'd9', 'd10', 'd11', 'd12', 'd13', 'd14',
|
||||
'd15', 'd16', 'd17', 'd18', 'd19', 'd20', 'd21',
|
||||
'd22', 'd23', 'd24', 'd25', 'd26', 'd27', 'd28', 'd29', 'd30',
|
||||
# Weekly (37-93 days)
|
||||
'd37', 'd44', 'd51', 'd58', 'd65', 'd72', 'd79', 'd86', 'd93',
|
||||
# Further out (100+ days)
|
||||
'd100', 'd107', 'd114', 'd121', 'd128', 'd135', 'd142', 'd149',
|
||||
'd156', 'd163', 'd170', 'd177',
|
||||
# Monthly intervals
|
||||
'd210', 'd240', 'd270', 'd300', 'd330', 'd365',
|
||||
]
|
||||
|
||||
# Build select clause for available columns
|
||||
select_cols = ", ".join([f"COALESCE({col}, 0) as {col}" for col in lead_time_columns])
|
||||
|
||||
query = text(f"""
|
||||
SELECT
|
||||
arrival_date,
|
||||
{select_cols}
|
||||
FROM newbook_booking_pace
|
||||
WHERE arrival_date >= :start_date AND arrival_date <= :end_date
|
||||
ORDER BY arrival_date
|
||||
""")
|
||||
|
||||
result = await db.execute(query, {"start_date": start, "end_date": end})
|
||||
rows = result.fetchall()
|
||||
|
||||
# Generate all dates in range
|
||||
all_dates = []
|
||||
current = start
|
||||
while current <= end:
|
||||
all_dates.append(current.strftime("%Y-%m-%d"))
|
||||
current += timedelta(days=1)
|
||||
|
||||
if not rows:
|
||||
# Return empty structure if no data
|
||||
lead_times = [int(col[1:]) for col in lead_time_columns]
|
||||
return Pickup3DResponse(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
metric=metric,
|
||||
consolidation=consolidation,
|
||||
arrival_dates=all_dates,
|
||||
lead_times=lead_times,
|
||||
surface_data=[[None] * len(all_dates) for _ in lead_times],
|
||||
final_values=[None] * len(all_dates)
|
||||
)
|
||||
|
||||
# Get total rooms for occupancy calculation
|
||||
total_rooms = 27 # Hotel Number Four has 27 rooms
|
||||
|
||||
# Build the surface data
|
||||
row_data_dict = {}
|
||||
for row in rows:
|
||||
arr_date = row.arrival_date.strftime("%Y-%m-%d")
|
||||
row_data_dict[arr_date] = {col: getattr(row, col, 0) or 0 for col in lead_time_columns}
|
||||
|
||||
# Handle weekly consolidation
|
||||
if consolidation == "week":
|
||||
# Group dates by week (Monday start)
|
||||
weekly_dates = []
|
||||
weekly_data = {}
|
||||
current = start
|
||||
week_start = None
|
||||
|
||||
while current <= end:
|
||||
# Get Monday of this week
|
||||
days_since_monday = current.weekday()
|
||||
monday = current - timedelta(days=days_since_monday)
|
||||
week_label = monday.strftime("%Y-%m-%d")
|
||||
|
||||
if week_label not in weekly_data:
|
||||
weekly_dates.append(week_label)
|
||||
weekly_data[week_label] = {col: [] for col in lead_time_columns}
|
||||
|
||||
date_str = current.strftime("%Y-%m-%d")
|
||||
if date_str in row_data_dict:
|
||||
for col in lead_time_columns:
|
||||
weekly_data[week_label][col].append(row_data_dict[date_str].get(col, 0) or 0)
|
||||
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Average the weekly data
|
||||
all_dates = weekly_dates
|
||||
row_data_dict = {}
|
||||
for week_label in weekly_dates:
|
||||
row_data_dict[week_label] = {}
|
||||
for col in lead_time_columns:
|
||||
values = weekly_data[week_label][col]
|
||||
if values:
|
||||
row_data_dict[week_label][col] = sum(values) / len(values)
|
||||
else:
|
||||
row_data_dict[week_label][col] = 0
|
||||
|
||||
# Build surface_data: [lead_time_index][arrival_date_index]
|
||||
lead_times = [int(col[1:]) for col in lead_time_columns]
|
||||
surface_data = []
|
||||
|
||||
for col in lead_time_columns:
|
||||
lead_row = []
|
||||
for arr_date in all_dates:
|
||||
if arr_date in row_data_dict:
|
||||
value = row_data_dict[arr_date].get(col, 0) or 0
|
||||
if metric == "occupancy":
|
||||
# Convert to occupancy percentage
|
||||
value = round((value / total_rooms) * 100, 1) if total_rooms > 0 else 0
|
||||
lead_row.append(value)
|
||||
else:
|
||||
lead_row.append(None)
|
||||
surface_data.append(lead_row)
|
||||
|
||||
# Get final values (d0) for each arrival date
|
||||
final_values = []
|
||||
for arr_date in all_dates:
|
||||
if arr_date in row_data_dict:
|
||||
value = row_data_dict[arr_date].get('d0', 0) or 0
|
||||
if metric == "occupancy":
|
||||
value = round((value / total_rooms) * 100, 1) if total_rooms > 0 else 0
|
||||
final_values.append(value)
|
||||
else:
|
||||
final_values.append(None)
|
||||
|
||||
return Pickup3DResponse(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
metric=metric,
|
||||
consolidation=consolidation,
|
||||
arrival_dates=all_dates,
|
||||
lead_times=lead_times,
|
||||
surface_data=surface_data,
|
||||
final_values=final_values
|
||||
)
|
||||
718
backend/api/resos.py
Normal file
718
backend/api/resos.py
Normal file
|
|
@ -0,0 +1,718 @@
|
|||
"""
|
||||
Resos Mapping API Endpoints
|
||||
|
||||
Handles Resos custom field and opening hours configuration.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
from services.resos_client import ResosClient, ResosAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Pydantic Schemas ============
|
||||
|
||||
class CustomFieldMappingItem(BaseModel):
|
||||
field_id: str
|
||||
field_name: str
|
||||
field_type: str
|
||||
maps_to: str # 'hotel_guest', 'dbb', 'booking_number', 'allergies', 'ignore'
|
||||
value_for_true: Optional[str] = None # For radio/checkbox: which value means "yes"
|
||||
|
||||
|
||||
class OpeningHoursMappingItem(BaseModel):
|
||||
opening_hour_id: str
|
||||
opening_hour_name: str
|
||||
period_type: str # 'lunch', 'afternoon', 'dinner', 'ignore'
|
||||
is_regular: bool = True
|
||||
|
||||
|
||||
class MappingUpdate(BaseModel):
|
||||
custom_fields: Optional[List[CustomFieldMappingItem]] = None
|
||||
opening_hours: Optional[List[OpeningHoursMappingItem]] = None
|
||||
|
||||
|
||||
# ============ Custom Fields Endpoints ============
|
||||
|
||||
@router.get("/custom-fields")
|
||||
async def fetch_custom_fields(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Fetch custom field definitions from Resos API.
|
||||
Returns fields that can be mapped to hotel_guest, dbb, etc.
|
||||
"""
|
||||
try:
|
||||
async with await ResosClient.from_db(db) as client:
|
||||
fields = await client.get_custom_field_definitions()
|
||||
except ResosAPIError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Resos custom fields: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch custom fields from Resos")
|
||||
|
||||
# Return all custom fields (no filtering by type - let user decide what to map)
|
||||
formatted_fields = [
|
||||
{
|
||||
"id": f.get("_id") or f.get("id"),
|
||||
"name": f.get("name", ""),
|
||||
"type": f.get("type", ""),
|
||||
"values": f.get("choices", []) # For radio/dropdown fields
|
||||
}
|
||||
for f in fields
|
||||
]
|
||||
|
||||
return formatted_fields
|
||||
|
||||
|
||||
@router.get("/opening-hours")
|
||||
async def fetch_opening_hours(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Fetch opening hours/service periods from Resos API.
|
||||
Filters to regular (non-special) periods only.
|
||||
"""
|
||||
try:
|
||||
async with await ResosClient.from_db(db) as client:
|
||||
hours = await client.get_opening_hours()
|
||||
except ResosAPIError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch Resos opening hours: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch opening hours from Resos")
|
||||
|
||||
logger.info(f"Raw opening hours from Resos API: {len(hours)} periods")
|
||||
|
||||
# Filter out special/one-off periods - only return regular service periods
|
||||
# special=True means one-off events, special=False means recurring
|
||||
regular_hours = [h for h in hours if h.get('special') == False]
|
||||
|
||||
# Day of week mapping (Resos uses 1=Monday, 7=Sunday)
|
||||
day_names = ['', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||
|
||||
# Transform and add helpful fields
|
||||
formatted_hours = []
|
||||
for hour in regular_hours:
|
||||
hour_id = hour.get('_id') or hour.get('id', '')
|
||||
hour_name = hour.get('name', '')
|
||||
day_num = hour.get('day', 0)
|
||||
|
||||
# Convert open/close times from HHMM integers to HH:MM strings
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
if 'open' in hour:
|
||||
open_val = hour['open']
|
||||
hours_part = open_val // 100
|
||||
mins_part = open_val % 100
|
||||
start_time = f"{hours_part:02d}:{mins_part:02d}"
|
||||
|
||||
if 'close' in hour:
|
||||
close_val = hour['close']
|
||||
hours_part = close_val // 100
|
||||
mins_part = close_val % 100
|
||||
end_time = f"{hours_part:02d}:{mins_part:02d}"
|
||||
|
||||
formatted_hours.append({
|
||||
"id": hour_id,
|
||||
"name": hour_name,
|
||||
"day": day_num,
|
||||
"day_name": day_names[day_num] if 1 <= day_num <= 7 else "Unknown",
|
||||
"start_time": start_time,
|
||||
"end_time": end_time
|
||||
})
|
||||
|
||||
# Sort by day of week first, then by open time
|
||||
formatted_hours.sort(key=lambda h: (h.get('day', 0), h.get('start_time', '')))
|
||||
|
||||
logger.info(f"After filtering: {len(formatted_hours)} regular periods (filtered out {len(hours) - len(formatted_hours)} special periods)")
|
||||
|
||||
return formatted_hours
|
||||
|
||||
|
||||
# ============ Mapping Storage Endpoints ============
|
||||
|
||||
@router.get("/custom-field-mapping")
|
||||
async def get_custom_field_mappings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get saved custom field mappings."""
|
||||
cf_result = await db.execute(text("""
|
||||
SELECT field_id, field_name, field_type, maps_to, value_for_true
|
||||
FROM resos_custom_field_mapping
|
||||
ORDER BY field_name
|
||||
"""))
|
||||
custom_field_rows = cf_result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"custom_field_id": row.field_id,
|
||||
"mapping_type": row.maps_to,
|
||||
"value_for_true": row.value_for_true
|
||||
}
|
||||
for row in custom_field_rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/opening-hours-mapping")
|
||||
async def get_opening_hours_mappings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get saved opening hours mappings."""
|
||||
oh_result = await db.execute(text("""
|
||||
SELECT opening_hour_id, opening_hour_name, period_type, display_name, is_regular
|
||||
FROM resos_opening_hours_mapping
|
||||
ORDER BY opening_hour_name
|
||||
"""))
|
||||
opening_hour_rows = oh_result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"opening_hour_id": row.opening_hour_id,
|
||||
"opening_hour_name": row.opening_hour_name,
|
||||
"period_type": row.period_type,
|
||||
"display_name": row.display_name,
|
||||
"is_regular": row.is_regular
|
||||
}
|
||||
for row in opening_hour_rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/manual-breakfast-periods")
|
||||
async def get_manual_breakfast_periods(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get manual breakfast period configuration."""
|
||||
# Get enabled flag
|
||||
result = await db.execute(text("""
|
||||
SELECT config_value FROM system_config WHERE config_key = 'resos_enable_manual_breakfast'
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
enabled = row.config_value.lower() == 'true' if row and row.config_value else False
|
||||
|
||||
# Get periods
|
||||
result = await db.execute(text("""
|
||||
SELECT day_of_week, start_time, end_time, is_active
|
||||
FROM resos_manual_breakfast_periods
|
||||
ORDER BY day_of_week
|
||||
"""))
|
||||
rows = result.fetchall()
|
||||
|
||||
periods = [
|
||||
{
|
||||
"day_of_week": row.day_of_week,
|
||||
"start_time": str(row.start_time) if row.start_time else None,
|
||||
"end_time": str(row.end_time) if row.end_time else None,
|
||||
"is_active": row.is_active
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"periods": periods
|
||||
}
|
||||
|
||||
|
||||
@router.post("/custom-field-mapping")
|
||||
async def save_custom_field_mappings(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Save custom field mappings."""
|
||||
mappings = data.get("mappings", [])
|
||||
saved = 0
|
||||
|
||||
for mapping in mappings:
|
||||
field_id = mapping.get("custom_field_id")
|
||||
mapping_type = mapping.get("mapping_type")
|
||||
value_for_true = mapping.get("value_for_true")
|
||||
|
||||
if not field_id or not mapping_type:
|
||||
continue
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO resos_custom_field_mapping
|
||||
(field_id, field_name, field_type, maps_to, value_for_true, updated_at)
|
||||
VALUES
|
||||
(:field_id, '', '', :maps_to, :value_for_true, NOW())
|
||||
ON CONFLICT (field_id) DO UPDATE SET
|
||||
maps_to = EXCLUDED.maps_to,
|
||||
value_for_true = EXCLUDED.value_for_true,
|
||||
updated_at = NOW()
|
||||
"""), {
|
||||
"field_id": field_id,
|
||||
"maps_to": mapping_type,
|
||||
"value_for_true": value_for_true
|
||||
})
|
||||
saved += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Saved {saved} custom field mappings")
|
||||
|
||||
return {
|
||||
"message": "Custom field mappings saved successfully",
|
||||
"saved": saved
|
||||
}
|
||||
|
||||
|
||||
@router.post("/opening-hours-mapping")
|
||||
async def save_opening_hours_mappings(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Save opening hours mappings."""
|
||||
mappings = data.get("mappings", [])
|
||||
saved = 0
|
||||
|
||||
for mapping in mappings:
|
||||
opening_hour_id = mapping.get("opening_hour_id")
|
||||
period_type = mapping.get("period_type")
|
||||
display_name = mapping.get("display_name")
|
||||
|
||||
if not opening_hour_id or not period_type:
|
||||
continue
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO resos_opening_hours_mapping
|
||||
(opening_hour_id, opening_hour_name, period_type, display_name, is_regular, updated_at)
|
||||
VALUES
|
||||
(:opening_hour_id, '', :period_type, :display_name, TRUE, NOW())
|
||||
ON CONFLICT (opening_hour_id) DO UPDATE SET
|
||||
period_type = EXCLUDED.period_type,
|
||||
display_name = EXCLUDED.display_name,
|
||||
updated_at = NOW()
|
||||
"""), {
|
||||
"opening_hour_id": opening_hour_id,
|
||||
"period_type": period_type,
|
||||
"display_name": display_name
|
||||
})
|
||||
saved += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Saved {saved} opening hours mappings")
|
||||
|
||||
return {
|
||||
"message": "Opening hours mappings saved successfully",
|
||||
"saved": saved
|
||||
}
|
||||
|
||||
|
||||
@router.post("/manual-breakfast-periods")
|
||||
async def save_manual_breakfast_periods(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Save manual breakfast period configuration."""
|
||||
from datetime import time as dt_time
|
||||
|
||||
enabled = data.get("enabled", False)
|
||||
periods = data.get("periods", [])
|
||||
|
||||
# Save enabled flag to system_config
|
||||
await db.execute(text("""
|
||||
INSERT INTO system_config (config_key, config_value, description, updated_at)
|
||||
VALUES ('resos_enable_manual_breakfast', :value, 'Enable manual breakfast configuration', NOW())
|
||||
ON CONFLICT (config_key) DO UPDATE SET
|
||||
config_value = EXCLUDED.config_value,
|
||||
updated_at = NOW()
|
||||
"""), {"value": str(enabled).lower()})
|
||||
|
||||
# Save periods
|
||||
for period in periods:
|
||||
# Convert time strings to Python time objects for asyncpg
|
||||
start_time_str = period.get("start_time")
|
||||
end_time_str = period.get("end_time")
|
||||
|
||||
start_time_obj = None
|
||||
end_time_obj = None
|
||||
|
||||
if start_time_str:
|
||||
hour, minute = map(int, start_time_str.split(':'))
|
||||
start_time_obj = dt_time(hour, minute)
|
||||
|
||||
if end_time_str:
|
||||
hour, minute = map(int, end_time_str.split(':'))
|
||||
end_time_obj = dt_time(hour, minute)
|
||||
|
||||
await db.execute(text("""
|
||||
INSERT INTO resos_manual_breakfast_periods
|
||||
(day_of_week, start_time, end_time, is_active, updated_at)
|
||||
VALUES
|
||||
(:day_of_week, :start_time, :end_time, :is_active, NOW())
|
||||
ON CONFLICT (day_of_week) DO UPDATE SET
|
||||
start_time = EXCLUDED.start_time,
|
||||
end_time = EXCLUDED.end_time,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW()
|
||||
"""), {
|
||||
"day_of_week": period["day_of_week"],
|
||||
"start_time": start_time_obj,
|
||||
"end_time": end_time_obj,
|
||||
"is_active": period.get("is_active", True)
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Manual breakfast periods saved successfully"}
|
||||
|
||||
|
||||
@router.get("/mapping")
|
||||
async def get_mappings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get saved custom field and opening hours mappings.
|
||||
"""
|
||||
# Get custom field mappings
|
||||
cf_result = await db.execute(text("""
|
||||
SELECT field_id, field_name, field_type, maps_to, value_for_true
|
||||
FROM resos_custom_field_mapping
|
||||
ORDER BY field_name
|
||||
"""))
|
||||
custom_field_rows = cf_result.fetchall()
|
||||
|
||||
# Get opening hours mappings
|
||||
oh_result = await db.execute(text("""
|
||||
SELECT opening_hour_id, opening_hour_name, period_type, is_regular
|
||||
FROM resos_opening_hours_mapping
|
||||
ORDER BY opening_hour_name
|
||||
"""))
|
||||
opening_hour_rows = oh_result.fetchall()
|
||||
|
||||
return {
|
||||
"custom_fields": [
|
||||
{
|
||||
"field_id": row.field_id,
|
||||
"field_name": row.field_name,
|
||||
"field_type": row.field_type,
|
||||
"maps_to": row.maps_to,
|
||||
"value_for_true": row.value_for_true
|
||||
}
|
||||
for row in custom_field_rows
|
||||
],
|
||||
"opening_hours": [
|
||||
{
|
||||
"opening_hour_id": row.opening_hour_id,
|
||||
"opening_hour_name": row.opening_hour_name,
|
||||
"period_type": row.period_type,
|
||||
"is_regular": row.is_regular
|
||||
}
|
||||
for row in opening_hour_rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mapping")
|
||||
async def save_mappings(
|
||||
update: MappingUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Save custom field and opening hours mappings.
|
||||
Uses upsert to update existing or insert new mappings.
|
||||
"""
|
||||
saved_cf = 0
|
||||
saved_oh = 0
|
||||
|
||||
# Save custom field mappings
|
||||
if update.custom_fields:
|
||||
for cf in update.custom_fields:
|
||||
await db.execute(text("""
|
||||
INSERT INTO resos_custom_field_mapping
|
||||
(field_id, field_name, field_type, maps_to, value_for_true, updated_at)
|
||||
VALUES
|
||||
(:field_id, :field_name, :field_type, :maps_to, :value_for_true, NOW())
|
||||
ON CONFLICT (field_id) DO UPDATE SET
|
||||
field_name = EXCLUDED.field_name,
|
||||
field_type = EXCLUDED.field_type,
|
||||
maps_to = EXCLUDED.maps_to,
|
||||
value_for_true = EXCLUDED.value_for_true,
|
||||
updated_at = NOW()
|
||||
"""), {
|
||||
"field_id": cf.field_id,
|
||||
"field_name": cf.field_name,
|
||||
"field_type": cf.field_type,
|
||||
"maps_to": cf.maps_to,
|
||||
"value_for_true": cf.value_for_true
|
||||
})
|
||||
saved_cf += 1
|
||||
|
||||
# Save opening hours mappings
|
||||
if update.opening_hours:
|
||||
for oh in update.opening_hours:
|
||||
await db.execute(text("""
|
||||
INSERT INTO resos_opening_hours_mapping
|
||||
(opening_hour_id, opening_hour_name, period_type, is_regular, updated_at)
|
||||
VALUES
|
||||
(:opening_hour_id, :opening_hour_name, :period_type, :is_regular, NOW())
|
||||
ON CONFLICT (opening_hour_id) DO UPDATE SET
|
||||
opening_hour_name = EXCLUDED.opening_hour_name,
|
||||
period_type = EXCLUDED.period_type,
|
||||
is_regular = EXCLUDED.is_regular,
|
||||
updated_at = NOW()
|
||||
"""), {
|
||||
"opening_hour_id": oh.opening_hour_id,
|
||||
"opening_hour_name": oh.opening_hour_name,
|
||||
"period_type": oh.period_type,
|
||||
"is_regular": oh.is_regular
|
||||
})
|
||||
saved_oh += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"Saved {saved_cf} custom field mappings, {saved_oh} opening hours mappings")
|
||||
|
||||
return {
|
||||
"message": "Mappings saved successfully",
|
||||
"custom_fields_saved": saved_cf,
|
||||
"opening_hours_saved": saved_oh
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/mapping/custom-field/{field_id}")
|
||||
async def delete_custom_field_mapping(
|
||||
field_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a custom field mapping."""
|
||||
await db.execute(text("""
|
||||
DELETE FROM resos_custom_field_mapping WHERE field_id = :field_id
|
||||
"""), {"field_id": field_id})
|
||||
await db.commit()
|
||||
return {"message": "Mapping deleted"}
|
||||
|
||||
|
||||
@router.delete("/mapping/opening-hour/{opening_hour_id}")
|
||||
async def delete_opening_hour_mapping(
|
||||
opening_hour_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete an opening hours mapping."""
|
||||
await db.execute(text("""
|
||||
DELETE FROM resos_opening_hours_mapping WHERE opening_hour_id = :opening_hour_id
|
||||
"""), {"opening_hour_id": opening_hour_id})
|
||||
await db.commit()
|
||||
return {"message": "Mapping deleted"}
|
||||
|
||||
|
||||
# ============ Average Spend Settings Endpoints ============
|
||||
|
||||
@router.get("/average-spend")
|
||||
async def get_average_spend(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get average spend settings for breakfast, lunch and dinner."""
|
||||
# Get values from system_config
|
||||
result = await db.execute(text("""
|
||||
SELECT config_key, config_value FROM system_config
|
||||
WHERE config_key IN (
|
||||
'resos_breakfast_food_spend',
|
||||
'resos_breakfast_drinks_spend',
|
||||
'resos_lunch_food_spend',
|
||||
'resos_lunch_drinks_spend',
|
||||
'resos_dinner_food_spend',
|
||||
'resos_dinner_drinks_spend'
|
||||
)
|
||||
"""))
|
||||
rows = result.fetchall()
|
||||
|
||||
settings = {}
|
||||
for row in rows:
|
||||
key = row.config_key.replace('resos_', '')
|
||||
try:
|
||||
settings[key] = float(row.config_value) if row.config_value else 0
|
||||
except (ValueError, TypeError):
|
||||
settings[key] = 0
|
||||
|
||||
return {
|
||||
"breakfast_food_spend": settings.get('breakfast_food_spend', 0),
|
||||
"breakfast_drinks_spend": settings.get('breakfast_drinks_spend', 0),
|
||||
"lunch_food_spend": settings.get('lunch_food_spend', 0),
|
||||
"lunch_drinks_spend": settings.get('lunch_drinks_spend', 0),
|
||||
"dinner_food_spend": settings.get('dinner_food_spend', 0),
|
||||
"dinner_drinks_spend": settings.get('dinner_drinks_spend', 0)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/average-spend")
|
||||
async def save_average_spend(
|
||||
data: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Save average spend settings for breakfast, lunch and dinner."""
|
||||
settings = [
|
||||
('resos_breakfast_food_spend', data.get('breakfast_food_spend', 0), 'Average food spend per cover for breakfast'),
|
||||
('resos_breakfast_drinks_spend', data.get('breakfast_drinks_spend', 0), 'Average drinks spend per cover for breakfast'),
|
||||
('resos_lunch_food_spend', data.get('lunch_food_spend', 0), 'Average food spend per cover for lunch'),
|
||||
('resos_lunch_drinks_spend', data.get('lunch_drinks_spend', 0), 'Average drinks spend per cover for lunch'),
|
||||
('resos_dinner_food_spend', data.get('dinner_food_spend', 0), 'Average food spend per cover for dinner'),
|
||||
('resos_dinner_drinks_spend', data.get('dinner_drinks_spend', 0), 'Average drinks spend per cover for dinner'),
|
||||
]
|
||||
|
||||
for config_key, config_value, description in settings:
|
||||
await db.execute(text("""
|
||||
INSERT INTO system_config (config_key, config_value, description, updated_at)
|
||||
VALUES (:config_key, :config_value, :description, NOW())
|
||||
ON CONFLICT (config_key) DO UPDATE SET
|
||||
config_value = EXCLUDED.config_value,
|
||||
updated_at = NOW()
|
||||
"""), {
|
||||
"config_key": config_key,
|
||||
"config_value": str(config_value),
|
||||
"description": description
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
logger.info("Saved average spend settings")
|
||||
|
||||
return {"message": "Average spend settings saved successfully"}
|
||||
|
||||
|
||||
# ============ Pace Data Backfill Endpoint ============
|
||||
|
||||
@router.post("/backfill-pace")
|
||||
async def backfill_pace_data(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Backfill resos_booking_pace table for historical dates.
|
||||
|
||||
This calculates pace snapshots using booking_placed timestamps
|
||||
to reconstruct what was on the books at each lead time.
|
||||
|
||||
Args:
|
||||
start_date: Start date (YYYY-MM-DD)
|
||||
end_date: End date (YYYY-MM-DD)
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return {"error": "Invalid date format. Use YYYY-MM-DD"}
|
||||
|
||||
if (end - start).days > 400:
|
||||
return {"error": "Date range too large. Max 400 days at a time."}
|
||||
|
||||
# Valid statuses and pace intervals
|
||||
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left')
|
||||
PACE_INTERVALS = [
|
||||
365, 330, 300, 270, 240, 210,
|
||||
177, 170, 163, 156, 149, 142, 135, 128, 121, 114,
|
||||
107, 100, 93, 86, 79, 72, 65, 58, 51, 44, 37,
|
||||
30, 29, 28, 27, 26, 25, 24, 23, 22, 21,
|
||||
20, 19, 18, 17, 16, 15, 14, 13, 12, 11,
|
||||
10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0
|
||||
]
|
||||
|
||||
dates_processed = 0
|
||||
current = start
|
||||
|
||||
while current <= end:
|
||||
for pace_type in ['total', 'resident', 'non_resident']:
|
||||
pace_values = {}
|
||||
|
||||
for days_out in PACE_INTERVALS:
|
||||
snapshot_date = current - timedelta(days=days_out)
|
||||
|
||||
# Build query based on pace_type
|
||||
if pace_type == 'total':
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(covers), 0) as total_covers
|
||||
FROM resos_bookings_data
|
||||
WHERE booking_date = :target_date
|
||||
AND status IN ('approved', 'arrived', 'seated', 'left')
|
||||
AND booking_placed <= :snapshot_date
|
||||
"""),
|
||||
{"target_date": current, "snapshot_date": snapshot_date}
|
||||
)
|
||||
elif pace_type == 'resident':
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(covers), 0) as total_covers
|
||||
FROM resos_bookings_data
|
||||
WHERE booking_date = :target_date
|
||||
AND status IN ('approved', 'arrived', 'seated', 'left')
|
||||
AND booking_placed <= :snapshot_date
|
||||
AND is_hotel_guest = true
|
||||
"""),
|
||||
{"target_date": current, "snapshot_date": snapshot_date}
|
||||
)
|
||||
else: # non_resident
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(covers), 0) as total_covers
|
||||
FROM resos_bookings_data
|
||||
WHERE booking_date = :target_date
|
||||
AND status IN ('approved', 'arrived', 'seated', 'left')
|
||||
AND booking_placed <= :snapshot_date
|
||||
AND (is_hotel_guest = false OR is_hotel_guest IS NULL)
|
||||
"""),
|
||||
{"target_date": current, "snapshot_date": snapshot_date}
|
||||
)
|
||||
|
||||
row = result.fetchone()
|
||||
pace_values[f"d{days_out}"] = row.total_covers if row else 0
|
||||
|
||||
# Upsert pace record
|
||||
columns = ", ".join(pace_values.keys())
|
||||
placeholders = ", ".join([f":{k}" for k in pace_values.keys()])
|
||||
updates = ", ".join([f"{k} = :{k}" for k in pace_values.keys()])
|
||||
|
||||
await db.execute(
|
||||
text(f"""
|
||||
INSERT INTO resos_booking_pace (booking_date, pace_type, {columns}, updated_at)
|
||||
VALUES (:booking_date, :pace_type, {placeholders}, NOW())
|
||||
ON CONFLICT (booking_date, pace_type) DO UPDATE SET
|
||||
{updates},
|
||||
updated_at = NOW()
|
||||
"""),
|
||||
{"booking_date": current, "pace_type": pace_type, **pace_values}
|
||||
)
|
||||
|
||||
dates_processed += 1
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Commit every 30 days to avoid large transactions
|
||||
if dates_processed % 30 == 0:
|
||||
await db.commit()
|
||||
logger.info(f"Backfill progress: {dates_processed} dates processed")
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Pace backfill completed: {dates_processed} dates from {start_date} to {end_date}")
|
||||
|
||||
return {
|
||||
"message": f"Pace data backfilled successfully",
|
||||
"dates_processed": dates_processed,
|
||||
"start_date": start_date,
|
||||
"end_date": end_date
|
||||
}
|
||||
244
backend/api/resos_sync.py
Normal file
244
backend/api/resos_sync.py
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
"""
|
||||
Resos Bookings Sync API Endpoints
|
||||
Pattern: Similar to sync_bookings.py but for Resos
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ResosSyncConfig(BaseModel):
|
||||
"""Auto sync configuration for Resos bookings"""
|
||||
auto_sync_enabled: bool
|
||||
sync_time: str = "05:05" # HH:MM format
|
||||
|
||||
|
||||
@router.get("/resos-bookings/status")
|
||||
async def get_resos_sync_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get sync status for Resos bookings data including last sync info."""
|
||||
# Get last successful sync
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, sync_type, started_at, completed_at, status,
|
||||
records_fetched, records_created, records_updated,
|
||||
date_from, date_to, error_message, triggered_by
|
||||
FROM sync_log
|
||||
WHERE source = 'resos' AND sync_type = 'bookings_data'
|
||||
AND status = 'success'
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
)
|
||||
last_success = result.fetchone()
|
||||
|
||||
# Get last sync (any status)
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, sync_type, started_at, completed_at, status,
|
||||
records_fetched, records_created, records_updated,
|
||||
date_from, date_to, error_message, triggered_by
|
||||
FROM sync_log
|
||||
WHERE source = 'resos' AND sync_type = 'bookings_data'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
)
|
||||
last_sync = result.fetchone()
|
||||
|
||||
# Get auto sync config
|
||||
result = await db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_enabled'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
auto_enabled = row.config_value.lower() == 'true' if row and row.config_value else False
|
||||
|
||||
result = await db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_time'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
sync_time = row.config_value if row and row.config_value else '05:05'
|
||||
|
||||
# Get total records
|
||||
result = await db.execute(text("SELECT COUNT(*) as count FROM resos_bookings_data"))
|
||||
total_records = result.fetchone().count
|
||||
|
||||
# Get date range
|
||||
result = await db.execute(
|
||||
text("SELECT MIN(booking_date) as min_date, MAX(booking_date) as max_date FROM resos_bookings_data")
|
||||
)
|
||||
date_range = result.fetchone()
|
||||
|
||||
return {
|
||||
"last_successful_sync": {
|
||||
"completed_at": last_success.completed_at.isoformat() if last_success and last_success.completed_at else None,
|
||||
"records_fetched": last_success.records_fetched if last_success else None,
|
||||
"records_created": last_success.records_created if last_success else None,
|
||||
"records_updated": last_success.records_updated if last_success else None,
|
||||
"date_from": last_success.date_from.isoformat() if last_success and last_success.date_from else None,
|
||||
"date_to": last_success.date_to.isoformat() if last_success and last_success.date_to else None,
|
||||
"triggered_by": last_success.triggered_by if last_success else None,
|
||||
} if last_success else None,
|
||||
"last_sync": {
|
||||
"started_at": last_sync.started_at.isoformat() if last_sync and last_sync.started_at else None,
|
||||
"completed_at": last_sync.completed_at.isoformat() if last_sync and last_sync.completed_at else None,
|
||||
"status": last_sync.status if last_sync else None,
|
||||
"records_fetched": last_sync.records_fetched if last_sync else None,
|
||||
"date_from": last_sync.date_from.isoformat() if last_sync and last_sync.date_from else None,
|
||||
"date_to": last_sync.date_to.isoformat() if last_sync and last_sync.date_to else None,
|
||||
"error_message": last_sync.error_message if last_sync else None,
|
||||
"triggered_by": last_sync.triggered_by if last_sync else None,
|
||||
} if last_sync else None,
|
||||
"auto_sync": {
|
||||
"enabled": auto_enabled,
|
||||
"time": sync_time
|
||||
},
|
||||
"total_records": total_records,
|
||||
"data_range": {
|
||||
"from": date_range.min_date.isoformat() if date_range and date_range.min_date else None,
|
||||
"to": date_range.max_date.isoformat() if date_range and date_range.max_date else None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/resos-bookings/sync")
|
||||
async def trigger_resos_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
from_date: Optional[date] = Query(None, description="Start date (default: today - 365)"),
|
||||
to_date: Optional[date] = Query(None, description="End date (default: today + 365)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger a Resos bookings sync.
|
||||
Default: -365 to +365 days (historical + forecast window)
|
||||
"""
|
||||
if not from_date:
|
||||
from_date = date.today() - timedelta(days=365)
|
||||
if not to_date:
|
||||
to_date = date.today() + timedelta(days=365)
|
||||
|
||||
# Queue background task
|
||||
background_tasks.add_task(
|
||||
run_resos_sync_task,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
triggered_by=f"user:{current_user['username']}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"from_date": from_date.isoformat(),
|
||||
"to_date": to_date.isoformat(),
|
||||
"message": f"Resos bookings sync started for {from_date} to {to_date}"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/resos-bookings/config")
|
||||
async def get_resos_sync_config(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Get auto sync configuration for Resos bookings."""
|
||||
# Get enabled setting
|
||||
result = await db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_enabled'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
enabled = row.config_value.lower() == 'true' if row and row.config_value else False
|
||||
|
||||
# Get sync time setting
|
||||
result = await db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'sync_resos_bookings_time'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
sync_time = row.config_value if row and row.config_value else '05:05'
|
||||
|
||||
return {
|
||||
"auto_sync_enabled": enabled,
|
||||
"sync_time": sync_time
|
||||
}
|
||||
|
||||
|
||||
@router.post("/resos-bookings/config")
|
||||
async def update_resos_sync_config(
|
||||
config: ResosSyncConfig,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update auto sync configuration for Resos bookings."""
|
||||
# Upsert enabled setting
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
|
||||
VALUES ('sync_resos_bookings_enabled', :value, 'Enable automatic Resos bookings sync', NOW(), :user)
|
||||
ON CONFLICT (config_key) DO UPDATE SET
|
||||
config_value = :value,
|
||||
updated_at = NOW(),
|
||||
updated_by = :user
|
||||
"""),
|
||||
{"value": str(config.auto_sync_enabled).lower(), "user": current_user['username']}
|
||||
)
|
||||
|
||||
# Upsert sync time setting
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO system_config (config_key, config_value, description, updated_at, updated_by)
|
||||
VALUES ('sync_resos_bookings_time', :value, 'Resos bookings sync time (HH:MM)', NOW(), :user)
|
||||
ON CONFLICT (config_key) DO UPDATE SET
|
||||
config_value = :value,
|
||||
updated_at = NOW(),
|
||||
updated_by = :user
|
||||
"""),
|
||||
{"value": config.sync_time, "user": current_user['username']}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"auto_sync_enabled": config.auto_sync_enabled,
|
||||
"sync_time": config.sync_time
|
||||
}
|
||||
|
||||
|
||||
def run_resos_sync_task(
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
triggered_by: str = "scheduler"
|
||||
):
|
||||
"""Background task to run Resos sync."""
|
||||
import sys
|
||||
import asyncio
|
||||
from jobs.resos_bookings_sync import sync_resos_bookings_data
|
||||
|
||||
print(f"[SYNC-RESOS] Starting sync ({from_date} to {to_date})", flush=True)
|
||||
sys.stdout.flush()
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
sync_resos_bookings_data(from_date, to_date, triggered_by)
|
||||
)
|
||||
print(f"[SYNC-RESOS] Sync completed", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[SYNC-RESOS] FAILED: {e}", flush=True)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
loop.close()
|
||||
571
backend/api/special_dates.py
Normal file
571
backend/api/special_dates.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"""
|
||||
Special Dates API - Configure custom holidays/events for Prophet forecasting
|
||||
"""
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db
|
||||
from auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================
|
||||
# MODELS
|
||||
# ============================================
|
||||
|
||||
class DatePatternType(str, Enum):
|
||||
FIXED = "fixed" # Fixed date each year (e.g., Feb 14)
|
||||
NTH_WEEKDAY = "nth_weekday" # Nth weekday of month (e.g., 2nd Monday of Feb)
|
||||
RELATIVE_TO_DATE = "relative_to_date" # Weekday before/after a fixed date
|
||||
|
||||
|
||||
class SpecialDateBase(BaseModel):
|
||||
name: str
|
||||
pattern_type: DatePatternType
|
||||
# For FIXED pattern
|
||||
fixed_month: Optional[int] = None # 1-12
|
||||
fixed_day: Optional[int] = None # 1-31
|
||||
# For NTH_WEEKDAY pattern
|
||||
nth_week: Optional[int] = None # 1-5 or -1 for last
|
||||
weekday: Optional[int] = None # 0=Mon, 6=Sun
|
||||
month: Optional[int] = None # 1-12
|
||||
# For RELATIVE_TO_DATE pattern
|
||||
relative_to_month: Optional[int] = None
|
||||
relative_to_day: Optional[int] = None
|
||||
relative_weekday: Optional[int] = None # Which weekday to find
|
||||
relative_direction: Optional[str] = None # 'before' or 'after'
|
||||
# Common fields
|
||||
duration_days: int = 1
|
||||
is_recurring: bool = True
|
||||
one_off_year: Optional[int] = None # Only if is_recurring = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SpecialDateCreate(SpecialDateBase):
|
||||
pass
|
||||
|
||||
|
||||
class SpecialDateUpdate(SpecialDateBase):
|
||||
pass
|
||||
|
||||
|
||||
class SpecialDateResponse(SpecialDateBase):
|
||||
id: int
|
||||
created_at: str
|
||||
|
||||
|
||||
class ResolvedDate(BaseModel):
|
||||
name: str
|
||||
date: str
|
||||
day_of_week: str
|
||||
|
||||
|
||||
# ============================================
|
||||
# TABLE CREATION
|
||||
# ============================================
|
||||
|
||||
async def ensure_table_exists(db: AsyncSession):
|
||||
"""Create the special_dates table if it doesn't exist"""
|
||||
await db.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS special_dates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
pattern_type VARCHAR(20) NOT NULL,
|
||||
fixed_month INTEGER,
|
||||
fixed_day INTEGER,
|
||||
nth_week INTEGER,
|
||||
weekday INTEGER,
|
||||
month INTEGER,
|
||||
relative_to_month INTEGER,
|
||||
relative_to_day INTEGER,
|
||||
relative_weekday INTEGER,
|
||||
relative_direction VARCHAR(10),
|
||||
duration_days INTEGER DEFAULT 1,
|
||||
is_recurring BOOLEAN DEFAULT TRUE,
|
||||
one_off_year INTEGER,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
"""))
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ============================================
|
||||
# DATE RESOLUTION HELPERS
|
||||
# ============================================
|
||||
|
||||
def get_nth_weekday_of_month(year: int, month: int, weekday: int, nth: int) -> Optional[date]:
|
||||
"""
|
||||
Get the nth occurrence of a weekday in a month.
|
||||
nth: 1-5 for first through fifth, -1 for last
|
||||
weekday: 0=Monday, 6=Sunday
|
||||
"""
|
||||
if nth == -1:
|
||||
# Last occurrence - start from end of month
|
||||
if month == 12:
|
||||
next_month = date(year + 1, 1, 1)
|
||||
else:
|
||||
next_month = date(year, month + 1, 1)
|
||||
last_day = next_month - timedelta(days=1)
|
||||
|
||||
# Find the last occurrence of the weekday
|
||||
days_back = (last_day.weekday() - weekday) % 7
|
||||
return last_day - timedelta(days=days_back)
|
||||
else:
|
||||
# Nth occurrence from start
|
||||
first_of_month = date(year, month, 1)
|
||||
# Find the first occurrence of the weekday
|
||||
days_ahead = (weekday - first_of_month.weekday()) % 7
|
||||
first_occurrence = first_of_month + timedelta(days=days_ahead)
|
||||
# Add weeks to get to nth occurrence
|
||||
result = first_occurrence + timedelta(weeks=nth - 1)
|
||||
# Verify it's still in the same month
|
||||
if result.month != month:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
def get_weekday_relative_to_date(year: int, month: int, day: int,
|
||||
target_weekday: int, direction: str) -> Optional[date]:
|
||||
"""
|
||||
Get a specific weekday before or after a fixed date.
|
||||
target_weekday: 0=Monday, 6=Sunday
|
||||
direction: 'before' or 'after'
|
||||
"""
|
||||
try:
|
||||
base_date = date(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if direction == 'before':
|
||||
# Find the weekday before (or on) the base date
|
||||
days_back = (base_date.weekday() - target_weekday) % 7
|
||||
if days_back == 0:
|
||||
days_back = 7 # If same weekday, go back a week
|
||||
return base_date - timedelta(days=days_back)
|
||||
else: # after
|
||||
# Find the weekday after the base date
|
||||
days_ahead = (target_weekday - base_date.weekday()) % 7
|
||||
if days_ahead == 0:
|
||||
days_ahead = 7 # If same weekday, go forward a week
|
||||
return base_date + timedelta(days=days_ahead)
|
||||
|
||||
|
||||
def resolve_special_date(sd: dict, year: int) -> List[date]:
|
||||
"""Resolve a special date pattern to actual dates for a given year"""
|
||||
if not sd['is_recurring'] and sd.get('one_off_year') and sd['one_off_year'] != year:
|
||||
return []
|
||||
|
||||
base_date = None
|
||||
pattern_type = sd['pattern_type']
|
||||
|
||||
if pattern_type == 'fixed':
|
||||
try:
|
||||
base_date = date(year, sd['fixed_month'], sd['fixed_day'])
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
elif pattern_type == 'nth_weekday':
|
||||
base_date = get_nth_weekday_of_month(
|
||||
year, sd['month'], sd['weekday'], sd['nth_week']
|
||||
)
|
||||
|
||||
elif pattern_type == 'relative_to_date':
|
||||
base_date = get_weekday_relative_to_date(
|
||||
year, sd['relative_to_month'], sd['relative_to_day'],
|
||||
sd['relative_weekday'], sd['relative_direction']
|
||||
)
|
||||
|
||||
if base_date is None:
|
||||
return []
|
||||
|
||||
# Generate dates for duration
|
||||
duration = sd.get('duration_days', 1) or 1
|
||||
return [base_date + timedelta(days=i) for i in range(duration)]
|
||||
|
||||
|
||||
# ============================================
|
||||
# CRUD ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@router.get("/special-dates", response_model=List[SpecialDateResponse])
|
||||
async def list_special_dates(
|
||||
active_only: bool = Query(False, description="Only return active dates"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List all special date configurations"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
query = "SELECT * FROM special_dates"
|
||||
if active_only:
|
||||
query += " WHERE is_active = TRUE"
|
||||
query += " ORDER BY name"
|
||||
|
||||
result = await db.execute(text(query))
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
SpecialDateResponse(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
pattern_type=row.pattern_type,
|
||||
fixed_month=row.fixed_month,
|
||||
fixed_day=row.fixed_day,
|
||||
nth_week=row.nth_week,
|
||||
weekday=row.weekday,
|
||||
month=row.month,
|
||||
relative_to_month=row.relative_to_month,
|
||||
relative_to_day=row.relative_to_day,
|
||||
relative_weekday=row.relative_weekday,
|
||||
relative_direction=row.relative_direction,
|
||||
duration_days=row.duration_days or 1,
|
||||
is_recurring=row.is_recurring,
|
||||
one_off_year=row.one_off_year,
|
||||
is_active=row.is_active,
|
||||
created_at=str(row.created_at)
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/special-dates", response_model=SpecialDateResponse)
|
||||
async def create_special_date(
|
||||
data: SpecialDateCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Create a new special date configuration"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
result = await db.execute(text("""
|
||||
INSERT INTO special_dates (
|
||||
name, pattern_type, fixed_month, fixed_day, nth_week, weekday, month,
|
||||
relative_to_month, relative_to_day, relative_weekday, relative_direction,
|
||||
duration_days, is_recurring, one_off_year, is_active
|
||||
) VALUES (
|
||||
:name, :pattern_type, :fixed_month, :fixed_day, :nth_week, :weekday, :month,
|
||||
:relative_to_month, :relative_to_day, :relative_weekday, :relative_direction,
|
||||
:duration_days, :is_recurring, :one_off_year, :is_active
|
||||
) RETURNING id, created_at
|
||||
"""), {
|
||||
"name": data.name,
|
||||
"pattern_type": data.pattern_type.value,
|
||||
"fixed_month": data.fixed_month,
|
||||
"fixed_day": data.fixed_day,
|
||||
"nth_week": data.nth_week,
|
||||
"weekday": data.weekday,
|
||||
"month": data.month,
|
||||
"relative_to_month": data.relative_to_month,
|
||||
"relative_to_day": data.relative_to_day,
|
||||
"relative_weekday": data.relative_weekday,
|
||||
"relative_direction": data.relative_direction,
|
||||
"duration_days": data.duration_days,
|
||||
"is_recurring": data.is_recurring,
|
||||
"one_off_year": data.one_off_year,
|
||||
"is_active": data.is_active
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
await db.commit()
|
||||
|
||||
return SpecialDateResponse(
|
||||
id=row.id,
|
||||
created_at=str(row.created_at),
|
||||
**data.dict()
|
||||
)
|
||||
|
||||
|
||||
@router.put("/special-dates/{date_id}", response_model=SpecialDateResponse)
|
||||
async def update_special_date(
|
||||
date_id: int,
|
||||
data: SpecialDateUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Update a special date configuration"""
|
||||
result = await db.execute(text("""
|
||||
UPDATE special_dates SET
|
||||
name = :name,
|
||||
pattern_type = :pattern_type,
|
||||
fixed_month = :fixed_month,
|
||||
fixed_day = :fixed_day,
|
||||
nth_week = :nth_week,
|
||||
weekday = :weekday,
|
||||
month = :month,
|
||||
relative_to_month = :relative_to_month,
|
||||
relative_to_day = :relative_to_day,
|
||||
relative_weekday = :relative_weekday,
|
||||
relative_direction = :relative_direction,
|
||||
duration_days = :duration_days,
|
||||
is_recurring = :is_recurring,
|
||||
one_off_year = :one_off_year,
|
||||
is_active = :is_active,
|
||||
updated_at = NOW()
|
||||
WHERE id = :id
|
||||
RETURNING id, created_at
|
||||
"""), {
|
||||
"id": date_id,
|
||||
"name": data.name,
|
||||
"pattern_type": data.pattern_type.value,
|
||||
"fixed_month": data.fixed_month,
|
||||
"fixed_day": data.fixed_day,
|
||||
"nth_week": data.nth_week,
|
||||
"weekday": data.weekday,
|
||||
"month": data.month,
|
||||
"relative_to_month": data.relative_to_month,
|
||||
"relative_to_day": data.relative_to_day,
|
||||
"relative_weekday": data.relative_weekday,
|
||||
"relative_direction": data.relative_direction,
|
||||
"duration_days": data.duration_days,
|
||||
"is_recurring": data.is_recurring,
|
||||
"one_off_year": data.one_off_year,
|
||||
"is_active": data.is_active
|
||||
})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Special date not found")
|
||||
|
||||
await db.commit()
|
||||
|
||||
return SpecialDateResponse(
|
||||
id=row.id,
|
||||
created_at=str(row.created_at),
|
||||
**data.dict()
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/special-dates/{date_id}")
|
||||
async def delete_special_date(
|
||||
date_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a special date configuration"""
|
||||
result = await db.execute(
|
||||
text("DELETE FROM special_dates WHERE id = :id RETURNING id"),
|
||||
{"id": date_id}
|
||||
)
|
||||
|
||||
if not result.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Special date not found")
|
||||
|
||||
await db.commit()
|
||||
return {"message": "Special date deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/special-dates/preview", response_model=List[ResolvedDate])
|
||||
async def preview_special_dates(
|
||||
year: int = Query(..., description="Year to preview dates for"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Preview resolved dates for a given year"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT * FROM special_dates WHERE is_active = TRUE ORDER BY name"
|
||||
))
|
||||
rows = result.fetchall()
|
||||
|
||||
resolved = []
|
||||
for row in rows:
|
||||
sd = {
|
||||
'pattern_type': row.pattern_type,
|
||||
'fixed_month': row.fixed_month,
|
||||
'fixed_day': row.fixed_day,
|
||||
'nth_week': row.nth_week,
|
||||
'weekday': row.weekday,
|
||||
'month': row.month,
|
||||
'relative_to_month': row.relative_to_month,
|
||||
'relative_to_day': row.relative_to_day,
|
||||
'relative_weekday': row.relative_weekday,
|
||||
'relative_direction': row.relative_direction,
|
||||
'duration_days': row.duration_days,
|
||||
'is_recurring': row.is_recurring,
|
||||
'one_off_year': row.one_off_year
|
||||
}
|
||||
|
||||
dates = resolve_special_date(sd, year)
|
||||
for d in dates:
|
||||
resolved.append(ResolvedDate(
|
||||
name=row.name,
|
||||
date=str(d),
|
||||
day_of_week=d.strftime("%a")
|
||||
))
|
||||
|
||||
# Sort by date
|
||||
resolved.sort(key=lambda x: x.date)
|
||||
return resolved
|
||||
|
||||
|
||||
@router.post("/special-dates/seed-defaults")
|
||||
async def seed_default_dates(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Seed default special dates (Valentine's, Christmas Eve, NYE, Bank Holiday weekends)"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
# Check if already seeded
|
||||
result = await db.execute(text("SELECT COUNT(*) as count FROM special_dates"))
|
||||
if result.fetchone().count > 0:
|
||||
return {"message": "Special dates already exist. Delete existing to re-seed."}
|
||||
|
||||
defaults = [
|
||||
# Valentine's Day
|
||||
{
|
||||
"name": "Valentine's Day",
|
||||
"pattern_type": "fixed",
|
||||
"fixed_month": 2,
|
||||
"fixed_day": 14,
|
||||
"duration_days": 1
|
||||
},
|
||||
# Christmas Eve
|
||||
{
|
||||
"name": "Christmas Eve",
|
||||
"pattern_type": "fixed",
|
||||
"fixed_month": 12,
|
||||
"fixed_day": 24,
|
||||
"duration_days": 1
|
||||
},
|
||||
# New Year's Eve
|
||||
{
|
||||
"name": "New Year's Eve",
|
||||
"pattern_type": "fixed",
|
||||
"fixed_month": 12,
|
||||
"fixed_day": 31,
|
||||
"duration_days": 1
|
||||
},
|
||||
# Friday before Christmas (if Christmas is Sat-Tue)
|
||||
{
|
||||
"name": "Friday Before Christmas",
|
||||
"pattern_type": "relative_to_date",
|
||||
"relative_to_month": 12,
|
||||
"relative_to_day": 25,
|
||||
"relative_weekday": 4, # Friday
|
||||
"relative_direction": "before",
|
||||
"duration_days": 1
|
||||
},
|
||||
# Saturday before Christmas
|
||||
{
|
||||
"name": "Saturday Before Christmas",
|
||||
"pattern_type": "relative_to_date",
|
||||
"relative_to_month": 12,
|
||||
"relative_to_day": 25,
|
||||
"relative_weekday": 5, # Saturday
|
||||
"relative_direction": "before",
|
||||
"duration_days": 1
|
||||
},
|
||||
# Early May Bank Holiday Weekend (Fri-Sat of first Monday in May)
|
||||
{
|
||||
"name": "Early May BH Friday",
|
||||
"pattern_type": "nth_weekday",
|
||||
"month": 5,
|
||||
"nth_week": 1,
|
||||
"weekday": 4, # Friday (before the Monday)
|
||||
"duration_days": 1
|
||||
},
|
||||
# Spring Bank Holiday Weekend (last Monday of May)
|
||||
{
|
||||
"name": "Spring BH Weekend",
|
||||
"pattern_type": "nth_weekday",
|
||||
"month": 5,
|
||||
"nth_week": -1, # Last week
|
||||
"weekday": 5, # Saturday
|
||||
"duration_days": 2 # Sat + Sun (Mon is the bank holiday itself)
|
||||
},
|
||||
# August Bank Holiday Weekend (last Monday of August)
|
||||
{
|
||||
"name": "August BH Weekend",
|
||||
"pattern_type": "nth_weekday",
|
||||
"month": 8,
|
||||
"nth_week": -1,
|
||||
"weekday": 5, # Saturday
|
||||
"duration_days": 2
|
||||
},
|
||||
]
|
||||
|
||||
for d in defaults:
|
||||
await db.execute(text("""
|
||||
INSERT INTO special_dates (
|
||||
name, pattern_type, fixed_month, fixed_day, nth_week, weekday, month,
|
||||
relative_to_month, relative_to_day, relative_weekday, relative_direction,
|
||||
duration_days, is_recurring, is_active
|
||||
) VALUES (
|
||||
:name, :pattern_type, :fixed_month, :fixed_day, :nth_week, :weekday, :month,
|
||||
:relative_to_month, :relative_to_day, :relative_weekday, :relative_direction,
|
||||
:duration_days, TRUE, TRUE
|
||||
)
|
||||
"""), {
|
||||
"name": d["name"],
|
||||
"pattern_type": d["pattern_type"],
|
||||
"fixed_month": d.get("fixed_month"),
|
||||
"fixed_day": d.get("fixed_day"),
|
||||
"nth_week": d.get("nth_week"),
|
||||
"weekday": d.get("weekday"),
|
||||
"month": d.get("month"),
|
||||
"relative_to_month": d.get("relative_to_month"),
|
||||
"relative_to_day": d.get("relative_to_day"),
|
||||
"relative_weekday": d.get("relative_weekday"),
|
||||
"relative_direction": d.get("relative_direction"),
|
||||
"duration_days": d.get("duration_days", 1)
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
return {"message": f"Seeded {len(defaults)} default special dates"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# HELPER FOR PROPHET INTEGRATION
|
||||
# ============================================
|
||||
|
||||
async def get_special_dates_for_prophet(db: AsyncSession, start_year: int, end_year: int) -> List[dict]:
|
||||
"""
|
||||
Get all special dates resolved for a range of years, formatted for Prophet.
|
||||
Returns list of dicts with 'ds' (date) and 'holiday' (name) columns.
|
||||
"""
|
||||
await ensure_table_exists(db)
|
||||
|
||||
result = await db.execute(text(
|
||||
"SELECT * FROM special_dates WHERE is_active = TRUE"
|
||||
))
|
||||
rows = result.fetchall()
|
||||
|
||||
prophet_holidays = []
|
||||
for year in range(start_year, end_year + 1):
|
||||
for row in rows:
|
||||
sd = {
|
||||
'pattern_type': row.pattern_type,
|
||||
'fixed_month': row.fixed_month,
|
||||
'fixed_day': row.fixed_day,
|
||||
'nth_week': row.nth_week,
|
||||
'weekday': row.weekday,
|
||||
'month': row.month,
|
||||
'relative_to_month': row.relative_to_month,
|
||||
'relative_to_day': row.relative_to_day,
|
||||
'relative_weekday': row.relative_weekday,
|
||||
'relative_direction': row.relative_direction,
|
||||
'duration_days': row.duration_days,
|
||||
'is_recurring': row.is_recurring,
|
||||
'one_off_year': row.one_off_year
|
||||
}
|
||||
|
||||
dates = resolve_special_date(sd, year)
|
||||
for d in dates:
|
||||
# Prophet expects datetime objects, not date objects
|
||||
prophet_holidays.append({
|
||||
'ds': datetime.combine(d, datetime.min.time()),
|
||||
'holiday': row.name
|
||||
})
|
||||
|
||||
return prophet_holidays
|
||||
888
backend/api/sync.py
Normal file
888
backend/api/sync.py
Normal file
|
|
@ -0,0 +1,888 @@
|
|||
"""
|
||||
Data Sync API endpoints
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from pydantic import BaseModel
|
||||
|
||||
from database import get_db, SyncSessionLocal
|
||||
from auth import get_current_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class BackfillRequest(BaseModel):
|
||||
"""Request model for backfill job"""
|
||||
source: str # 'newbook', 'resos', or 'all'
|
||||
from_date: date
|
||||
to_date: date
|
||||
chunk_months: int = 1 # Process in monthly chunks to avoid timeouts
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_sync_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get last sync times and status for all data sources and sync types.
|
||||
|
||||
Returns dict keyed by "{source}_{sync_type}" (e.g., "newbook_bookings", "newbook_earned_revenue")
|
||||
"""
|
||||
query = """
|
||||
SELECT DISTINCT ON (source, sync_type)
|
||||
source,
|
||||
sync_type,
|
||||
completed_at as last_sync,
|
||||
status as last_status,
|
||||
records_fetched as last_records,
|
||||
records_created as last_created,
|
||||
date_from,
|
||||
date_to
|
||||
FROM sync_log
|
||||
ORDER BY source, sync_type, completed_at DESC
|
||||
"""
|
||||
|
||||
result = await db.execute(text(query))
|
||||
rows = result.fetchall()
|
||||
|
||||
response = {}
|
||||
for row in rows:
|
||||
# Create key like "newbook_bookings" or "newbook_earned_revenue"
|
||||
key = f"{row.source}_{row.sync_type}" if row.sync_type else row.source
|
||||
response[key] = {
|
||||
"source": row.source,
|
||||
"sync_type": row.sync_type,
|
||||
"last_sync": row.last_sync,
|
||||
"status": row.last_status,
|
||||
"records_fetched": row.last_records,
|
||||
"records_created": row.last_created,
|
||||
"date_from": row.date_from,
|
||||
"date_to": row.date_to
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/newbook")
|
||||
async def trigger_newbook_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
full_sync: bool = Query(False, description="If True, fetches all bookings. If False, only fetches since last sync."),
|
||||
from_date: Optional[date] = Query(None, description="Start date for stay period (filters by arrival/stay dates)"),
|
||||
to_date: Optional[date] = Query(None, description="End date for stay period (filters by arrival/stay dates)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger manual Newbook data sync.
|
||||
Runs in background to avoid timeout.
|
||||
|
||||
- full_sync=False (default): Incremental sync - only bookings modified since last successful sync
|
||||
- full_sync=True: Full sync - fetches entire booking database
|
||||
- from_date/to_date: If provided, fetches bookings staying during this period (overrides full_sync)
|
||||
"""
|
||||
from jobs.data_sync import sync_newbook_data
|
||||
|
||||
# Queue background task
|
||||
background_tasks.add_task(
|
||||
sync_newbook_data,
|
||||
full_sync=full_sync,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
triggered_by=f"user:{current_user['username']}"
|
||||
)
|
||||
|
||||
msg = "Newbook sync started in background"
|
||||
if from_date and to_date:
|
||||
msg = f"Newbook sync for {from_date} to {to_date} started in background"
|
||||
elif full_sync:
|
||||
msg = "Newbook full sync started in background"
|
||||
else:
|
||||
msg = "Newbook incremental sync started in background"
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"source": "newbook",
|
||||
"full_sync": full_sync,
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"message": msg
|
||||
}
|
||||
|
||||
|
||||
@router.post("/resos")
|
||||
async def trigger_resos_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
from_date: Optional[date] = Query(None, description="Start date for sync"),
|
||||
to_date: Optional[date] = Query(None, description="End date for sync"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger manual Resos data sync.
|
||||
Runs in background to avoid timeout.
|
||||
"""
|
||||
from jobs.data_sync import sync_resos_data
|
||||
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=7)
|
||||
if to_date is None:
|
||||
to_date = date.today() + timedelta(days=365)
|
||||
|
||||
# Queue background task
|
||||
background_tasks.add_task(
|
||||
sync_resos_data,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
triggered_by=f"user:{current_user['username']}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"source": "resos",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"message": "Resos sync started in background"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/newbook/occupancy-report")
|
||||
async def trigger_occupancy_report_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
from_date: Optional[date] = Query(None, description="Start date for report"),
|
||||
to_date: Optional[date] = Query(None, description="End date for report"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger Newbook occupancy report sync.
|
||||
|
||||
This fetches Newbook's official occupancy report which provides:
|
||||
- Available rooms (accounting for maintenance/offline)
|
||||
- Official occupied room counts
|
||||
- Maintenance/offline room counts
|
||||
- Official revenue figures (gross and net)
|
||||
|
||||
Use this to ensure accurate occupancy % calculations when rooms
|
||||
have been taken offline for maintenance.
|
||||
"""
|
||||
from jobs.data_sync import sync_newbook_occupancy_report
|
||||
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=90)
|
||||
if to_date is None:
|
||||
to_date = date.today() + timedelta(days=30)
|
||||
|
||||
background_tasks.add_task(
|
||||
sync_newbook_occupancy_report,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
triggered_by=f"user:{current_user['username']}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"source": "newbook_occupancy_report",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"message": f"Newbook occupancy report sync started for {from_date} to {to_date}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/newbook/earned-revenue")
|
||||
async def trigger_earned_revenue_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
from_date: Optional[date] = Query(None, description="Start date for revenue"),
|
||||
to_date: Optional[date] = Query(None, description="End date for revenue"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger Newbook earned revenue sync.
|
||||
|
||||
This fetches official financial figures by GL account from Newbook's
|
||||
report_earned_revenue endpoint. Uses accommodation_gl_codes config
|
||||
to identify which GL accounts are room revenue.
|
||||
|
||||
Defaults to last 7 days if no dates specified (catches adjustments).
|
||||
For historical backfill, specify a wider date range.
|
||||
"""
|
||||
from jobs.data_sync import sync_newbook_earned_revenue
|
||||
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=7)
|
||||
if to_date is None:
|
||||
to_date = date.today()
|
||||
|
||||
background_tasks.add_task(
|
||||
sync_newbook_earned_revenue,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
triggered_by=f"user:{current_user['username']}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"source": "newbook_earned_revenue",
|
||||
"from_date": from_date,
|
||||
"to_date": to_date,
|
||||
"message": f"Newbook earned revenue sync started for {from_date} to {to_date}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/full")
|
||||
async def trigger_full_sync(
|
||||
background_tasks: BackgroundTasks,
|
||||
full_sync: bool = Query(False, description="If True, fetches all bookings from both sources"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger full sync from all sources (Newbook bookings, Newbook occupancy report, Resos).
|
||||
"""
|
||||
from jobs.data_sync import run_data_sync
|
||||
|
||||
background_tasks.add_task(
|
||||
run_data_sync,
|
||||
full_sync=full_sync,
|
||||
triggered_by=f"user:{current_user['username']}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"sources": ["newbook", "newbook_occupancy_report", "resos"],
|
||||
"full_sync": full_sync,
|
||||
"message": f"Full {'complete' if full_sync else 'incremental'} sync started in background"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/aggregate")
|
||||
async def trigger_aggregation(
|
||||
background_tasks: BackgroundTasks,
|
||||
source: Optional[str] = Query(None, description="Filter by source: newbook, resos. Leave empty for all."),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Trigger manual aggregation of pending dates.
|
||||
Processes the aggregation queue and updates daily_occupancy/daily_covers tables.
|
||||
"""
|
||||
from jobs.aggregation import run_aggregation
|
||||
|
||||
if source and source not in ['newbook', 'resos']:
|
||||
raise HTTPException(status_code=400, detail="Source must be 'newbook', 'resos', or omitted for all")
|
||||
|
||||
background_tasks.add_task(
|
||||
run_aggregation,
|
||||
source=source
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"source": source or "all",
|
||||
"message": f"Aggregation started for {source or 'all sources'}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/aggregate/requeue")
|
||||
async def requeue_for_aggregation(
|
||||
background_tasks: BackgroundTasks,
|
||||
source: str = Query(..., description="Source to requeue: newbook or resos"),
|
||||
from_date: Optional[date] = Query(None, description="Start date (optional, defaults to all)"),
|
||||
to_date: Optional[date] = Query(None, description="End date (optional, defaults to all)"),
|
||||
run_aggregation_after: bool = Query(True, description="Automatically run aggregation after queuing"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Re-queue dates from raw data for aggregation.
|
||||
|
||||
Useful when you want to re-aggregate existing data (e.g., after changing
|
||||
mappings or fixing bugs in aggregation logic).
|
||||
"""
|
||||
if source not in ['newbook', 'resos']:
|
||||
raise HTTPException(status_code=400, detail="Source must be 'newbook' or 'resos'")
|
||||
|
||||
# Get distinct dates from raw data
|
||||
if source == 'resos':
|
||||
date_query = "SELECT DISTINCT booking_date as date FROM resos_bookings WHERE 1=1"
|
||||
else: # newbook
|
||||
date_query = """
|
||||
SELECT DISTINCT stay_date as date
|
||||
FROM newbook_booking_nights bn
|
||||
JOIN newbook_bookings b ON bn.booking_id = b.id
|
||||
WHERE 1=1
|
||||
"""
|
||||
|
||||
params = {}
|
||||
if from_date:
|
||||
if source == 'resos':
|
||||
date_query += " AND booking_date >= :from_date"
|
||||
else:
|
||||
date_query += " AND stay_date >= :from_date"
|
||||
params["from_date"] = from_date
|
||||
|
||||
if to_date:
|
||||
if source == 'resos':
|
||||
date_query += " AND booking_date <= :to_date"
|
||||
else:
|
||||
date_query += " AND stay_date <= :to_date"
|
||||
params["to_date"] = to_date
|
||||
|
||||
date_query += " ORDER BY date"
|
||||
|
||||
result = await db.execute(text(date_query), params)
|
||||
dates = [row.date for row in result.fetchall()]
|
||||
|
||||
if not dates:
|
||||
return {
|
||||
"status": "no_data",
|
||||
"message": f"No dates found in {source} raw data for the specified range"
|
||||
}
|
||||
|
||||
# Insert dates into queue (delete existing pending entries first, then insert)
|
||||
# Clear any existing pending entries for these dates
|
||||
await db.execute(
|
||||
text("""
|
||||
DELETE FROM aggregation_queue
|
||||
WHERE source = :source
|
||||
AND aggregated_at IS NULL
|
||||
"""),
|
||||
{"source": source}
|
||||
)
|
||||
|
||||
# Insert all dates
|
||||
for d in dates:
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO aggregation_queue (date, source, reason, queued_at)
|
||||
VALUES (:date, :source, 'manual_requeue', NOW())
|
||||
"""),
|
||||
{"date": d, "source": source}
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Optionally trigger aggregation
|
||||
if run_aggregation_after:
|
||||
from jobs.aggregation import run_aggregation as do_aggregation
|
||||
background_tasks.add_task(do_aggregation, source=source)
|
||||
|
||||
return {
|
||||
"status": "queued",
|
||||
"source": source,
|
||||
"dates_queued": len(dates),
|
||||
"date_range": f"{min(dates)} to {max(dates)}",
|
||||
"aggregation_started": run_aggregation_after,
|
||||
"message": f"Queued {len(dates)} dates for {source} aggregation"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/aggregate/status")
|
||||
async def get_aggregation_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get aggregation status summary including pending counts and totals.
|
||||
"""
|
||||
# Get pending counts by source
|
||||
pending_query = """
|
||||
SELECT
|
||||
source,
|
||||
COUNT(*) as pending_count,
|
||||
MIN(date) as earliest_date,
|
||||
MAX(date) as latest_date
|
||||
FROM aggregation_queue
|
||||
WHERE aggregated_at IS NULL
|
||||
GROUP BY source
|
||||
"""
|
||||
result = await db.execute(text(pending_query))
|
||||
pending_rows = result.fetchall()
|
||||
|
||||
pending_by_source = {
|
||||
row.source: {
|
||||
"count": row.pending_count,
|
||||
"earliest": row.earliest_date,
|
||||
"latest": row.latest_date
|
||||
}
|
||||
for row in pending_rows
|
||||
}
|
||||
|
||||
# Get total pending count
|
||||
total_pending_result = await db.execute(
|
||||
text("SELECT COUNT(*) as total FROM aggregation_queue WHERE aggregated_at IS NULL")
|
||||
)
|
||||
total_pending = total_pending_result.fetchone().total
|
||||
|
||||
# Get aggregated totals
|
||||
occupancy_result = await db.execute(
|
||||
text("SELECT COUNT(*) as count, MIN(date) as earliest, MAX(date) as latest FROM daily_occupancy")
|
||||
)
|
||||
occupancy_row = occupancy_result.fetchone()
|
||||
|
||||
covers_result = await db.execute(
|
||||
text("SELECT COUNT(*) as count, MIN(date) as earliest, MAX(date) as latest FROM daily_covers")
|
||||
)
|
||||
covers_row = covers_result.fetchone()
|
||||
|
||||
# Get last aggregation timestamp (from most recent processed queue entry)
|
||||
last_agg_result = await db.execute(
|
||||
text("SELECT MAX(aggregated_at) as last_run FROM aggregation_queue WHERE aggregated_at IS NOT NULL")
|
||||
)
|
||||
last_agg_row = last_agg_result.fetchone()
|
||||
|
||||
return {
|
||||
"pending": {
|
||||
"total": total_pending,
|
||||
"by_source": pending_by_source
|
||||
},
|
||||
"aggregated": {
|
||||
"daily_occupancy": {
|
||||
"count": occupancy_row.count if occupancy_row else 0,
|
||||
"earliest": occupancy_row.earliest if occupancy_row else None,
|
||||
"latest": occupancy_row.latest if occupancy_row else None
|
||||
},
|
||||
"daily_covers": {
|
||||
"count": covers_row.count if covers_row else 0,
|
||||
"earliest": covers_row.earliest if covers_row else None,
|
||||
"latest": covers_row.latest if covers_row else None
|
||||
}
|
||||
},
|
||||
"last_aggregation": last_agg_row.last_run if last_agg_row else None
|
||||
}
|
||||
|
||||
|
||||
@router.get("/aggregate/queue")
|
||||
async def get_aggregation_queue(
|
||||
source: Optional[str] = Query(None, description="Filter by source"),
|
||||
pending_only: bool = Query(True, description="Only show pending (un-aggregated) entries"),
|
||||
limit: int = Query(100, description="Max entries to return"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
View the aggregation queue status.
|
||||
"""
|
||||
query = """
|
||||
SELECT date, source, reason, booking_id, queued_at, aggregated_at
|
||||
FROM aggregation_queue
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = {"limit": limit}
|
||||
|
||||
if source:
|
||||
query += " AND source = :source"
|
||||
params["source"] = source
|
||||
|
||||
if pending_only:
|
||||
query += " AND aggregated_at IS NULL"
|
||||
|
||||
query += " ORDER BY date, source LIMIT :limit"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return {
|
||||
"count": len(rows),
|
||||
"entries": [
|
||||
{
|
||||
"date": row.date,
|
||||
"source": row.source,
|
||||
"reason": row.reason,
|
||||
"booking_id": row.booking_id,
|
||||
"queued_at": row.queued_at,
|
||||
"aggregated_at": row.aggregated_at
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def get_sync_logs(
|
||||
source: Optional[str] = Query(None, description="Filter by source: newbook, resos"),
|
||||
limit: int = Query(20, description="Number of logs to return"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get recent sync logs for monitoring.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
id,
|
||||
sync_type,
|
||||
source,
|
||||
started_at,
|
||||
completed_at,
|
||||
status,
|
||||
records_fetched,
|
||||
records_created,
|
||||
records_updated,
|
||||
date_from,
|
||||
date_to,
|
||||
error_message,
|
||||
triggered_by
|
||||
FROM sync_log
|
||||
"""
|
||||
params = {"limit": limit}
|
||||
|
||||
if source:
|
||||
query += " WHERE source = :source"
|
||||
params["source"] = source
|
||||
|
||||
query += " ORDER BY started_at DESC LIMIT :limit"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": row.id,
|
||||
"sync_type": row.sync_type,
|
||||
"source": row.source,
|
||||
"started_at": row.started_at,
|
||||
"completed_at": row.completed_at,
|
||||
"status": row.status,
|
||||
"records_fetched": row.records_fetched,
|
||||
"records_created": row.records_created,
|
||||
"records_updated": row.records_updated,
|
||||
"date_range": f"{row.date_from} to {row.date_to}" if row.date_from else None,
|
||||
"error_message": row.error_message,
|
||||
"triggered_by": row.triggered_by
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# ============================================
|
||||
# HISTORICAL BACKFILL ENDPOINTS
|
||||
# ============================================
|
||||
|
||||
@router.post("/backfill")
|
||||
async def start_backfill(
|
||||
request: BackfillRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Start a historical data backfill job.
|
||||
|
||||
Processes data in monthly chunks to avoid API timeouts and rate limits.
|
||||
Progress can be monitored via GET /sync/backfill/status/{job_id}
|
||||
"""
|
||||
if request.source not in ['newbook', 'resos', 'all']:
|
||||
raise HTTPException(status_code=400, detail="Source must be 'newbook', 'resos', or 'all'")
|
||||
|
||||
if request.from_date >= request.to_date:
|
||||
raise HTTPException(status_code=400, detail="from_date must be before to_date")
|
||||
|
||||
# Calculate number of chunks
|
||||
total_months = (request.to_date.year - request.from_date.year) * 12 + \
|
||||
(request.to_date.month - request.from_date.month) + 1
|
||||
chunks_total = (total_months + request.chunk_months - 1) // request.chunk_months
|
||||
|
||||
# Create backfill job record
|
||||
job_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO backfill_jobs (
|
||||
job_id, source, from_date, to_date, chunk_months,
|
||||
status, chunks_total, triggered_by, created_at
|
||||
) VALUES (
|
||||
:job_id, :source, :from_date, :to_date, :chunk_months,
|
||||
'pending', :chunks_total, :triggered_by, NOW()
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"job_id": job_id,
|
||||
"source": request.source,
|
||||
"from_date": request.from_date,
|
||||
"to_date": request.to_date,
|
||||
"chunk_months": request.chunk_months,
|
||||
"chunks_total": chunks_total,
|
||||
"triggered_by": f"user:{current_user['username']}"
|
||||
}
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Queue background task
|
||||
background_tasks.add_task(
|
||||
run_backfill_job,
|
||||
job_id=job_id,
|
||||
source=request.source,
|
||||
from_date=request.from_date,
|
||||
to_date=request.to_date,
|
||||
chunk_months=request.chunk_months
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"job_id": job_id,
|
||||
"source": request.source,
|
||||
"from_date": request.from_date,
|
||||
"to_date": request.to_date,
|
||||
"chunk_months": request.chunk_months,
|
||||
"chunks_total": chunks_total,
|
||||
"message": f"Backfill job started. Monitor progress at /sync/backfill/status/{job_id}"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/backfill/status/{job_id}")
|
||||
async def get_backfill_status(
|
||||
job_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get status of a backfill job.
|
||||
"""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
job_id, source, from_date, to_date, chunk_months,
|
||||
status, current_chunk_start, current_chunk_end,
|
||||
chunks_total, chunks_completed, records_total,
|
||||
error_message, started_at, completed_at, triggered_by
|
||||
FROM backfill_jobs
|
||||
WHERE job_id = :job_id
|
||||
"""),
|
||||
{"job_id": job_id}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Backfill job not found")
|
||||
|
||||
progress_pct = 0
|
||||
if row.chunks_total and row.chunks_total > 0:
|
||||
progress_pct = round((row.chunks_completed / row.chunks_total) * 100, 1)
|
||||
|
||||
return {
|
||||
"job_id": row.job_id,
|
||||
"source": row.source,
|
||||
"date_range": f"{row.from_date} to {row.to_date}",
|
||||
"chunk_months": row.chunk_months,
|
||||
"status": row.status,
|
||||
"progress": {
|
||||
"current_chunk": f"{row.current_chunk_start} to {row.current_chunk_end}" if row.current_chunk_start else None,
|
||||
"chunks_completed": row.chunks_completed,
|
||||
"chunks_total": row.chunks_total,
|
||||
"percent_complete": progress_pct,
|
||||
"records_synced": row.records_total
|
||||
},
|
||||
"error_message": row.error_message,
|
||||
"started_at": row.started_at,
|
||||
"completed_at": row.completed_at,
|
||||
"triggered_by": row.triggered_by
|
||||
}
|
||||
|
||||
|
||||
@router.get("/backfill/jobs")
|
||||
async def list_backfill_jobs(
|
||||
status: Optional[str] = Query(None, description="Filter by status: pending, running, completed, failed"),
|
||||
limit: int = Query(20, description="Number of jobs to return"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
List recent backfill jobs.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
job_id, source, from_date, to_date, status,
|
||||
chunks_completed, chunks_total, records_total,
|
||||
started_at, completed_at
|
||||
FROM backfill_jobs
|
||||
"""
|
||||
params = {"limit": limit}
|
||||
|
||||
if status:
|
||||
query += " WHERE status = :status"
|
||||
params["status"] = status
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT :limit"
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"job_id": row.job_id,
|
||||
"source": row.source,
|
||||
"date_range": f"{row.from_date} to {row.to_date}",
|
||||
"status": row.status,
|
||||
"progress": f"{row.chunks_completed}/{row.chunks_total} chunks",
|
||||
"records_synced": row.records_total,
|
||||
"started_at": row.started_at,
|
||||
"completed_at": row.completed_at
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def run_backfill_job(
|
||||
job_id: str,
|
||||
source: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
chunk_months: int
|
||||
):
|
||||
"""
|
||||
Background task to run backfill in chunks.
|
||||
|
||||
For Newbook: Uses modified_since/modified_until to backfill booking data by modification date.
|
||||
For Resos: Uses from_date/to_date to backfill by booking date.
|
||||
"""
|
||||
from jobs.data_sync import sync_resos_data
|
||||
from services.newbook_client import NewbookClient
|
||||
import json
|
||||
|
||||
db = SyncSessionLocal()
|
||||
|
||||
try:
|
||||
# Mark job as running
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE backfill_jobs
|
||||
SET status = 'running', started_at = NOW()
|
||||
WHERE job_id = :job_id
|
||||
"""),
|
||||
{"job_id": job_id}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Process in chunks
|
||||
current_start = from_date
|
||||
chunks_completed = 0
|
||||
total_records = 0
|
||||
|
||||
while current_start <= to_date:
|
||||
# Calculate chunk end date
|
||||
current_end = current_start + relativedelta(months=chunk_months) - timedelta(days=1)
|
||||
if current_end > to_date:
|
||||
current_end = to_date
|
||||
|
||||
logger.info(f"Backfill {job_id}: Processing {current_start} to {current_end}")
|
||||
|
||||
# Update current chunk in job
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE backfill_jobs
|
||||
SET current_chunk_start = :start, current_chunk_end = :end
|
||||
WHERE job_id = :job_id
|
||||
"""),
|
||||
{"job_id": job_id, "start": current_start, "end": current_end}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Sync data for this chunk
|
||||
try:
|
||||
if source in ['newbook', 'all']:
|
||||
# For backfill, do a full sync (no modified_since filter)
|
||||
# This pulls all bookings - the sync job handles deduplication via upserts
|
||||
from jobs.data_sync import sync_newbook_data, sync_newbook_occupancy_report
|
||||
await sync_newbook_data(
|
||||
full_sync=True,
|
||||
triggered_by=f"backfill:{job_id}"
|
||||
)
|
||||
# Also backfill occupancy report for this chunk
|
||||
# This provides available rooms, maintenance, official occupancy figures
|
||||
await sync_newbook_occupancy_report(
|
||||
from_date=current_start,
|
||||
to_date=current_end,
|
||||
triggered_by=f"backfill:{job_id}"
|
||||
)
|
||||
|
||||
# Backfill earned revenue for this chunk (historical dates only)
|
||||
# This provides official financial figures by GL account
|
||||
from jobs.data_sync import sync_newbook_earned_revenue
|
||||
# Only sync earned revenue for historical dates (not future)
|
||||
earned_rev_end = min(current_end, date.today())
|
||||
if current_start <= earned_rev_end:
|
||||
await sync_newbook_earned_revenue(
|
||||
from_date=current_start,
|
||||
to_date=earned_rev_end,
|
||||
triggered_by=f"backfill:{job_id}"
|
||||
)
|
||||
|
||||
if source in ['resos', 'all']:
|
||||
await sync_resos_data(current_start, current_end, f"backfill:{job_id}")
|
||||
|
||||
except Exception as chunk_error:
|
||||
logger.error(f"Backfill chunk error: {chunk_error}")
|
||||
# Continue with next chunk instead of failing entire job
|
||||
|
||||
chunks_completed += 1
|
||||
|
||||
# Get records synced for this chunk from sync_log
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(records_fetched), 0) as total
|
||||
FROM sync_log
|
||||
WHERE triggered_by = :triggered_by
|
||||
"""),
|
||||
{"triggered_by": f"backfill:{job_id}"}
|
||||
)
|
||||
row = result.fetchone()
|
||||
total_records = row.total if row else 0
|
||||
|
||||
# Update progress
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE backfill_jobs
|
||||
SET chunks_completed = :completed, records_total = :records
|
||||
WHERE job_id = :job_id
|
||||
"""),
|
||||
{"job_id": job_id, "completed": chunks_completed, "records": total_records}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Move to next chunk
|
||||
current_start = current_end + timedelta(days=1)
|
||||
|
||||
# For Newbook full sync, we only need to run once (not chunked)
|
||||
if source == 'newbook':
|
||||
break
|
||||
|
||||
# Run aggregation after backfill
|
||||
from jobs.aggregation import run_aggregation
|
||||
await run_aggregation()
|
||||
|
||||
# Mark job as completed
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE backfill_jobs
|
||||
SET status = 'completed', completed_at = NOW()
|
||||
WHERE job_id = :job_id
|
||||
"""),
|
||||
{"job_id": job_id}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Backfill {job_id} completed: {total_records} records synced")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backfill {job_id} failed: {e}")
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE backfill_jobs
|
||||
SET status = 'failed', error_message = :error, completed_at = NOW()
|
||||
WHERE job_id = :job_id
|
||||
"""),
|
||||
{"job_id": job_id, "error": str(e)}
|
||||
)
|
||||
db.commit()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
1426
backend/api/sync_bookings.py
Normal file
1426
backend/api/sync_bookings.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue