- require_cap was a Depends-factory but every call site uses it inline; make it an inline checker (fixes 500 on /analysis/hotels, /direct/*) - /analysis/comparison returned a per-date matrix the frontend never read; return per-hotel aggregates (our/their avg, price index) and default to all active competitors so the Market Comparison table works without params - Room categories were never populated (lost in port): add sites_list fetch to the Newbook client, categories list/sync/toggle endpoints, and a Settings card — without included categories every rates sync exits early Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
326 lines
14 KiB
Python
326 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, h.name, h.tier, h.star_rating, h.review_score,
|
||
h.booking_com_url,
|
||
COUNT(DISTINCT r.rate_date) AS scraped_dates,
|
||
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 info
|
||
hotel_row = await db.execute(
|
||
text("SELECT id, name, tier, star_rating, booking_com_url FROM booking_com_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
hotel = hotel_row.mappings().fetchone()
|
||
if not hotel:
|
||
from fastapi import HTTPException
|
||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||
|
||
# Advance purchase curve
|
||
apc_result = await db.execute(
|
||
text("""
|
||
SELECT
|
||
CASE
|
||
WHEN (rate_date - scraped_at::date) <= 7 THEN '0-7d'
|
||
WHEN (rate_date - scraped_at::date) <= 29 THEN '8-29d'
|
||
WHEN (rate_date - scraped_at::date) <= 89 THEN '30-89d'
|
||
ELSE '90+d'
|
||
END AS lead_bucket,
|
||
ROUND(AVG(rate_gross)::numeric, 2) AS avg_rate,
|
||
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
|
||
GROUP BY lead_bucket
|
||
ORDER BY
|
||
CASE lead_bucket
|
||
WHEN '0-7d' THEN 1
|
||
WHEN '8-29d' THEN 2
|
||
WHEN '30-89d' THEN 3
|
||
ELSE 4
|
||
END
|
||
"""),
|
||
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
|
||
)
|
||
advance_purchase_curve = [dict(r) for r in apc_result.mappings().all()]
|
||
|
||
# Day-of-week averages (latest scrape per date)
|
||
dow_result = await db.execute(
|
||
text("""
|
||
SELECT
|
||
EXTRACT(DOW FROM rate_date)::int AS dow,
|
||
TO_CHAR(rate_date, 'Dy') AS dow_label,
|
||
ROUND(AVG(rate_gross)::numeric, 2) AS avg_rate,
|
||
COUNT(DISTINCT rate_date) AS date_count
|
||
FROM (
|
||
SELECT DISTINCT ON (rate_date)
|
||
rate_date, rate_gross
|
||
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
|
||
ORDER BY rate_date, scraped_at DESC
|
||
) latest
|
||
GROUP BY dow, dow_label
|
||
ORDER BY dow
|
||
"""),
|
||
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
|
||
)
|
||
dow_analysis = [dict(r) for r in dow_result.mappings().all()]
|
||
|
||
# Sold-out pattern by day-of-week
|
||
sold_out_result = await db.execute(
|
||
text("""
|
||
SELECT
|
||
EXTRACT(DOW FROM rate_date)::int AS dow,
|
||
TO_CHAR(rate_date, 'Dy') AS dow_label,
|
||
COUNT(DISTINCT rate_date) AS total_dates,
|
||
COUNT(DISTINCT rate_date) FILTER (
|
||
WHERE availability_status = 'sold_out'
|
||
) AS sold_out_dates
|
||
FROM (
|
||
SELECT DISTINCT ON (rate_date)
|
||
rate_date, 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
|
||
GROUP BY dow, dow_label
|
||
ORDER BY dow
|
||
"""),
|
||
{"hid": hotel_id, "from_date": from_date, "to_date": to_date}
|
||
)
|
||
sold_out_pattern = [dict(r) for r in sold_out_result.mappings().all()]
|
||
|
||
# Strategy summary
|
||
strategy = _compute_strategy(advance_purchase_curve, dow_analysis, sold_out_pattern)
|
||
|
||
return {
|
||
"hotel": dict(hotel),
|
||
"date_range": {"from": str(from_date), "to": str(to_date)},
|
||
"advance_purchase_curve": advance_purchase_curve,
|
||
"dow_analysis": dow_analysis,
|
||
"sold_out_pattern": sold_out_pattern,
|
||
"strategy_summary": strategy,
|
||
}
|
||
|
||
|
||
# ─── Rate timeline for a single date ─────────────────────────────────────────
|
||
|
||
@router.get("/hotel/{hotel_id}/timeline")
|
||
async def rate_timeline(
|
||
hotel_id: int,
|
||
rate_date: date = Query(...),
|
||
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,
|
||
(rate_date - scraped_at::date) AS days_out
|
||
FROM booking_com_rates
|
||
WHERE hotel_id = :hid AND rate_date = :rd
|
||
ORDER BY scraped_at ASC
|
||
"""),
|
||
{"hid": hotel_id, "rd": rate_date}
|
||
)
|
||
rows = result.mappings().all()
|
||
return {
|
||
"hotel_id": hotel_id,
|
||
"rate_date": str(rate_date),
|
||
"timeline": [dict(r) for r in rows],
|
||
}
|
||
|
||
|
||
# ─── 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(gross_rate) 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(apc: list, dow: list, sold_out: list) -> dict:
|
||
# Advance discount — compare 0-7d vs 30-89d
|
||
rates_by_bucket = {r["lead_bucket"]: float(r["avg_rate"]) for r in apc if r.get("avg_rate")}
|
||
advance_discount_pct = None
|
||
if "0-7d" in rates_by_bucket and "30-89d" in rates_by_bucket:
|
||
close_in = rates_by_bucket["0-7d"]
|
||
far_out = rates_by_bucket["30-89d"]
|
||
if far_out > 0:
|
||
# 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(5)+Sat(6) vs Mon(1)–Thu(4)
|
||
rates_by_dow = {r["dow"]: float(r["avg_rate"]) for r in dow if r.get("avg_rate")}
|
||
weekend_premium_pct = None
|
||
weekend_rates = [rates_by_dow[d] for d in [5, 6] if d in rates_by_dow]
|
||
weekday_rates = [rates_by_dow[d] for d in [1, 2, 3, 4] 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
|
||
total_dates = sum(r["total_dates"] for r in sold_out)
|
||
sold_out_dates = sum(r["sold_out_dates"] for r in sold_out)
|
||
sold_out_rate_pct = round(sold_out_dates / total_dates * 100, 1) if total_dates > 0 else None
|
||
|
||
# Strategy label
|
||
label = "Mixed / insufficient data"
|
||
if advance_discount_pct is not None:
|
||
if advance_discount_pct <= -5:
|
||
label = "Advance-booking discounter"
|
||
elif advance_discount_pct >= 8 and (sold_out_rate_pct or 0) >= 10:
|
||
label = "Yield manager (scarcity-driven)"
|
||
elif advance_discount_pct >= 3:
|
||
label = "Flat-rate / hold-firm strategy"
|
||
else:
|
||
label = "Stable pricing"
|
||
|
||
return {
|
||
"advance_discount_pct": advance_discount_pct,
|
||
"weekend_premium_pct": weekend_premium_pct,
|
||
"avg_sold_out_rate_pct": sold_out_rate_pct,
|
||
"strategy_label": label,
|
||
}
|