Rate Analysis: fix hotel detail 500 + timeline 422, match frontend shapes; signed % delta badge

- /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>
This commit is contained in:
jtricerolph 2026-07-05 15:01:57 +00:00
parent ea9484e3fa
commit 349236cd7e
2 changed files with 108 additions and 97 deletions

View file

@ -33,9 +33,9 @@ async def list_analysis_hotels(
params["tier"] = tier params["tier"] = tier
result = await db.execute( result = await db.execute(
text(f""" text(f"""
SELECT h.id, h.name, h.tier, h.star_rating, h.review_score, SELECT h.id AS hotel_id, h.name AS hotel_name, h.tier,
h.booking_com_url, h.star_rating, h.review_score, h.booking_com_url,
COUNT(DISTINCT r.rate_date) AS scraped_dates, COUNT(DISTINCT r.rate_date) AS date_count,
MAX(r.scraped_at) AS last_scraped MAX(r.scraped_at) AS last_scraped
FROM booking_com_hotels h FROM booking_com_hotels h
LEFT JOIN booking_com_rates r ON r.hotel_id = h.id 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") require_cap(user, "rate_analysis")
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
# Hotel info # Hotel must exist
hotel_row = await db.execute( 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} {"id": hotel_id}
) )
hotel = hotel_row.mappings().fetchone() if not hotel_row.fetchone():
if not hotel:
from fastapi import HTTPException from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Hotel not found") 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( apc_result = await db.execute(
text(""" text("""
SELECT SELECT
CASE (rate_date - scraped_at::date) AS days_ahead,
WHEN (rate_date - scraped_at::date) <= 7 THEN '0-7d' ROUND(AVG(rate_gross)::numeric, 2) AS avg_price,
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 COUNT(*) AS sample_count
FROM booking_com_rates FROM booking_com_rates
WHERE hotel_id = :hid WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date AND rate_date BETWEEN :from_date AND :to_date
AND availability_status = 'available' AND availability_status = 'available'
AND rate_gross IS NOT NULL AND rate_gross IS NOT NULL
GROUP BY lead_bucket AND (rate_date - scraped_at::date) >= 0
ORDER BY GROUP BY days_ahead
CASE lead_bucket ORDER BY days_ahead
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} 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( dow_result = await db.execute(
text(""" text("""
SELECT SELECT
EXTRACT(DOW FROM rate_date)::int AS dow, (EXTRACT(ISODOW FROM rate_date)::int - 1) AS dow,
TO_CHAR(rate_date, 'Dy') AS dow_label, TO_CHAR(rate_date, 'Dy') AS dow_name,
ROUND(AVG(rate_gross)::numeric, 2) AS avg_rate, ROUND(AVG(rate_gross)::numeric, 2) AS avg_price,
COUNT(DISTINCT rate_date) AS date_count COUNT(DISTINCT rate_date) AS count
FROM ( FROM (
-- Latest row per date first, THEN filter: a 'not_listed' -- Latest row per date first, THEN filter: a 'not_listed'
-- latest row must drop the date, not resurface an older rate -- latest row must drop the date, not resurface an older rate
@ -120,48 +111,41 @@ async def analyse_hotel(
) latest ) latest
WHERE availability_status = 'available' WHERE availability_status = 'available'
AND rate_gross IS NOT NULL AND rate_gross IS NOT NULL
GROUP BY dow, dow_label GROUP BY dow, dow_name
ORDER BY dow 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 # Latest status + rate per stay date (sold-out pattern, peak months)
sold_out_result = await db.execute( latest_result = await db.execute(
text(""" 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) SELECT DISTINCT ON (rate_date)
rate_date, availability_status rate_date, rate_gross, availability_status
FROM booking_com_rates FROM booking_com_rates
WHERE hotel_id = :hid WHERE hotel_id = :hid
AND rate_date BETWEEN :from_date AND :to_date AND rate_date BETWEEN :from_date AND :to_date
ORDER BY rate_date, scraped_at DESC 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} 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_curve, dow_breakdown, latest_rows)
strategy = _compute_strategy(advance_purchase_curve, dow_analysis, sold_out_pattern)
return { return {
"hotel": dict(hotel), "strategy": strategy,
"date_range": {"from": str(from_date), "to": str(to_date)}, "advance_curve": advance_curve,
"advance_purchase_curve": advance_purchase_curve, "dow_breakdown": dow_breakdown,
"dow_analysis": dow_analysis,
"sold_out_pattern": sold_out_pattern, "sold_out_pattern": sold_out_pattern,
"strategy_summary": strategy,
} }
@ -170,7 +154,7 @@ async def analyse_hotel(
@router.get("/hotel/{hotel_id}/timeline") @router.get("/hotel/{hotel_id}/timeline")
async def rate_timeline( async def rate_timeline(
hotel_id: int, hotel_id: int,
rate_date: date = Query(...), rate_date: date = Query(..., alias="date"),
user=Depends(get_current_user) user=Depends(get_current_user)
): ):
require_cap(user, "rate_analysis") require_cap(user, "rate_analysis")
@ -182,20 +166,25 @@ async def rate_timeline(
rate_gross, rate_gross,
availability_status, availability_status,
rooms_left, rooms_left,
room_type, room_type
(rate_date - scraped_at::date) AS days_out
FROM booking_com_rates FROM booking_com_rates
WHERE hotel_id = :hid AND rate_date = :rd WHERE hotel_id = :hid AND rate_date = :rd
ORDER BY scraped_at ASC ORDER BY scraped_at ASC
"""), """),
{"hid": hotel_id, "rd": rate_date} {"hid": hotel_id, "rd": rate_date}
) )
rows = result.mappings().all() return [
return { {
"hotel_id": hotel_id, "scraped_at": r["scraped_at"].isoformat() if r["scraped_at"] else None,
"rate_date": str(rate_date), "room_id": r["room_type"] or "cheapest",
"timeline": [dict(r) for r in rows], "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 ──────────────────────────────────────────── # ─── Own vs Competitor comparison ────────────────────────────────────────────
@ -281,39 +270,60 @@ async def rate_comparison(
# ─── Strategy computation helper ───────────────────────────────────────────── # ─── Strategy computation helper ─────────────────────────────────────────────
def _compute_strategy(apc: list, dow: list, sold_out: list) -> dict: def _compute_strategy(advance_curve: list, dow_breakdown: list, latest_rows: list) -> dict:
# Advance discount — compare 0-7d vs 30-89d """Shape matches the frontend StrategyLabel interface — pcts must never be null."""
rates_by_bucket = {r["lead_bucket"]: float(r["avg_rate"]) for r in apc if r.get("avg_rate")}
advance_discount_pct = None def _weighted_avg(points):
if "0-7d" in rates_by_bucket and "30-89d" in rates_by_bucket: total = sum(p["sample_count"] for p in points)
close_in = rates_by_bucket["0-7d"] if not total:
far_out = rates_by_bucket["30-89d"] return None
if far_out > 0: 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 # Positive = closes in higher (scarcity premium); negative = discount for advance
advance_discount_pct = round((close_in - far_out) / far_out * 100, 1) advance_discount_pct = round((close_in - far_out) / far_out * 100, 1)
# Weekend premium — Fri(5)+Sat(6) vs Mon(1)Thu(4) # 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_rate"]) for r in dow if r.get("avg_rate")} rates_by_dow = {r["dow"]: float(r["avg_price"]) for r in dow_breakdown if r.get("avg_price")}
weekend_premium_pct = None weekend_premium_pct = 0.0
weekend_rates = [rates_by_dow[d] for d in [5, 6] if d in rates_by_dow] 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 [1, 2, 3, 4] 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: if weekend_rates and weekday_rates:
avg_wk = sum(weekend_rates) / len(weekend_rates) avg_wk = sum(weekend_rates) / len(weekend_rates)
avg_wd = sum(weekday_rates) / len(weekday_rates) avg_wd = sum(weekday_rates) / len(weekday_rates)
if avg_wd > 0: if avg_wd > 0:
weekend_premium_pct = round((avg_wk - avg_wd) / avg_wd * 100, 1) weekend_premium_pct = round((avg_wk - avg_wd) / avg_wd * 100, 1)
# Sold-out rate # Sold-out rate — % of stay dates whose latest status is sold_out
total_dates = sum(r["total_dates"] for r in sold_out) sold_out_rate_pct = 0.0
sold_out_dates = sum(r["sold_out_dates"] for r in sold_out) if latest_rows:
sold_out_rate_pct = round(sold_out_dates / total_dates * 100, 1) if total_dates > 0 else None 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" label = "Mixed / insufficient data"
if advance_discount_pct is not None: if has_advance_data:
if advance_discount_pct <= -5: if advance_discount_pct <= -5:
label = "Advance-booking discounter" 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)" label = "Yield manager (scarcity-driven)"
elif advance_discount_pct >= 3: elif advance_discount_pct >= 3:
label = "Flat-rate / hold-firm strategy" label = "Flat-rate / hold-firm strategy"
@ -321,8 +331,9 @@ def _compute_strategy(apc: list, dow: list, sold_out: list) -> dict:
label = "Stable pricing" label = "Stable pricing"
return { return {
"label": label,
"advance_discount_pct": advance_discount_pct, "advance_discount_pct": advance_discount_pct,
"weekend_premium_pct": weekend_premium_pct, "weekend_premium_pct": weekend_premium_pct,
"avg_sold_out_rate_pct": sold_out_rate_pct, "avg_sold_out_rate_pct": sold_out_rate_pct,
"strategy_label": label, "peak_months": peak_months,
} }

View file

@ -1247,13 +1247,13 @@ const RateMatrixTab: React.FC = () => {
// Price index badge for competitor rows // Price index badge for competitor rows
let priceIndexBadge: React.ReactNode = null let priceIndexBadge: React.ReactNode = null
if (hotel.tier === 'competitor' && rate?.rate_gross && ownRateByDate[d]) { if (hotel.tier === 'competitor' && rate?.rate_gross && ownRateByDate[d]) {
const idx = Math.round((rate.rate_gross / ownRateByDate[d]!) * 100) const delta = Math.round((rate.rate_gross / ownRateByDate[d]! - 1) * 100)
const bg = idx > 105 ? '#dcfce7' : idx < 85 ? '#fee2e2' : idx < 95 ? '#fef3c7' : '#f1f5f9' const bg = delta > 5 ? '#dcfce7' : delta < -15 ? '#fee2e2' : delta < -5 ? '#fef3c7' : '#f1f5f9'
const fg = idx > 105 ? '#16a34a' : idx < 85 ? '#dc2626' : idx < 95 ? '#d97706' : '#64748b' const fg = delta > 5 ? '#16a34a' : delta < -15 ? '#dc2626' : delta < -5 ? '#d97706' : '#64748b'
priceIndexBadge = ( priceIndexBadge = (
<span style={{ display: 'block', fontSize: 9, fontWeight: 700, color: fg, background: bg, <span style={{ display: 'block', fontSize: 9, fontWeight: 700, color: fg, background: bg,
borderRadius: 4, padding: '0 3px', lineHeight: '14px', marginTop: 1 }}> borderRadius: 4, padding: '0 3px', lineHeight: '14px', marginTop: 1 }}>
{idx}% {delta > 0 ? '+' : ''}{delta}%
</span> </span>
) )
} }