""" 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(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(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, }