Port occupancy sync + category ordering from the forecasting version
- Bookability showed no availability because newbook_occupancy_report_data was never populated: add reports_occupancy client method and sync_occupancy job, run before rates in both Sync Now and the daily schedule (single fast API call) - Category order: default display_order to the Newbook category id on sync (was 0 → alphabetical), preserve manual order on re-sync, extend PATCH to accept display_order, add up/down reorder arrows in Settings Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c924d793e8
commit
f51e1dcb76
5 changed files with 199 additions and 9 deletions
|
|
@ -76,7 +76,8 @@ class RoomCategory(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class CategoryUpdate(BaseModel):
|
class CategoryUpdate(BaseModel):
|
||||||
is_included: bool
|
is_included: Optional[bool] = None
|
||||||
|
display_order: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/categories", response_model=List[RoomCategory])
|
@router.get("/categories", response_model=List[RoomCategory])
|
||||||
|
|
@ -118,16 +119,23 @@ async def sync_categories(
|
||||||
raise HTTPException(status_code=502, detail="Newbook returned no room categories — check credentials in Settings")
|
raise HTTPException(status_code=502, detail="Newbook returned no room categories — check credentials in Settings")
|
||||||
|
|
||||||
for cat in categories:
|
for cat in categories:
|
||||||
|
# New categories default to Newbook's category id order; a manually
|
||||||
|
# set display_order is preserved on re-sync
|
||||||
|
try:
|
||||||
|
default_order = int(cat["category_id"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
default_order = 0
|
||||||
await db.execute(
|
await db.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO newbook_room_categories (site_id, site_name, room_count, fetched_at)
|
INSERT INTO newbook_room_categories (site_id, site_name, room_count, display_order, fetched_at)
|
||||||
VALUES (:site_id, :site_name, :room_count, NOW())
|
VALUES (:site_id, :site_name, :room_count, :display_order, NOW())
|
||||||
ON CONFLICT (site_id) DO UPDATE SET
|
ON CONFLICT (site_id) DO UPDATE SET
|
||||||
site_name = EXCLUDED.site_name,
|
site_name = EXCLUDED.site_name,
|
||||||
room_count = EXCLUDED.room_count,
|
room_count = EXCLUDED.room_count,
|
||||||
fetched_at = NOW()
|
fetched_at = NOW()
|
||||||
"""),
|
"""),
|
||||||
{"site_id": cat["category_id"], "site_name": cat["category_name"], "room_count": cat["room_count"]}
|
{"site_id": cat["category_id"], "site_name": cat["category_name"],
|
||||||
|
"room_count": cat["room_count"], "display_order": default_order}
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"status": "success", "count": len(categories)}
|
return {"status": "success", "count": len(categories)}
|
||||||
|
|
@ -140,10 +148,19 @@ async def update_category(
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""Toggle whether a category is included in rate syncs."""
|
"""Update a category's include-in-sync flag and/or display order."""
|
||||||
|
sets, params = [], {"id": category_id}
|
||||||
|
if body.is_included is not None:
|
||||||
|
sets.append("is_included = :inc")
|
||||||
|
params["inc"] = body.is_included
|
||||||
|
if body.display_order is not None:
|
||||||
|
sets.append("display_order = :ord")
|
||||||
|
params["ord"] = body.display_order
|
||||||
|
if not sets:
|
||||||
|
raise HTTPException(status_code=400, detail="Nothing to update")
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text("UPDATE newbook_room_categories SET is_included = :inc WHERE id = :id RETURNING id"),
|
text(f"UPDATE newbook_room_categories SET {', '.join(sets)} WHERE id = :id RETURNING id"),
|
||||||
{"inc": body.is_included, "id": category_id}
|
params
|
||||||
)
|
)
|
||||||
if not result.fetchone():
|
if not result.fetchone():
|
||||||
raise HTTPException(status_code=404, detail="Category not found")
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
@ -564,6 +581,14 @@ async def refresh_rates(
|
||||||
Note: This can be slow as it respects Newbook API rate limits.
|
Note: This can be slow as it respects Newbook API rate limits.
|
||||||
"""
|
"""
|
||||||
from jobs.fetch_current_rates import run_fetch_current_rates
|
from jobs.fetch_current_rates import run_fetch_current_rates
|
||||||
|
from jobs.sync_occupancy import run_sync_occupancy
|
||||||
|
|
||||||
|
# Occupancy first — a single fast API call that gives the matrix
|
||||||
|
# availability data even while the (slow) rates fetch is running
|
||||||
|
try:
|
||||||
|
await run_sync_occupancy()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Occupancy sync failed (continuing with rates): {e}")
|
||||||
|
|
||||||
# For now, just run the standard fetch
|
# For now, just run the standard fetch
|
||||||
# TODO: Add support for custom date range and category filter
|
# TODO: Add support for custom date range and category filter
|
||||||
|
|
|
||||||
94
backend/jobs/sync_occupancy.py
Normal file
94
backend/jobs/sync_occupancy.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""
|
||||||
|
Occupancy report sync — populates newbook_occupancy_report_data so the
|
||||||
|
Bookability matrix can show availability alongside rates.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_sync_occupancy(days_ahead: int = 365):
|
||||||
|
"""
|
||||||
|
Fetch the Newbook occupancy report from today forward and upsert
|
||||||
|
per-category, per-date availability into newbook_occupancy_report_data.
|
||||||
|
"""
|
||||||
|
from database import AsyncSessionLocal
|
||||||
|
from services.newbook_rates_client import NewbookRatesClient
|
||||||
|
|
||||||
|
from_date = date.today()
|
||||||
|
to_date = from_date + timedelta(days=days_ahead)
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
client = await NewbookRatesClient.from_db(db)
|
||||||
|
async with client:
|
||||||
|
report = await client.get_occupancy_report(from_date, to_date)
|
||||||
|
|
||||||
|
logger.info(f"Occupancy report: {len(report)} categories, {from_date} to {to_date}")
|
||||||
|
vat = float(client.vat_rate or 0)
|
||||||
|
records = 0
|
||||||
|
|
||||||
|
for category in report:
|
||||||
|
category_id = str(category.get("category_id") or "")
|
||||||
|
category_name = category.get("category_name") or ""
|
||||||
|
if not category_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for date_str, day in (category.get("occupancy") or {}).items():
|
||||||
|
try:
|
||||||
|
report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str
|
||||||
|
available = int(day.get("available", 0) or 0)
|
||||||
|
occupied = int(day.get("occupied", 0) or 0)
|
||||||
|
maintenance = int(day.get("maintenance", 0) or 0)
|
||||||
|
allotted = int(day.get("allotted", 0) or 0)
|
||||||
|
revenue_gross = float(day.get("revenue_gross", 0) or 0)
|
||||||
|
revenue_net = day.get("revenue_net")
|
||||||
|
if revenue_net is None:
|
||||||
|
revenue_net = revenue_gross / (1 + vat) if vat else revenue_gross
|
||||||
|
occupancy_pct = (occupied / available * 100) if available > 0 else 0
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO newbook_occupancy_report_data (
|
||||||
|
date, category_id, category_name,
|
||||||
|
available, occupied, maintenance, allotted,
|
||||||
|
revenue_gross, revenue_net, occupancy_pct, fetched_at
|
||||||
|
) VALUES (
|
||||||
|
:date, :category_id, :category_name,
|
||||||
|
:available, :occupied, :maintenance, :allotted,
|
||||||
|
:revenue_gross, :revenue_net, :occupancy_pct, NOW()
|
||||||
|
)
|
||||||
|
ON CONFLICT (date, category_id) DO UPDATE SET
|
||||||
|
category_name = EXCLUDED.category_name,
|
||||||
|
available = EXCLUDED.available,
|
||||||
|
occupied = EXCLUDED.occupied,
|
||||||
|
maintenance = EXCLUDED.maintenance,
|
||||||
|
allotted = EXCLUDED.allotted,
|
||||||
|
revenue_gross = EXCLUDED.revenue_gross,
|
||||||
|
revenue_net = EXCLUDED.revenue_net,
|
||||||
|
occupancy_pct = EXCLUDED.occupancy_pct,
|
||||||
|
fetched_at = NOW()
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"date": report_date,
|
||||||
|
"category_id": category_id,
|
||||||
|
"category_name": category_name,
|
||||||
|
"available": available,
|
||||||
|
"occupied": occupied,
|
||||||
|
"maintenance": maintenance,
|
||||||
|
"allotted": allotted,
|
||||||
|
"revenue_gross": round(revenue_gross, 2),
|
||||||
|
"revenue_net": round(revenue_net, 2),
|
||||||
|
"occupancy_pct": round(occupancy_pct, 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
records += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Skipping occupancy row {category_id}/{date_str}: {e}")
|
||||||
|
await db.rollback()
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
logger.info(f"Occupancy sync complete: {records} records upserted")
|
||||||
|
return records
|
||||||
|
|
@ -73,6 +73,11 @@ async def run_scheduled_fetch_current_rates():
|
||||||
# run_fetch_current_rates is a coroutine — await it directly;
|
# run_fetch_current_rates is a coroutine — await it directly;
|
||||||
# run_in_executor would return the coroutine object unawaited
|
# run_in_executor would return the coroutine object unawaited
|
||||||
from jobs.fetch_current_rates import run_fetch_current_rates
|
from jobs.fetch_current_rates import run_fetch_current_rates
|
||||||
|
from jobs.sync_occupancy import run_sync_occupancy
|
||||||
|
try:
|
||||||
|
await run_sync_occupancy()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Occupancy sync failed (continuing with rates): {e}")
|
||||||
await run_fetch_current_rates()
|
await run_fetch_current_rates()
|
||||||
else:
|
else:
|
||||||
logger.debug("Newbook current rates sync skipped (disabled)")
|
logger.debug("Newbook current rates sync skipped (disabled)")
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,32 @@ class NewbookRatesClient:
|
||||||
|
|
||||||
return sorted(categories.values(), key=lambda c: c["category_name"])
|
return sorted(categories.values(), key=lambda c: c["category_name"])
|
||||||
|
|
||||||
|
async def get_occupancy_report(self, from_date: date, to_date: date) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Fetch the occupancy report (reports_occupancy) for a date range.
|
||||||
|
|
||||||
|
Returns a list of category objects, each with nested per-date
|
||||||
|
occupancy: {category_id, category_name, occupancy: {date: {available,
|
||||||
|
occupied, maintenance, allotted, revenue_gross, revenue_net}}}.
|
||||||
|
Single response, no pagination.
|
||||||
|
"""
|
||||||
|
payload = self._get_auth_payload()
|
||||||
|
payload.update({
|
||||||
|
"period_from": f"{from_date.isoformat()} 00:00:00",
|
||||||
|
"period_to": f"{to_date.isoformat()} 23:59:59",
|
||||||
|
})
|
||||||
|
|
||||||
|
response = await self.client.post(
|
||||||
|
self._get_url("reports_occupancy"),
|
||||||
|
json=payload,
|
||||||
|
auth=(self.username, self.password)
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if not data.get("success"):
|
||||||
|
raise NewbookRatesError(f"Newbook occupancy report failed: {data.get('message')}")
|
||||||
|
return data.get("data") or []
|
||||||
|
|
||||||
async def get_category_rates(
|
async def get_category_rates(
|
||||||
self,
|
self,
|
||||||
category_id: str,
|
category_id: str,
|
||||||
|
|
|
||||||
|
|
@ -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, BedDouble } from 'lucide-react'
|
import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import api from '../api'
|
import api from '../api'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
|
|
@ -203,6 +203,23 @@ function RoomCategoriesCard() {
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const reorderCategories = useMutation({
|
||||||
|
mutationFn: (ordered: RoomCategory[]) =>
|
||||||
|
Promise.all(ordered.map((cat, idx) =>
|
||||||
|
api.patch(`/bookability/categories/${cat.id}`, { display_order: (idx + 1) * 10 })
|
||||||
|
)),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const moveCategory = (idx: number, dir: -1 | 1) => {
|
||||||
|
if (!categories) return
|
||||||
|
const target = idx + dir
|
||||||
|
if (target < 0 || target >= categories.length) return
|
||||||
|
const reordered = [...categories]
|
||||||
|
;[reordered[idx], reordered[target]] = [reordered[target], reordered[idx]]
|
||||||
|
reorderCategories.mutate(reordered)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
|
|
@ -243,10 +260,11 @@ function RoomCategoriesCard() {
|
||||||
<th>Included</th>
|
<th>Included</th>
|
||||||
<th>Category</th>
|
<th>Category</th>
|
||||||
<th>Rooms</th>
|
<th>Rooms</th>
|
||||||
|
<th>Order</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{categories.map(cat => (
|
{categories.map((cat, idx) => (
|
||||||
<tr key={cat.id}>
|
<tr key={cat.id}>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
|
@ -258,6 +276,28 @@ function RoomCategoriesCard() {
|
||||||
</td>
|
</td>
|
||||||
<td>{cat.site_name}</td>
|
<td>{cat.site_name}</td>
|
||||||
<td>{cat.room_count}</td>
|
<td>{cat.room_count}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
style={{ padding: '2px 6px' }}
|
||||||
|
onClick={() => moveCategory(idx, -1)}
|
||||||
|
disabled={idx === 0 || reorderCategories.isPending}
|
||||||
|
title="Move up"
|
||||||
|
>
|
||||||
|
<ChevronUp size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
style={{ padding: '2px 6px' }}
|
||||||
|
onClick={() => moveCategory(idx, 1)}
|
||||||
|
disabled={idx === categories.length - 1 || reorderCategories.isPending}
|
||||||
|
title="Move down"
|
||||||
|
>
|
||||||
|
<ChevronDown size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue