diff --git a/backend/api/analysis.py b/backend/api/analysis.py index 547b5a1..f77e401 100644 --- a/backend/api/analysis.py +++ b/backend/api/analysis.py @@ -33,9 +33,9 @@ async def list_analysis_hotels( 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, + 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 @@ -60,54 +60,45 @@ async def analyse_hotel( ): require_cap(user, "rate_analysis") async with AsyncSessionLocal() as db: - # Hotel info + # Hotel must exist hotel_row = await db.execute( - text("SELECT id, name, tier, star_rating, booking_com_url FROM booking_com_hotels WHERE id = :id"), + text("SELECT id FROM booking_com_hotels WHERE id = :id"), {"id": hotel_id} ) - hotel = hotel_row.mappings().fetchone() - if not hotel: + if not hotel_row.fetchone(): from fastapi import HTTPException raise HTTPException(status_code=404, detail="Hotel not found") - # Advance purchase curve + 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 - 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, + (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 - 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 + AND (rate_date - scraped_at::date) >= 0 + GROUP BY days_ahead + ORDER BY days_ahead """), - {"hid": hotel_id, "from_date": from_date, "to_date": to_date} + params ) - advance_purchase_curve = [dict(r) for r in apc_result.mappings().all()] + advance_curve = [dict(r) for r in apc_result.mappings().all()] - # Day-of-week averages (latest scrape per date) + # Day-of-week averages (latest scrape per date), Mon=0 … Sun=6 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 + (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 @@ -120,48 +111,41 @@ async def analyse_hotel( ) latest WHERE availability_status = 'available' AND rate_gross IS NOT NULL - GROUP BY dow, dow_label + GROUP BY dow, dow_name ORDER BY dow """), - {"hid": hotel_id, "from_date": from_date, "to_date": to_date} + params ) - dow_analysis = [dict(r) for r in dow_result.mappings().all()] + dow_breakdown = [dict(r) for r in dow_result.mappings().all()] - # Sold-out pattern by day-of-week - sold_out_result = await db.execute( + # Latest status + rate per stay date (sold-out pattern, peak months) + latest_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 + 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 """), - {"hid": hotel_id, "from_date": from_date, "to_date": to_date} + params ) - sold_out_pattern = [dict(r) for r in sold_out_result.mappings().all()] + 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 summary - strategy = _compute_strategy(advance_purchase_curve, dow_analysis, sold_out_pattern) + strategy = _compute_strategy(advance_curve, dow_breakdown, latest_rows) return { - "hotel": dict(hotel), - "date_range": {"from": str(from_date), "to": str(to_date)}, - "advance_purchase_curve": advance_purchase_curve, - "dow_analysis": dow_analysis, + "strategy": strategy, + "advance_curve": advance_curve, + "dow_breakdown": dow_breakdown, "sold_out_pattern": sold_out_pattern, - "strategy_summary": strategy, } @@ -170,7 +154,7 @@ async def analyse_hotel( @router.get("/hotel/{hotel_id}/timeline") async def rate_timeline( hotel_id: int, - rate_date: date = Query(...), + rate_date: date = Query(..., alias="date"), user=Depends(get_current_user) ): require_cap(user, "rate_analysis") @@ -182,20 +166,25 @@ async def rate_timeline( rate_gross, availability_status, rooms_left, - room_type, - (rate_date - scraped_at::date) AS days_out + 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} ) - rows = result.mappings().all() - return { - "hotel_id": hotel_id, - "rate_date": str(rate_date), - "timeline": [dict(r) for r in rows], - } + 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 ──────────────────────────────────────────── @@ -281,39 +270,60 @@ async def rate_comparison( # ─── 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) +def _compute_strategy(advance_curve: list, dow_breakdown: list, latest_rows: list) -> dict: + """Shape matches the frontend StrategyLabel interface — pcts must never be null.""" - # 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] + 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 - 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 + # 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 + ] - # Strategy label label = "Mixed / insufficient data" - if advance_discount_pct is not None: + if has_advance_data: if advance_discount_pct <= -5: label = "Advance-booking discounter" - elif advance_discount_pct >= 8 and (sold_out_rate_pct or 0) >= 10: + 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" @@ -321,8 +331,9 @@ def _compute_strategy(apc: list, dow: list, sold_out: list) -> dict: 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, - "strategy_label": label, + "peak_months": peak_months, } diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index a2485d9..ef48f9c 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -1247,13 +1247,13 @@ const RateMatrixTab: React.FC = () => { // Price index badge for competitor rows let priceIndexBadge: React.ReactNode = null if (hotel.tier === 'competitor' && rate?.rate_gross && ownRateByDate[d]) { - const idx = Math.round((rate.rate_gross / ownRateByDate[d]!) * 100) - const bg = idx > 105 ? '#dcfce7' : idx < 85 ? '#fee2e2' : idx < 95 ? '#fef3c7' : '#f1f5f9' - const fg = idx > 105 ? '#16a34a' : idx < 85 ? '#dc2626' : idx < 95 ? '#d97706' : '#64748b' + const delta = Math.round((rate.rate_gross / ownRateByDate[d]! - 1) * 100) + const bg = delta > 5 ? '#dcfce7' : delta < -15 ? '#fee2e2' : delta < -5 ? '#fef3c7' : '#f1f5f9' + const fg = delta > 5 ? '#16a34a' : delta < -15 ? '#dc2626' : delta < -5 ? '#d97706' : '#64748b' priceIndexBadge = ( - {idx}% + {delta > 0 ? '+' : ''}{delta}% ) }