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
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -52,15 +52,12 @@ def has_cap(user: dict, cap: str) -> bool:
|
|||
return user.get("is_admin", False) or cap in user.get("caps", [])
|
||||
|
||||
|
||||
def require_cap(cap: str):
|
||||
async def checker(user: dict = Depends(get_current_user)):
|
||||
if not has_cap(user, cap):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing capability: {cap}",
|
||||
)
|
||||
return user
|
||||
return checker
|
||||
def require_cap(user: dict, cap: str) -> None:
|
||||
if not has_cap(user, cap):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Missing capability: {cap}",
|
||||
)
|
||||
|
||||
|
||||
async def get_admin_user(user: dict = Depends(get_current_user)) -> dict:
|
||||
|
|
|
|||
|
|
@ -93,6 +93,39 @@ class NewbookRatesClient:
|
|||
"region": self.region
|
||||
}
|
||||
|
||||
async def get_room_categories(self) -> List[Dict]:
|
||||
"""
|
||||
Fetch room categories by grouping the sites_list endpoint.
|
||||
|
||||
Each site includes site_category_id and site_category_name; categories
|
||||
are derived by grouping sites and counting rooms per category.
|
||||
|
||||
Returns:
|
||||
List of dicts with {category_id, category_name, room_count}
|
||||
"""
|
||||
payload = self._get_auth_payload()
|
||||
response = await self.client.post(
|
||||
self._get_url("sites_list"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
response.raise_for_status()
|
||||
sites = response.json().get("data") or []
|
||||
|
||||
categories: Dict[str, Dict] = {}
|
||||
for site in sites:
|
||||
cat_id = str(site.get("site_category_id") or "")
|
||||
if not cat_id:
|
||||
continue
|
||||
entry = categories.setdefault(cat_id, {
|
||||
"category_id": cat_id,
|
||||
"category_name": site.get("site_category_name") or f"Category {cat_id}",
|
||||
"room_count": 0,
|
||||
})
|
||||
entry["room_count"] += 1
|
||||
|
||||
return sorted(categories.values(), key=lambda c: c["category_name"])
|
||||
|
||||
async def get_category_rates(
|
||||
self,
|
||||
category_id: str,
|
||||
|
|
|
|||
5509
frontend/package-lock.json
generated
Normal file
5509
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save, RefreshCw, Database, Clock } from 'lucide-react'
|
||||
import { Save, RefreshCw, Database, Clock, BedDouble } from 'lucide-react'
|
||||
import api from '../api'
|
||||
|
||||
const TABS = [
|
||||
|
|
@ -13,6 +13,15 @@ interface SystemConfig {
|
|||
[key: string]: string | null
|
||||
}
|
||||
|
||||
interface RoomCategory {
|
||||
id: number
|
||||
site_id: string
|
||||
site_name: string
|
||||
room_count: number
|
||||
is_included: boolean
|
||||
display_order: number
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { tab: tabParam } = useParams<{ tab?: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -167,6 +176,95 @@ function NewbookTab({ config, isLoading, onSave, onSyncNow, saving, syncing }: N
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoomCategoriesCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Room Categories ──────────────────────────────────────────────────────────
|
||||
|
||||
function RoomCategoriesCard() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: categories, isLoading } = useQuery<RoomCategory[]>({
|
||||
queryKey: ['room-categories'],
|
||||
queryFn: () => api.get('/bookability/categories').then(r => r.data),
|
||||
})
|
||||
|
||||
const syncCategories = useMutation({
|
||||
mutationFn: () => api.post('/bookability/categories/sync'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
|
||||
})
|
||||
|
||||
const toggleCategory = useMutation({
|
||||
mutationFn: ({ id, included }: { id: number; included: boolean }) =>
|
||||
api.patch(`/bookability/categories/${id}`, { is_included: included }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<BedDouble size={16} strokeWidth={1.75} />
|
||||
Room Categories
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => syncCategories.mutate()}
|
||||
disabled={syncCategories.isPending}
|
||||
>
|
||||
<RefreshCw size={13} strokeWidth={1.75} />
|
||||
{syncCategories.isPending ? 'Fetching…' : 'Fetch from Newbook'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
|
||||
Rate syncs only fetch tariffs for the included categories below. If the list is
|
||||
empty, fetch categories from Newbook first — otherwise syncs will do nothing.
|
||||
</p>
|
||||
{syncCategories.isError && (
|
||||
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>
|
||||
{(syncCategories.error as any)?.response?.data?.detail || 'Category fetch failed'}
|
||||
</p>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="loading-state"><div className="spinner" /> Loading…</div>
|
||||
) : !categories?.length ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--warning)', margin: 0 }}>
|
||||
No room categories yet — click “Fetch from Newbook” to load them.
|
||||
</p>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Included</th>
|
||||
<th>Category</th>
|
||||
<th>Rooms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map(cat => (
|
||||
<tr key={cat.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cat.is_included}
|
||||
onChange={e => toggleCategory.mutate({ id: cat.id, included: e.target.checked })}
|
||||
disabled={toggleCategory.isPending}
|
||||
/>
|
||||
</td>
|
||||
<td>{cat.site_name}</td>
|
||||
<td>{cat.room_count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue