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 ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -62,6 +62,95 @@ class RateMatrixResponse(BaseModel):
|
|||
date_last_updated: Dict[str, Optional[str]] = {}
|
||||
|
||||
|
||||
# ============================================
|
||||
# ROOM CATEGORIES
|
||||
# ============================================
|
||||
|
||||
class RoomCategory(BaseModel):
|
||||
id: int
|
||||
site_id: str
|
||||
site_name: str
|
||||
room_count: int
|
||||
is_included: bool
|
||||
display_order: int
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
is_included: bool
|
||||
|
||||
|
||||
@router.get("/categories", response_model=List[RoomCategory])
|
||||
async def list_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""List Newbook room categories and their include-in-sync status."""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id, site_id, site_name, room_count, is_included, display_order
|
||||
FROM newbook_room_categories
|
||||
ORDER BY display_order, site_name
|
||||
""")
|
||||
)
|
||||
return [dict(r) for r in result.mappings().all()]
|
||||
|
||||
|
||||
@router.post("/categories/sync")
|
||||
async def sync_categories(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Fetch room categories from the Newbook API (sites_list) and upsert them.
|
||||
New categories default to included; existing include flags are preserved.
|
||||
"""
|
||||
from services.newbook_rates_client import NewbookRatesClient
|
||||
|
||||
client = await NewbookRatesClient.from_db(db)
|
||||
try:
|
||||
async with client:
|
||||
categories = await client.get_room_categories()
|
||||
except Exception as e:
|
||||
logger.error(f"Category sync failed: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"Newbook category fetch failed: {str(e)}")
|
||||
|
||||
if not categories:
|
||||
raise HTTPException(status_code=502, detail="Newbook returned no room categories — check credentials in Settings")
|
||||
|
||||
for cat in categories:
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO newbook_room_categories (site_id, site_name, room_count, fetched_at)
|
||||
VALUES (:site_id, :site_name, :room_count, NOW())
|
||||
ON CONFLICT (site_id) DO UPDATE SET
|
||||
site_name = EXCLUDED.site_name,
|
||||
room_count = EXCLUDED.room_count,
|
||||
fetched_at = NOW()
|
||||
"""),
|
||||
{"site_id": cat["category_id"], "site_name": cat["category_name"], "room_count": cat["room_count"]}
|
||||
)
|
||||
await db.commit()
|
||||
return {"status": "success", "count": len(categories)}
|
||||
|
||||
|
||||
@router.patch("/categories/{category_id}")
|
||||
async def update_category(
|
||||
category_id: int,
|
||||
body: CategoryUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""Toggle whether a category is included in rate syncs."""
|
||||
result = await db.execute(
|
||||
text("UPDATE newbook_room_categories SET is_included = :inc WHERE id = :id RETURNING id"),
|
||||
{"inc": body.is_included, "id": category_id}
|
||||
)
|
||||
if not result.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
await db.commit()
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATE MATRIX ENDPOINT
|
||||
# ============================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue