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