- /analysis/hotel/{id}: lead_bucket alias in ORDER BY CASE broke Postgres; replaced
bucketed curve with per-days_ahead curve and reshaped response to the frontend
HotelAnalysis interface (strategy/advance_curve/dow_breakdown/sold_out_pattern)
- /analysis/hotel/{id}/timeline: accept ?date= (was rate_date, 422) and return
flat TimelineEntry array
- /analysis/hotels: alias to hotel_id/hotel_name/date_count for the selector
- strategy pcts default 0 (frontend calls .toFixed), added peak_months
- Market View badge now shows +/-% vs our rate instead of 100-index
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
339 lines
14 KiB
Python
339 lines
14 KiB
Python
"""
|
|
Rate Analysis API — advance purchase curves, DOW analysis, rate timelines, strategy summary
|
|
"""
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import text
|
|
from typing import Optional
|
|
from datetime import date, timedelta
|
|
|
|
from database import AsyncSessionLocal
|
|
from auth import get_current_user, require_cap
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
async def get_db():
|
|
async with AsyncSessionLocal() as db:
|
|
yield db
|
|
|
|
|
|
# ─── Hotels available for analysis ───────────────────────────────────────────
|
|
|
|
@router.get("/hotels")
|
|
async def list_analysis_hotels(
|
|
tier: Optional[str] = Query(None, description="Filter by tier: own|competitor|market"),
|
|
user=Depends(get_current_user)
|
|
):
|
|
require_cap(user, "rate_analysis")
|
|
async with AsyncSessionLocal() as db:
|
|
where = "is_active = true"
|
|
params = {}
|
|
if tier:
|
|
where += " AND tier = :tier"
|
|
params["tier"] = tier
|
|
result = await db.execute(
|
|
text(f"""
|
|
SELECT h.id AS hotel_id, h.name AS hotel_name, h.tier,
|
|
h.star_rating, h.review_score, h.booking_com_url,
|
|
COUNT(DISTINCT r.rate_date) AS date_count,
|
|
MAX(r.scraped_at) AS last_scraped
|
|
FROM booking_com_hotels h
|
|
LEFT JOIN booking_com_rates r ON r.hotel_id = h.id
|
|
WHERE {where}
|
|
GROUP BY h.id, h.name, h.tier, h.star_rating, h.review_score, h.booking_com_url
|
|
ORDER BY h.tier, h.display_order, h.name
|
|
"""),
|
|
params
|
|
)
|
|
rows = result.mappings().all()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
# ─── Full analysis for one hotel ─────────────────────────────────────────────
|
|
|
|
@router.get("/hotel/{hotel_id}")
|
|
async def analyse_hotel(
|
|
hotel_id: int,
|
|
from_date: date = Query(default_factory=lambda: date.today()),
|
|
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=89)),
|
|
user=Depends(get_current_user)
|
|
):
|
|
require_cap(user, "rate_analysis")
|
|
async with AsyncSessionLocal() as db:
|
|
# Hotel must exist
|
|
hotel_row = await db.execute(
|
|
text("SELECT id FROM booking_com_hotels WHERE id = :id"),
|
|
{"id": hotel_id}
|
|
)
|
|
if not hotel_row.fetchone():
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=404, detail="Hotel not found")
|
|
|
|
params = {"hid": hotel_id, "from_date": from_date, "to_date": to_date}
|
|
|
|
# Advance purchase curve — avg price per days-ahead-of-stay
|
|
apc_result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
(rate_date - scraped_at::date) AS days_ahead,
|
|
ROUND(AVG(rate_gross)::numeric, 2) AS avg_price,
|
|
COUNT(*) AS sample_count
|
|
FROM booking_com_rates
|
|
WHERE hotel_id = :hid
|
|
AND rate_date BETWEEN :from_date AND :to_date
|
|
AND availability_status = 'available'
|
|
AND rate_gross IS NOT NULL
|
|
AND (rate_date - scraped_at::date) >= 0
|
|
GROUP BY days_ahead
|
|
ORDER BY days_ahead
|
|
"""),
|
|
params
|
|
)
|
|
advance_curve = [dict(r) for r in apc_result.mappings().all()]
|
|
|
|
# Day-of-week averages (latest scrape per date), Mon=0 … Sun=6
|
|
dow_result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
(EXTRACT(ISODOW FROM rate_date)::int - 1) AS dow,
|
|
TO_CHAR(rate_date, 'Dy') AS dow_name,
|
|
ROUND(AVG(rate_gross)::numeric, 2) AS avg_price,
|
|
COUNT(DISTINCT rate_date) AS count
|
|
FROM (
|
|
-- Latest row per date first, THEN filter: a 'not_listed'
|
|
-- latest row must drop the date, not resurface an older rate
|
|
SELECT DISTINCT ON (rate_date)
|
|
rate_date, rate_gross, availability_status
|
|
FROM booking_com_rates
|
|
WHERE hotel_id = :hid
|
|
AND rate_date BETWEEN :from_date AND :to_date
|
|
ORDER BY rate_date, scraped_at DESC
|
|
) latest
|
|
WHERE availability_status = 'available'
|
|
AND rate_gross IS NOT NULL
|
|
GROUP BY dow, dow_name
|
|
ORDER BY dow
|
|
"""),
|
|
params
|
|
)
|
|
dow_breakdown = [dict(r) for r in dow_result.mappings().all()]
|
|
|
|
# Latest status + rate per stay date (sold-out pattern, peak months)
|
|
latest_result = await db.execute(
|
|
text("""
|
|
SELECT DISTINCT ON (rate_date)
|
|
rate_date, rate_gross, availability_status
|
|
FROM booking_com_rates
|
|
WHERE hotel_id = :hid
|
|
AND rate_date BETWEEN :from_date AND :to_date
|
|
ORDER BY rate_date, scraped_at DESC
|
|
"""),
|
|
params
|
|
)
|
|
latest_rows = latest_result.mappings().all()
|
|
sold_out_pattern = [
|
|
{
|
|
"stay_date": str(r["rate_date"]),
|
|
"sold_out_pct": 100.0 if r["availability_status"] == "sold_out" else 0.0,
|
|
}
|
|
for r in latest_rows
|
|
]
|
|
|
|
strategy = _compute_strategy(advance_curve, dow_breakdown, latest_rows)
|
|
|
|
return {
|
|
"strategy": strategy,
|
|
"advance_curve": advance_curve,
|
|
"dow_breakdown": dow_breakdown,
|
|
"sold_out_pattern": sold_out_pattern,
|
|
}
|
|
|
|
|
|
# ─── Rate timeline for a single date ─────────────────────────────────────────
|
|
|
|
@router.get("/hotel/{hotel_id}/timeline")
|
|
async def rate_timeline(
|
|
hotel_id: int,
|
|
rate_date: date = Query(..., alias="date"),
|
|
user=Depends(get_current_user)
|
|
):
|
|
require_cap(user, "rate_analysis")
|
|
async with AsyncSessionLocal() as db:
|
|
result = await db.execute(
|
|
text("""
|
|
SELECT
|
|
scraped_at,
|
|
rate_gross,
|
|
availability_status,
|
|
rooms_left,
|
|
room_type
|
|
FROM booking_com_rates
|
|
WHERE hotel_id = :hid AND rate_date = :rd
|
|
ORDER BY scraped_at ASC
|
|
"""),
|
|
{"hid": hotel_id, "rd": rate_date}
|
|
)
|
|
return [
|
|
{
|
|
"scraped_at": r["scraped_at"].isoformat() if r["scraped_at"] else None,
|
|
"room_id": r["room_type"] or "cheapest",
|
|
"rate_id": "",
|
|
"room_label": r["room_type"] or "Cheapest rate",
|
|
"rate_label": r["availability_status"],
|
|
"price_incl": float(r["rate_gross"]) if r["rate_gross"] is not None else None,
|
|
"availability": r["rooms_left"] or 0,
|
|
}
|
|
for r in result.mappings().all()
|
|
]
|
|
|
|
|
|
# ─── Own vs Competitor comparison ────────────────────────────────────────────
|
|
|
|
@router.get("/comparison")
|
|
async def rate_comparison(
|
|
from_date: date = Query(default_factory=lambda: date.today()),
|
|
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=29)),
|
|
competitor_ids: Optional[str] = Query(None, description="Comma-separated hotel IDs; defaults to all active competitors"),
|
|
user=Depends(get_current_user)
|
|
):
|
|
"""Per-hotel market comparison: avg own vs competitor rate over the range."""
|
|
require_cap(user, "rate_analysis")
|
|
comp_ids = None
|
|
if competitor_ids:
|
|
try:
|
|
comp_ids = [int(x.strip()) for x in competitor_ids.split(",") if x.strip()]
|
|
except ValueError:
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=400, detail="competitor_ids must be comma-separated integers")
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
# Own hotel avg rate per date (across included categories)
|
|
own_result = await db.execute(
|
|
text("""
|
|
SELECT rate_date, AVG(rate_gross) AS own_rate
|
|
FROM newbook_current_rates
|
|
WHERE rate_date BETWEEN :from_date AND :to_date
|
|
GROUP BY rate_date
|
|
"""),
|
|
{"from_date": from_date, "to_date": to_date}
|
|
)
|
|
own_rates = {r.rate_date: float(r.own_rate) for r in own_result if r.own_rate}
|
|
|
|
# Competitor latest rate per date
|
|
hotel_filter = "h.id = ANY(:comp_ids)" if comp_ids else "h.tier = 'competitor'"
|
|
comp_result = await db.execute(
|
|
text(f"""
|
|
SELECT r.hotel_id, h.name AS hotel_name, r.rate_date, r.rate_gross
|
|
FROM (
|
|
SELECT DISTINCT ON (hotel_id, rate_date)
|
|
hotel_id, rate_date, rate_gross
|
|
FROM booking_com_rates
|
|
WHERE rate_date BETWEEN :from_date AND :to_date
|
|
ORDER BY hotel_id, rate_date, scraped_at DESC
|
|
) r
|
|
JOIN booking_com_hotels h ON h.id = r.hotel_id
|
|
WHERE h.is_active = true AND {hotel_filter}
|
|
ORDER BY h.display_order, h.name
|
|
"""),
|
|
{"from_date": from_date, "to_date": to_date,
|
|
**({"comp_ids": comp_ids} if comp_ids else {})}
|
|
)
|
|
|
|
# Aggregate per hotel, comparing own rates over the same dates
|
|
by_hotel: dict = {}
|
|
for row in comp_result.mappings().all():
|
|
entry = by_hotel.setdefault(row["hotel_id"], {
|
|
"hotel_id": row["hotel_id"],
|
|
"hotel_name": row["hotel_name"],
|
|
"their": [], "ours": [],
|
|
})
|
|
if row["rate_gross"]:
|
|
entry["their"].append(float(row["rate_gross"]))
|
|
if row["rate_date"] in own_rates:
|
|
entry["ours"].append(own_rates[row["rate_date"]])
|
|
|
|
rows = []
|
|
for entry in by_hotel.values():
|
|
their_rate = round(sum(entry["their"]) / len(entry["their"]), 2) if entry["their"] else None
|
|
our_rate = round(sum(entry["ours"]) / len(entry["ours"]), 2) if entry["ours"] else None
|
|
price_index = round(their_rate / our_rate * 100, 1) if their_rate and our_rate else None
|
|
rows.append({
|
|
"hotel_id": entry["hotel_id"],
|
|
"hotel_name": entry["hotel_name"],
|
|
"our_rate": our_rate,
|
|
"their_rate": their_rate,
|
|
"price_index": price_index,
|
|
"days_checked": len(entry["their"]),
|
|
})
|
|
return rows
|
|
|
|
|
|
# ─── Strategy computation helper ─────────────────────────────────────────────
|
|
|
|
def _compute_strategy(advance_curve: list, dow_breakdown: list, latest_rows: list) -> dict:
|
|
"""Shape matches the frontend StrategyLabel interface — pcts must never be null."""
|
|
|
|
def _weighted_avg(points):
|
|
total = sum(p["sample_count"] for p in points)
|
|
if not total:
|
|
return None
|
|
return sum(float(p["avg_price"]) * p["sample_count"] for p in points) / total
|
|
|
|
# Advance discount — close-in (0-7d) vs far-out (30d+)
|
|
close_in = _weighted_avg([p for p in advance_curve if p["days_ahead"] <= 7])
|
|
far_out = _weighted_avg([p for p in advance_curve if p["days_ahead"] >= 30])
|
|
advance_discount_pct = 0.0
|
|
has_advance_data = close_in is not None and far_out is not None and far_out > 0
|
|
if has_advance_data:
|
|
# Positive = closes in higher (scarcity premium); negative = discount for advance
|
|
advance_discount_pct = round((close_in - far_out) / far_out * 100, 1)
|
|
|
|
# Weekend premium — Fri(4)-Sun(6) vs Mon(0)-Thu(3), dow is Mon=0 … Sun=6
|
|
rates_by_dow = {r["dow"]: float(r["avg_price"]) for r in dow_breakdown if r.get("avg_price")}
|
|
weekend_premium_pct = 0.0
|
|
weekend_rates = [rates_by_dow[d] for d in [4, 5, 6] if d in rates_by_dow]
|
|
weekday_rates = [rates_by_dow[d] for d in [0, 1, 2, 3] if d in rates_by_dow]
|
|
if weekend_rates and weekday_rates:
|
|
avg_wk = sum(weekend_rates) / len(weekend_rates)
|
|
avg_wd = sum(weekday_rates) / len(weekday_rates)
|
|
if avg_wd > 0:
|
|
weekend_premium_pct = round((avg_wk - avg_wd) / avg_wd * 100, 1)
|
|
|
|
# Sold-out rate — % of stay dates whose latest status is sold_out
|
|
sold_out_rate_pct = 0.0
|
|
if latest_rows:
|
|
sold_out_dates = sum(1 for r in latest_rows if r["availability_status"] == "sold_out")
|
|
sold_out_rate_pct = round(sold_out_dates / len(latest_rows) * 100, 1)
|
|
|
|
# Peak months — months averaging >10% above the overall average
|
|
monthly: dict = {}
|
|
for r in latest_rows:
|
|
if r["availability_status"] == "available" and r["rate_gross"] is not None:
|
|
key = (r["rate_date"].month, r["rate_date"].strftime("%b"))
|
|
monthly.setdefault(key, []).append(float(r["rate_gross"]))
|
|
peak_months = []
|
|
if monthly:
|
|
overall = sum(sum(v) for v in monthly.values()) / sum(len(v) for v in monthly.values())
|
|
peak_months = [
|
|
label for (num, label), v in sorted(monthly.items())
|
|
if sum(v) / len(v) > overall * 1.10
|
|
]
|
|
|
|
label = "Mixed / insufficient data"
|
|
if has_advance_data:
|
|
if advance_discount_pct <= -5:
|
|
label = "Advance-booking discounter"
|
|
elif advance_discount_pct >= 8 and sold_out_rate_pct >= 10:
|
|
label = "Yield manager (scarcity-driven)"
|
|
elif advance_discount_pct >= 3:
|
|
label = "Flat-rate / hold-firm strategy"
|
|
else:
|
|
label = "Stable pricing"
|
|
|
|
return {
|
|
"label": label,
|
|
"advance_discount_pct": advance_discount_pct,
|
|
"weekend_premium_pct": weekend_premium_pct,
|
|
"avg_sold_out_rate_pct": sold_out_rate_pct,
|
|
"peak_months": peak_months,
|
|
}
|