Fix require_cap TypeError, rewrite /analysis/comparison, add room category sync
- require_cap was a Depends-factory but every call site uses it inline; make it an inline checker (fixes 500 on /analysis/hotels, /direct/*) - /analysis/comparison returned a per-date matrix the frontend never read; return per-hotel aggregates (our/their avg, price index) and default to all active competitors so the Market Comparison table works without params - Room categories were never populated (lost in port): add sites_list fetch to the Newbook client, categories list/sync/toggle endpoints, and a Settings card — without included categories every rates sync exits early Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c9f2dc804c
commit
69c9b16a2b
6 changed files with 5787 additions and 52 deletions
|
|
@ -200,72 +200,81 @@ async def rate_timeline(
|
|||
|
||||
@router.get("/comparison")
|
||||
async def rate_comparison(
|
||||
competitor_ids: str = Query(..., description="Comma-separated hotel IDs to compare"),
|
||||
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")
|
||||
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")
|
||||
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 latest rates
|
||||
# Own hotel avg rate per date (across included categories)
|
||||
own_result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
ncr.rate_date,
|
||||
ncr.gross_rate AS own_rate
|
||||
FROM newbook_current_rates ncr
|
||||
WHERE ncr.rate_date BETWEEN :from_date AND :to_date
|
||||
ORDER BY ncr.rate_date
|
||||
SELECT rate_date, AVG(gross_rate) 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 = {str(r.rate_date): float(r.own_rate) for r in own_result if r.own_rate}
|
||||
own_rates = {r.rate_date: float(r.own_rate) for r in own_result if r.own_rate}
|
||||
|
||||
# Competitor latest rates per date
|
||||
# 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("""
|
||||
SELECT
|
||||
r.hotel_id,
|
||||
h.name AS hotel_name,
|
||||
r.rate_date,
|
||||
r.rate_gross,
|
||||
r.availability_status
|
||||
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, availability_status
|
||||
hotel_id, rate_date, rate_gross
|
||||
FROM booking_com_rates
|
||||
WHERE hotel_id = ANY(:comp_ids)
|
||||
AND rate_date BETWEEN :from_date AND :to_date
|
||||
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
|
||||
ORDER BY r.rate_date, h.display_order
|
||||
WHERE h.is_active = true AND {hotel_filter}
|
||||
ORDER BY h.display_order, h.name
|
||||
"""),
|
||||
{"comp_ids": comp_ids, "from_date": from_date, "to_date": to_date}
|
||||
{"from_date": from_date, "to_date": to_date,
|
||||
**({"comp_ids": comp_ids} if comp_ids else {})}
|
||||
)
|
||||
comp_rows = comp_result.mappings().all()
|
||||
|
||||
# Build per-date rows
|
||||
date_map: dict = {}
|
||||
hotel_names: dict = {}
|
||||
for row in comp_rows:
|
||||
d = str(row["rate_date"])
|
||||
if d not in date_map:
|
||||
date_map[d] = {"date": d, "own_rate": own_rates.get(d)}
|
||||
date_map[d][f"h{row['hotel_id']}"] = float(row["rate_gross"]) if row["rate_gross"] else None
|
||||
date_map[d][f"h{row['hotel_id']}_status"] = row["availability_status"]
|
||||
hotel_names[row["hotel_id"]] = row["hotel_name"]
|
||||
# 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"]])
|
||||
|
||||
return {
|
||||
"hotel_names": hotel_names,
|
||||
"rows": sorted(date_map.values(), key=lambda x: x["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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue