651 lines
26 KiB
Python
651 lines
26 KiB
Python
"""
|
||
Direct booking engine API — hotel management, discovery, scrape control, rate data.
|
||
"""
|
||
import json
|
||
import logging
|
||
from datetime import date, timedelta
|
||
from typing import Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
||
from pydantic import BaseModel
|
||
from sqlalchemy import text
|
||
|
||
from database import AsyncSessionLocal
|
||
from auth import get_current_user, require_cap
|
||
from services.direct_profiles import PROFILES, detect_profile, get_profile
|
||
from services.direct_scraper import run_discovery, get_discovery_status, run_scrape
|
||
|
||
router = APIRouter()
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
# ─── Pydantic models ─────────────────────────────────────────────────────────
|
||
|
||
class HotelCreate(BaseModel):
|
||
name: str
|
||
profile_name: str
|
||
params: dict
|
||
|
||
|
||
class HotelUpdate(BaseModel):
|
||
name: Optional[str] = None
|
||
room_labels: Optional[dict] = None
|
||
rate_labels: Optional[dict] = None
|
||
room_order: Optional[list] = None
|
||
benchmark_room: Optional[str] = None
|
||
benchmark_rate: Optional[str] = None
|
||
tier_base_room: Optional[str] = None
|
||
tier_offsets: Optional[dict] = None
|
||
scrape_enabled: Optional[bool] = None
|
||
params: Optional[dict] = None
|
||
|
||
|
||
class DetectRequest(BaseModel):
|
||
url: str
|
||
|
||
|
||
# ─── Benchmark helpers (ported from guestline-monitor) ──────────────────────
|
||
|
||
def _resolve_bench(prices: dict, bench_room: str, offsets: dict):
|
||
"""
|
||
Derive the benchmark room's price from whatever room prices are available.
|
||
`prices` maps room_id -> price on the benchmark rate plan.
|
||
All offsets are relative to tier_base_room; bench = room_price - room_offset + bench_offset.
|
||
Returns (price, is_calculated).
|
||
"""
|
||
if prices.get(bench_room) is not None:
|
||
return float(prices[bench_room]), False
|
||
|
||
bench_offset = offsets.get(bench_room, 0)
|
||
for room_id, room_offset in offsets.items():
|
||
if room_id == bench_room:
|
||
continue
|
||
if prices.get(room_id) is not None:
|
||
return float(prices[room_id]) - room_offset + bench_offset, True
|
||
|
||
for v in prices.values():
|
||
if v is not None:
|
||
return float(v), True
|
||
|
||
return None, False
|
||
|
||
|
||
def _room_stock(rows) -> dict:
|
||
"""room_id -> MAX(availability) ever seen = estimated stock."""
|
||
return {r["room_id"]: r["stock"] for r in rows}
|
||
|
||
|
||
# ─── Profiles ────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/profiles")
|
||
async def list_profiles(user=Depends(get_current_user)):
|
||
require_cap(user, "view_direct_rates")
|
||
return [
|
||
{
|
||
"name": name,
|
||
"label": cls.label,
|
||
"required_params": cls.required_params,
|
||
}
|
||
for name, cls in PROFILES.items()
|
||
]
|
||
|
||
|
||
@router.post("/profiles/detect")
|
||
async def detect_engine(req: DetectRequest, user=Depends(get_current_user)):
|
||
require_cap(user, "manage_hotels")
|
||
result = detect_profile(req.url)
|
||
if not result:
|
||
raise HTTPException(status_code=422, detail="Could not detect booking engine from URL")
|
||
return result
|
||
|
||
|
||
# ─── Hotel CRUD ───────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/hotels")
|
||
async def list_hotels(user=Depends(get_current_user)):
|
||
require_cap(user, "view_direct_rates")
|
||
async with AsyncSessionLocal() as db:
|
||
result = await db.execute(
|
||
text("""SELECT h.id, h.name, h.profile_name, h.params,
|
||
h.room_labels, h.rate_labels, h.room_order,
|
||
h.benchmark_room, h.benchmark_rate, h.tier_base_room, h.tier_offsets,
|
||
h.scrape_enabled, h.last_scraped_at,
|
||
COUNT(DISTINCT r.stay_date) AS scraped_dates,
|
||
MAX(r.scraped_at) AS last_rate_at
|
||
FROM direct_competitor_hotels h
|
||
LEFT JOIN direct_rates r ON r.hotel_id = h.id
|
||
GROUP BY h.id
|
||
ORDER BY h.id""")
|
||
)
|
||
rows = result.mappings().all()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
@router.post("/hotels", status_code=201)
|
||
async def create_hotel(body: HotelCreate, user=Depends(get_current_user)):
|
||
require_cap(user, "manage_hotels")
|
||
if body.profile_name not in PROFILES:
|
||
raise HTTPException(status_code=400, detail=f"Unknown profile: {body.profile_name}")
|
||
async with AsyncSessionLocal() as db:
|
||
result = await db.execute(
|
||
text("""INSERT INTO direct_competitor_hotels (name, profile_name, params)
|
||
VALUES (:name, :profile, :params) RETURNING id"""),
|
||
{"name": body.name, "profile": body.profile_name, "params": json.dumps(body.params)}
|
||
)
|
||
new_id = result.fetchone()[0]
|
||
await db.commit()
|
||
return {"id": new_id, "name": body.name, "profile_name": body.profile_name}
|
||
|
||
|
||
@router.put("/hotels/{hotel_id}")
|
||
async def update_hotel(hotel_id: int, body: HotelUpdate, user=Depends(get_current_user)):
|
||
require_cap(user, "manage_hotels")
|
||
updates = {}
|
||
if body.name is not None: updates["name"] = body.name
|
||
if body.scrape_enabled is not None: updates["scrape_enabled"] = body.scrape_enabled
|
||
if body.benchmark_room is not None: updates["benchmark_room"] = body.benchmark_room
|
||
if body.benchmark_rate is not None: updates["benchmark_rate"] = body.benchmark_rate
|
||
if body.tier_base_room is not None: updates["tier_base_room"] = body.tier_base_room
|
||
if body.params is not None: updates["params"] = json.dumps(body.params)
|
||
if body.room_labels is not None: updates["room_labels"] = json.dumps(body.room_labels)
|
||
if body.rate_labels is not None: updates["rate_labels"] = json.dumps(body.rate_labels)
|
||
if body.room_order is not None: updates["room_order"] = json.dumps(body.room_order)
|
||
if body.tier_offsets is not None: updates["tier_offsets"] = json.dumps(body.tier_offsets)
|
||
|
||
if not updates:
|
||
raise HTTPException(status_code=400, detail="No fields to update")
|
||
|
||
set_clause = ", ".join(f"{k} = :{k}" for k in updates)
|
||
updates["hotel_id"] = hotel_id
|
||
async with AsyncSessionLocal() as db:
|
||
await db.execute(
|
||
text(f"UPDATE direct_competitor_hotels SET {set_clause} WHERE id = :hotel_id"),
|
||
updates
|
||
)
|
||
await db.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
@router.delete("/hotels/{hotel_id}", status_code=204)
|
||
async def delete_hotel(hotel_id: int, user=Depends(get_current_user)):
|
||
require_cap(user, "manage_hotels")
|
||
async with AsyncSessionLocal() as db:
|
||
await db.execute(
|
||
text("DELETE FROM direct_competitor_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
await db.commit()
|
||
|
||
|
||
# ─── Discovery ───────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/hotels/{hotel_id}/discover")
|
||
async def trigger_discovery(hotel_id: int, background_tasks: BackgroundTasks, user=Depends(get_current_user)):
|
||
require_cap(user, "manage_hotels")
|
||
async with AsyncSessionLocal() as db:
|
||
row = await db.execute(
|
||
text("SELECT profile_name, params FROM direct_competitor_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
hotel = row.mappings().fetchone()
|
||
if not hotel:
|
||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||
|
||
background_tasks.add_task(run_discovery, hotel_id, hotel["profile_name"], hotel["params"])
|
||
return {"status": "discovery started", "hotel_id": hotel_id}
|
||
|
||
|
||
@router.get("/hotels/{hotel_id}/discovery-status")
|
||
async def discovery_status(hotel_id: int, user=Depends(get_current_user)):
|
||
require_cap(user, "view_direct_rates")
|
||
return get_discovery_status(hotel_id)
|
||
|
||
|
||
# ─── Manual scrape trigger ────────────────────────────────────────────────────
|
||
|
||
@router.post("/hotels/{hotel_id}/scrape")
|
||
async def trigger_scrape(hotel_id: int, background_tasks: BackgroundTasks, user=Depends(get_current_user)):
|
||
require_cap(user, "manage_scraper")
|
||
async with AsyncSessionLocal() as db:
|
||
row = await db.execute(
|
||
text("SELECT profile_name, params FROM direct_competitor_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
hotel = row.mappings().fetchone()
|
||
if not hotel:
|
||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||
|
||
def _bg():
|
||
run_scrape(hotel_id, hotel["profile_name"], hotel["params"])
|
||
|
||
background_tasks.add_task(_bg)
|
||
return {"status": "scrape started", "hotel_id": hotel_id}
|
||
|
||
|
||
# ─── Rate data ────────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/hotels/{hotel_id}/dates")
|
||
async def hotel_dates(
|
||
hotel_id: int,
|
||
from_date: date = None,
|
||
to_date: date = None,
|
||
user=Depends(get_current_user)
|
||
):
|
||
require_cap(user, "view_direct_rates")
|
||
if from_date is None:
|
||
from_date = date.today()
|
||
if to_date is None:
|
||
to_date = date.today() + timedelta(days=89)
|
||
|
||
async with AsyncSessionLocal() as db:
|
||
# Hotel config for labels/tier offsets
|
||
cfg_row = await db.execute(
|
||
text("SELECT name, room_labels, rate_labels, tier_offsets, benchmark_room, benchmark_rate, tier_base_room FROM direct_competitor_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
hotel = cfg_row.mappings().fetchone()
|
||
if not hotel:
|
||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||
|
||
# Latest snapshot per (date, room, rate)
|
||
rates_result = await db.execute(
|
||
text("""
|
||
SELECT DISTINCT ON (stay_date, room_id, rate_id)
|
||
stay_date, room_id, rate_id, availability,
|
||
price_incl, min_stay_nights, scraped_at
|
||
FROM direct_rates
|
||
WHERE hotel_id = :hid
|
||
AND stay_date BETWEEN :fd AND :td
|
||
ORDER BY stay_date, room_id, rate_id, scraped_at DESC
|
||
"""),
|
||
{"hid": hotel_id, "fd": from_date, "td": to_date}
|
||
)
|
||
rows = rates_result.mappings().all()
|
||
|
||
# Stock = max availability ever seen per room (across all history)
|
||
stock_result = await db.execute(
|
||
text("""SELECT room_id, MAX(availability) AS stock
|
||
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
|
||
{"hid": hotel_id}
|
||
)
|
||
room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
|
||
|
||
bench_room = hotel["benchmark_room"] or ""
|
||
bench_rate = hotel["benchmark_rate"] or ""
|
||
offsets = hotel["tier_offsets"] or {}
|
||
max_rooms = sum(v for v in room_stock.values() if v) or None
|
||
|
||
# Group by date; availability counted once per room (max across its rate plans)
|
||
by_date: dict = {}
|
||
for r in rows:
|
||
d = by_date.setdefault(r["stay_date"], {
|
||
"room_avail": {}, "bench_prices": {}, "prices": [],
|
||
"min_stay": None, "scraped_at": None,
|
||
})
|
||
room_avail = d["room_avail"]
|
||
room_avail[r["room_id"]] = max(room_avail.get(r["room_id"], 0), r["availability"] or 0)
|
||
if r["price_incl"] is not None and (r["availability"] or 0) > 0:
|
||
d["prices"].append(float(r["price_incl"]))
|
||
if r["rate_id"] == bench_rate and r["price_incl"] is not None:
|
||
d["bench_prices"][r["room_id"]] = r["price_incl"]
|
||
if r["min_stay_nights"] and (d["min_stay"] is None or r["min_stay_nights"] > d["min_stay"]):
|
||
d["min_stay"] = r["min_stay_nights"]
|
||
if d["scraped_at"] is None or r["scraped_at"] > d["scraped_at"]:
|
||
d["scraped_at"] = r["scraped_at"]
|
||
|
||
dates = []
|
||
for stay_date in sorted(by_date):
|
||
d = by_date[stay_date]
|
||
total_avail = sum(d["room_avail"].values())
|
||
bench_price, bench_calc = _resolve_bench(d["bench_prices"], bench_room, offsets)
|
||
dates.append({
|
||
"stay_date": stay_date,
|
||
"total_avail": total_avail,
|
||
"cheapest_rate": min(d["prices"]) if d["prices"] else None,
|
||
"bench_rate": round(bench_price, 2) if bench_price is not None else None,
|
||
"bench_calculated": bench_calc,
|
||
"has_availability": total_avail > 0,
|
||
"min_stay_nights": d["min_stay"],
|
||
"has_min_stay": bool(d["min_stay"] and d["min_stay"] > 1),
|
||
"scraped_at": d["scraped_at"],
|
||
})
|
||
|
||
return {
|
||
"hotel_id": hotel_id,
|
||
"hotel_name": hotel["name"],
|
||
"from_date": str(from_date),
|
||
"to_date": str(to_date),
|
||
"max_rooms": max_rooms,
|
||
"room_labels": hotel["room_labels"] or {},
|
||
"rate_labels": hotel["rate_labels"] or {},
|
||
"benchmark_room": bench_room or None,
|
||
"benchmark_rate": bench_rate or None,
|
||
"dates": dates,
|
||
}
|
||
|
||
|
||
@router.get("/hotels/{hotel_id}/date/{rate_date}/rooms")
|
||
async def hotel_date_rooms(
|
||
hotel_id: int,
|
||
rate_date: date,
|
||
user=Depends(get_current_user)
|
||
):
|
||
require_cap(user, "view_direct_rates")
|
||
async with AsyncSessionLocal() as db:
|
||
cfg_row = await db.execute(
|
||
text("SELECT room_labels, rate_labels, room_order, tier_offsets, tier_base_room, benchmark_room, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
hotel = cfg_row.mappings().fetchone()
|
||
if not hotel:
|
||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||
|
||
# Latest snapshot per room/rate for this date
|
||
rooms_result = await db.execute(
|
||
text("""
|
||
SELECT DISTINCT ON (room_id, rate_id)
|
||
room_id, rate_id, availability, price_excl, price_incl,
|
||
currency, min_stay_nights, scraped_at
|
||
FROM direct_rates
|
||
WHERE hotel_id = :hid AND stay_date = :sd
|
||
ORDER BY room_id, rate_id, scraped_at DESC
|
||
"""),
|
||
{"hid": hotel_id, "sd": rate_date}
|
||
)
|
||
rows = [dict(r) for r in rooms_result.mappings().all()]
|
||
|
||
# Stock = max availability ever seen per room
|
||
stock_result = await db.execute(
|
||
text("""SELECT room_id, MAX(availability) AS stock
|
||
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
|
||
{"hid": hotel_id}
|
||
)
|
||
room_stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
|
||
|
||
room_labels = hotel["room_labels"] or {}
|
||
rate_labels = hotel["rate_labels"] or {}
|
||
tier_offsets = hotel["tier_offsets"] or {}
|
||
benchmark_room = hotel["benchmark_room"] or ""
|
||
benchmark_rate = hotel["benchmark_rate"] or ""
|
||
bench_offset = tier_offsets.get(benchmark_room, 0)
|
||
|
||
# Group flat room×rate rows into one entry per room
|
||
rooms: dict = {}
|
||
for r in rows:
|
||
rid = r["room_id"]
|
||
room = rooms.setdefault(rid, {
|
||
"room_id": rid,
|
||
"room_label": room_labels.get(rid, rid),
|
||
"availability": r["availability"],
|
||
"stock": room_stock.get(rid),
|
||
"best_rate": None,
|
||
"bench_rate": None,
|
||
"bench_calculated": False,
|
||
"min_stay_nights": r["min_stay_nights"],
|
||
"unavailable": False,
|
||
"rates": [],
|
||
})
|
||
room["availability"] = max(room["availability"] or 0, r["availability"] or 0)
|
||
if r["price_incl"] is not None:
|
||
p = float(r["price_incl"])
|
||
if room["best_rate"] is None or p < room["best_rate"]:
|
||
room["best_rate"] = p
|
||
if r["min_stay_nights"] and not room["min_stay_nights"]:
|
||
room["min_stay_nights"] = r["min_stay_nights"]
|
||
room["rates"].append({
|
||
"rate_id": r["rate_id"],
|
||
"rate_label": rate_labels.get(r["rate_id"], r["rate_id"]),
|
||
"price_incl": r["price_incl"],
|
||
"price_excl": r["price_excl"],
|
||
"min_stay_nights": r["min_stay_nights"],
|
||
})
|
||
|
||
# Resolve the benchmark room's price today from whatever rooms are present
|
||
bench_prices = {}
|
||
for rid, room in rooms.items():
|
||
p = next((rt["price_incl"] for rt in room["rates"] if rt["rate_id"] == benchmark_rate), None)
|
||
if p is not None:
|
||
bench_prices[rid] = p
|
||
bench_price, _ = _resolve_bench(bench_prices, benchmark_room, tier_offsets)
|
||
|
||
# Per-room benchmark: direct quote if present, else estimate via tier offsets
|
||
for rid, room in rooms.items():
|
||
direct = bench_prices.get(rid)
|
||
room_offset = tier_offsets.get(rid)
|
||
if direct is not None:
|
||
room["bench_rate"] = round(float(direct), 2)
|
||
elif bench_price is not None and room_offset is not None:
|
||
room["bench_rate"] = round(bench_price + (room_offset - bench_offset), 2)
|
||
room["bench_calculated"] = True
|
||
|
||
# Inject known rooms absent from this date's snapshot (no availability at all)
|
||
for rid in set(room_stock) - set(rooms):
|
||
room_offset = tier_offsets.get(rid)
|
||
derived = round(bench_price + (room_offset - bench_offset), 2) \
|
||
if bench_price is not None and room_offset is not None else None
|
||
rooms[rid] = {
|
||
"room_id": rid,
|
||
"room_label": room_labels.get(rid, rid),
|
||
"availability": None,
|
||
"stock": room_stock.get(rid),
|
||
"best_rate": None,
|
||
"bench_rate": derived,
|
||
"bench_calculated": derived is not None,
|
||
"min_stay_nights": None,
|
||
"unavailable": True,
|
||
"rates": [],
|
||
}
|
||
|
||
# Sort by configured room_order, then by stock (largest first)
|
||
room_order = hotel["room_order"] or []
|
||
|
||
def sort_key(r):
|
||
try:
|
||
return (0, room_order.index(r["room_id"]), 0)
|
||
except ValueError:
|
||
return (1, -(r["stock"] or 0), 0)
|
||
|
||
return {
|
||
"hotel_id": hotel_id,
|
||
"date": str(rate_date),
|
||
"bench_price": bench_price,
|
||
"rooms": sorted(rooms.values(), key=sort_key),
|
||
}
|
||
|
||
|
||
# ─── Rate history ─────────────────────────────────────────────────────────────
|
||
|
||
@router.get("/hotels/{hotel_id}/history/{stay_date}")
|
||
async def rate_history(
|
||
hotel_id: int,
|
||
stay_date: date,
|
||
room_id: Optional[str] = None,
|
||
rate_id: Optional[str] = None,
|
||
mode: Optional[str] = None,
|
||
user=Depends(get_current_user)
|
||
):
|
||
"""
|
||
Time series of tracked rates for one stay date, across scrape runs.
|
||
- room_id + rate_id: history for that exact room/rate combo
|
||
- mode=benchmark: benchmark-room price history (tier-resolved)
|
||
- default: total availability + cheapest rate per scrape
|
||
"""
|
||
require_cap(user, "view_direct_rates")
|
||
async with AsyncSessionLocal() as db:
|
||
cfg_row = await db.execute(
|
||
text("SELECT benchmark_room, benchmark_rate, tier_offsets FROM direct_competitor_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
hotel = cfg_row.mappings().fetchone()
|
||
if not hotel:
|
||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||
|
||
if room_id and rate_id:
|
||
result = await db.execute(
|
||
text("""SELECT scraped_at, availability, price_incl
|
||
FROM direct_rates
|
||
WHERE hotel_id = :hid AND stay_date = :sd
|
||
AND room_id = :room AND rate_id = :rate
|
||
ORDER BY scraped_at"""),
|
||
{"hid": hotel_id, "sd": stay_date, "room": room_id, "rate": rate_id}
|
||
)
|
||
return [dict(r) for r in result.mappings().all()]
|
||
|
||
result = await db.execute(
|
||
text("""SELECT scraped_at, room_id, rate_id, availability, price_incl
|
||
FROM direct_rates
|
||
WHERE hotel_id = :hid AND stay_date = :sd
|
||
ORDER BY scraped_at"""),
|
||
{"hid": hotel_id, "sd": stay_date}
|
||
)
|
||
rows = result.mappings().all()
|
||
|
||
bench_room = hotel["benchmark_room"] or ""
|
||
bench_rate = hotel["benchmark_rate"] or ""
|
||
offsets = hotel["tier_offsets"] or {}
|
||
|
||
# Group by scrape timestamp
|
||
by_scrape: dict = {}
|
||
for r in rows:
|
||
s = by_scrape.setdefault(r["scraped_at"], {"room_avail": {}, "bench_prices": {}, "prices": []})
|
||
s["room_avail"][r["room_id"]] = max(s["room_avail"].get(r["room_id"], 0), r["availability"] or 0)
|
||
if r["price_incl"] is not None:
|
||
s["prices"].append(float(r["price_incl"]))
|
||
if r["rate_id"] == bench_rate:
|
||
s["bench_prices"][r["room_id"]] = r["price_incl"]
|
||
|
||
series = []
|
||
for scraped_at in sorted(by_scrape):
|
||
s = by_scrape[scraped_at]
|
||
if mode == "benchmark":
|
||
price, calc = _resolve_bench(s["bench_prices"], bench_room, offsets)
|
||
series.append({
|
||
"scraped_at": scraped_at,
|
||
"price_incl": round(price, 2) if price is not None else None,
|
||
"calculated": calc,
|
||
})
|
||
else:
|
||
series.append({
|
||
"scraped_at": scraped_at,
|
||
"availability": sum(s["room_avail"].values()),
|
||
"price_incl": min(s["prices"]) if s["prices"] else None,
|
||
})
|
||
return series
|
||
|
||
|
||
# ─── Per-room stats ───────────────────────────────────────────────────────────
|
||
|
||
STAT_WINDOWS = {
|
||
"this_week": 7,
|
||
"this_month": 30,
|
||
"next_6mo": 182,
|
||
"next_12mo": 365,
|
||
}
|
||
|
||
|
||
@router.get("/hotels/{hotel_id}/stats")
|
||
async def hotel_stats(hotel_id: int, user=Depends(get_current_user)):
|
||
"""
|
||
Per-room stats on the benchmark rate across time windows: stock (max
|
||
availability ever seen), avg rate, current occupancy vs stock, and
|
||
estimated final occupancy from the equivalent look-back window (past
|
||
dates no longer change, so they reflect completed bookings).
|
||
"""
|
||
require_cap(user, "view_direct_rates")
|
||
today = date.today()
|
||
|
||
async with AsyncSessionLocal() as db:
|
||
cfg_row = await db.execute(
|
||
text("SELECT room_labels, benchmark_rate FROM direct_competitor_hotels WHERE id = :id"),
|
||
{"id": hotel_id}
|
||
)
|
||
hotel = cfg_row.mappings().fetchone()
|
||
if not hotel:
|
||
raise HTTPException(status_code=404, detail="Hotel not found")
|
||
|
||
stock_result = await db.execute(
|
||
text("""SELECT room_id, MAX(availability) AS stock
|
||
FROM direct_rates WHERE hotel_id = :hid GROUP BY room_id"""),
|
||
{"hid": hotel_id}
|
||
)
|
||
stock = {r["room_id"]: r["stock"] for r in stock_result.mappings().all()}
|
||
|
||
bench_rate = hotel["benchmark_rate"] or ""
|
||
result = await db.execute(
|
||
text("""SELECT DISTINCT ON (stay_date, room_id, rate_id)
|
||
stay_date, room_id, rate_id, availability, price_incl
|
||
FROM direct_rates
|
||
WHERE hotel_id = :hid
|
||
AND stay_date BETWEEN :fd AND :td
|
||
ORDER BY stay_date, room_id, rate_id, scraped_at DESC"""),
|
||
{"hid": hotel_id,
|
||
"fd": today - timedelta(days=365), "td": today + timedelta(days=365)}
|
||
)
|
||
rows = result.mappings().all()
|
||
|
||
room_labels = hotel["room_labels"] or {}
|
||
|
||
# One entry per (room, date): prefer the benchmark rate plan when scraped,
|
||
# otherwise fall back to max availability / cheapest price across plans.
|
||
by_room_date: dict = {}
|
||
for r in rows:
|
||
entry = by_room_date.setdefault((r["room_id"], r["stay_date"]), {
|
||
"stay_date": r["stay_date"], "availability": 0, "price_incl": None, "bench": False,
|
||
})
|
||
is_bench = bench_rate and r["rate_id"] == bench_rate
|
||
if is_bench:
|
||
entry.update({
|
||
"availability": r["availability"] or 0,
|
||
"price_incl": r["price_incl"],
|
||
"bench": True,
|
||
})
|
||
elif not entry["bench"]:
|
||
entry["availability"] = max(entry["availability"], r["availability"] or 0)
|
||
if r["price_incl"] is not None and (entry["price_incl"] is None or r["price_incl"] < entry["price_incl"]):
|
||
entry["price_incl"] = r["price_incl"]
|
||
|
||
by_room: dict = {}
|
||
for (room_id, _), entry in by_room_date.items():
|
||
by_room.setdefault(room_id, []).append(entry)
|
||
|
||
result_out = {}
|
||
for room_id, room_stock_val in stock.items():
|
||
room_rows = by_room.get(room_id, [])
|
||
room_stock_safe = room_stock_val or 1
|
||
windows = {}
|
||
for wname, days in STAT_WINDOWS.items():
|
||
future = [r for r in room_rows if today <= r["stay_date"] < today + timedelta(days=days)]
|
||
past = [r for r in room_rows if today - timedelta(days=days) <= r["stay_date"] < today]
|
||
|
||
if not future and not past:
|
||
windows[wname] = None
|
||
continue
|
||
|
||
prices = [float(r["price_incl"]) for r in future if r["price_incl"] is not None]
|
||
avg_price = round(sum(prices) / len(prices), 2) if prices else None
|
||
|
||
avg_occ = None
|
||
if future:
|
||
avg_avail = sum(r["availability"] or 0 for r in future) / len(future)
|
||
avg_occ = round((1 - avg_avail / room_stock_safe) * 100, 1)
|
||
|
||
est_final_occ = None
|
||
if past:
|
||
past_sold = sum(room_stock_safe - (r["availability"] or 0) for r in past)
|
||
est_final_occ = round(past_sold / (len(past) * room_stock_safe) * 100, 1)
|
||
|
||
windows[wname] = {
|
||
"dates": len(future),
|
||
"avg_price": avg_price,
|
||
"avg_occ": avg_occ,
|
||
"past_dates": len(past),
|
||
"est_final_occ": est_final_occ,
|
||
}
|
||
|
||
result_out[room_id] = {
|
||
"stock": room_stock_val,
|
||
"room_label": room_labels.get(room_id, room_id),
|
||
"windows": windows,
|
||
}
|
||
|
||
return result_out
|