Add rate snapshot to modal and room-type history chart improvements
Modal now shows two sections:
- Current availability: all room types with rooms-left count, then each rate
plan (meal plan × cancel policy × price) from the latest scrape batch
- Rate history chart: one line per room type, cheapest 2-adult rate per
scrape run (max_persons filter added to exclude 1-adult variants)
New endpoint: GET /competitors/hotels/{id}/rate-snapshot/{date}
Returns all rate plan variants grouped by room type from the latest batch.
Handles legacy single-row data (pre hotel-page scraper) gracefully.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bb37fcf501
commit
5462773dd7
2 changed files with 189 additions and 34 deletions
|
|
@ -1213,7 +1213,8 @@ async def get_hotel_rate_history(
|
|||
):
|
||||
"""Per-room-type best available rate over time for a single stay date.
|
||||
Groups by scrape batch so each x-point is one scrape run, not one row.
|
||||
Returns series: [{room_type, points: [{t, rate}]}]"""
|
||||
Returns series: [{room_type, points: [{t, rate}]}]
|
||||
Filters to 2-adult rates only (max_persons=2 or legacy NULL rows)."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
|
|
@ -1226,6 +1227,7 @@ async def get_hotel_rate_history(
|
|||
AND r.rate_date = :stay_date
|
||||
AND r.rate_gross IS NOT NULL
|
||||
AND r.availability_status = 'available'
|
||||
AND (r.max_persons IS NULL OR r.max_persons = 2)
|
||||
GROUP BY COALESCE(b.started_at, date_trunc('hour', r.scraped_at)), r.room_type
|
||||
ORDER BY scrape_time
|
||||
"""),
|
||||
|
|
@ -1234,7 +1236,7 @@ async def get_hotel_rate_history(
|
|||
|
||||
by_room: dict = {}
|
||||
for row in result.mappings():
|
||||
rt = (row["room_type"] or "Unknown").split("\n")[0].strip()
|
||||
rt = (row["room_type"] or "Best available").split("\n")[0].strip()
|
||||
by_room.setdefault(rt, []).append({
|
||||
"t": row["scrape_time"].isoformat() if row["scrape_time"] else None,
|
||||
"rate": row["best_rate"],
|
||||
|
|
@ -1245,6 +1247,95 @@ async def get_hotel_rate_history(
|
|||
return {"stay_date": str(stay_date), "series": series}
|
||||
|
||||
|
||||
@router.get("/hotels/{hotel_id}/rate-snapshot/{stay_date}")
|
||||
async def get_hotel_rate_snapshot(
|
||||
hotel_id: int,
|
||||
stay_date: date,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""All rate plan variants from the most recent scrape for one hotel+date.
|
||||
Returns rooms: [{room_type, rooms_left, plans: [{meal, cancel, price, max_persons}]}]"""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
r.room_type,
|
||||
r.breakfast_included,
|
||||
r.free_cancellation,
|
||||
r.rate_gross::float AS price,
|
||||
r.rooms_left,
|
||||
r.max_persons,
|
||||
r.availability_status
|
||||
FROM booking_com_rates r
|
||||
WHERE r.hotel_id = :hotel_id
|
||||
AND r.rate_date = :stay_date
|
||||
AND r.scrape_batch_id = (
|
||||
SELECT scrape_batch_id
|
||||
FROM booking_com_rates
|
||||
WHERE hotel_id = :hotel_id AND rate_date = :stay_date
|
||||
AND scrape_batch_id IS NOT NULL
|
||||
ORDER BY scraped_at DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY r.room_type, r.breakfast_included, r.free_cancellation DESC, r.rate_gross
|
||||
"""),
|
||||
{"hotel_id": hotel_id, "stay_date": stay_date},
|
||||
)
|
||||
|
||||
rows = result.mappings().all()
|
||||
|
||||
# If no rate_plan data (old scraper rows), return a simple summary
|
||||
if not rows:
|
||||
return {"stay_date": str(stay_date), "rooms": [], "legacy": True}
|
||||
|
||||
# Check if this is old single-row data (no rate_plan_id breakdown)
|
||||
has_breakdown = any(r["room_type"] for r in rows)
|
||||
|
||||
if not has_breakdown:
|
||||
# Legacy: single row per hotel+date from search results scraper
|
||||
row = rows[0]
|
||||
return {
|
||||
"stay_date": str(stay_date),
|
||||
"legacy": True,
|
||||
"rooms": [{
|
||||
"room_type": "Best available",
|
||||
"rooms_left": row["rooms_left"],
|
||||
"availability_status": row["availability_status"],
|
||||
"plans": [{
|
||||
"meal": "B&B" if row["breakfast_included"] else "Room only",
|
||||
"cancel": "Free cancellation" if row["free_cancellation"] else "Non-refundable",
|
||||
"price": row["price"],
|
||||
"max_persons": row["max_persons"],
|
||||
}] if row["price"] else [],
|
||||
}],
|
||||
}
|
||||
|
||||
# Group by room_type, keeping rooms_left from the first occurrence (same per room)
|
||||
rooms_map: dict = {}
|
||||
for r in rows:
|
||||
rt = (r["room_type"] or "Unknown").strip()
|
||||
if rt not in rooms_map:
|
||||
rooms_map[rt] = {
|
||||
"room_type": rt,
|
||||
"rooms_left": r["rooms_left"],
|
||||
"availability_status": r["availability_status"],
|
||||
"plans": [],
|
||||
}
|
||||
if r["price"] and (r["max_persons"] is None or r["max_persons"] == 2):
|
||||
rooms_map[rt]["plans"].append({
|
||||
"meal": "B&B" if r["breakfast_included"] else "Room only",
|
||||
"cancel": "Free cancellation" if r["free_cancellation"] else "Non-refundable",
|
||||
"price": r["price"],
|
||||
"max_persons": r["max_persons"],
|
||||
})
|
||||
|
||||
return {
|
||||
"stay_date": str(stay_date),
|
||||
"legacy": False,
|
||||
"rooms": list(rooms_map.values()),
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# SCRAPE HISTORY
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue