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,12 +200,15 @@ async def rate_timeline(
|
||||||
|
|
||||||
@router.get("/comparison")
|
@router.get("/comparison")
|
||||||
async def rate_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()),
|
from_date: date = Query(default_factory=lambda: date.today()),
|
||||||
to_date: date = Query(default_factory=lambda: date.today() + timedelta(days=29)),
|
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)
|
user=Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
"""Per-hotel market comparison: avg own vs competitor rate over the range."""
|
||||||
require_cap(user, "rate_analysis")
|
require_cap(user, "rate_analysis")
|
||||||
|
comp_ids = None
|
||||||
|
if competitor_ids:
|
||||||
try:
|
try:
|
||||||
comp_ids = [int(x.strip()) for x in competitor_ids.split(",") if x.strip()]
|
comp_ids = [int(x.strip()) for x in competitor_ids.split(",") if x.strip()]
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -213,59 +216,65 @@ async def rate_comparison(
|
||||||
raise HTTPException(status_code=400, detail="competitor_ids must be comma-separated integers")
|
raise HTTPException(status_code=400, detail="competitor_ids must be comma-separated integers")
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
# Own hotel latest rates
|
# Own hotel avg rate per date (across included categories)
|
||||||
own_result = await db.execute(
|
own_result = await db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT
|
SELECT rate_date, AVG(gross_rate) AS own_rate
|
||||||
ncr.rate_date,
|
FROM newbook_current_rates
|
||||||
ncr.gross_rate AS own_rate
|
WHERE rate_date BETWEEN :from_date AND :to_date
|
||||||
FROM newbook_current_rates ncr
|
GROUP BY rate_date
|
||||||
WHERE ncr.rate_date BETWEEN :from_date AND :to_date
|
|
||||||
ORDER BY ncr.rate_date
|
|
||||||
"""),
|
"""),
|
||||||
{"from_date": from_date, "to_date": to_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(
|
comp_result = await db.execute(
|
||||||
text("""
|
text(f"""
|
||||||
SELECT
|
SELECT r.hotel_id, h.name AS hotel_name, r.rate_date, r.rate_gross
|
||||||
r.hotel_id,
|
|
||||||
h.name AS hotel_name,
|
|
||||||
r.rate_date,
|
|
||||||
r.rate_gross,
|
|
||||||
r.availability_status
|
|
||||||
FROM (
|
FROM (
|
||||||
SELECT DISTINCT ON (hotel_id, rate_date)
|
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
|
FROM booking_com_rates
|
||||||
WHERE hotel_id = ANY(:comp_ids)
|
WHERE rate_date BETWEEN :from_date AND :to_date
|
||||||
AND rate_date BETWEEN :from_date AND :to_date
|
|
||||||
ORDER BY hotel_id, rate_date, scraped_at DESC
|
ORDER BY hotel_id, rate_date, scraped_at DESC
|
||||||
) r
|
) r
|
||||||
JOIN booking_com_hotels h ON h.id = r.hotel_id
|
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
|
# Aggregate per hotel, comparing own rates over the same dates
|
||||||
date_map: dict = {}
|
by_hotel: dict = {}
|
||||||
hotel_names: dict = {}
|
for row in comp_result.mappings().all():
|
||||||
for row in comp_rows:
|
entry = by_hotel.setdefault(row["hotel_id"], {
|
||||||
d = str(row["rate_date"])
|
"hotel_id": row["hotel_id"],
|
||||||
if d not in date_map:
|
"hotel_name": row["hotel_name"],
|
||||||
date_map[d] = {"date": d, "own_rate": own_rates.get(d)}
|
"their": [], "ours": [],
|
||||||
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"]
|
if row["rate_gross"]:
|
||||||
hotel_names[row["hotel_id"]] = row["hotel_name"]
|
entry["their"].append(float(row["rate_gross"]))
|
||||||
|
if row["rate_date"] in own_rates:
|
||||||
|
entry["ours"].append(own_rates[row["rate_date"]])
|
||||||
|
|
||||||
return {
|
rows = []
|
||||||
"hotel_names": hotel_names,
|
for entry in by_hotel.values():
|
||||||
"rows": sorted(date_map.values(), key=lambda x: x["date"]),
|
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 ─────────────────────────────────────────────
|
# ─── Strategy computation helper ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,95 @@ class RateMatrixResponse(BaseModel):
|
||||||
date_last_updated: Dict[str, Optional[str]] = {}
|
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
|
# 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", [])
|
return user.get("is_admin", False) or cap in user.get("caps", [])
|
||||||
|
|
||||||
|
|
||||||
def require_cap(cap: str):
|
def require_cap(user: dict, cap: str) -> None:
|
||||||
async def checker(user: dict = Depends(get_current_user)):
|
|
||||||
if not has_cap(user, cap):
|
if not has_cap(user, cap):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Missing capability: {cap}",
|
detail=f"Missing capability: {cap}",
|
||||||
)
|
)
|
||||||
return user
|
|
||||||
return checker
|
|
||||||
|
|
||||||
|
|
||||||
async def get_admin_user(user: dict = Depends(get_current_user)) -> dict:
|
async def get_admin_user(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,39 @@ class NewbookRatesClient:
|
||||||
"region": self.region
|
"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(
|
async def get_category_rates(
|
||||||
self,
|
self,
|
||||||
category_id: str,
|
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 { useState } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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'
|
import api from '../api'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
|
@ -13,6 +13,15 @@ interface SystemConfig {
|
||||||
[key: string]: string | null
|
[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() {
|
export default function Settings() {
|
||||||
const { tab: tabParam } = useParams<{ tab?: string }>()
|
const { tab: tabParam } = useParams<{ tab?: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -167,6 +176,95 @@ function NewbookTab({ config, isLoading, onSave, onSyncNow, saving, syncing }: N
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue