diff --git a/backend/api/bookability.py b/backend/api/bookability.py index a77c536..0713e68 100644 --- a/backend/api/bookability.py +++ b/backend/api/bookability.py @@ -76,7 +76,8 @@ class RoomCategory(BaseModel): class CategoryUpdate(BaseModel): - is_included: bool + is_included: Optional[bool] = None + display_order: Optional[int] = None @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") 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( text(""" - INSERT INTO newbook_room_categories (site_id, site_name, room_count, fetched_at) - VALUES (:site_id, :site_name, :room_count, NOW()) + INSERT INTO newbook_room_categories (site_id, site_name, room_count, display_order, fetched_at) + VALUES (:site_id, :site_name, :room_count, :display_order, 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"]} + {"site_id": cat["category_id"], "site_name": cat["category_name"], + "room_count": cat["room_count"], "display_order": default_order} ) await db.commit() return {"status": "success", "count": len(categories)} @@ -140,10 +148,19 @@ async def update_category( db: AsyncSession = Depends(get_db), 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( - text("UPDATE newbook_room_categories SET is_included = :inc WHERE id = :id RETURNING id"), - {"inc": body.is_included, "id": category_id} + text(f"UPDATE newbook_room_categories SET {', '.join(sets)} WHERE id = :id RETURNING id"), + params ) if not result.fetchone(): 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. """ 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 # TODO: Add support for custom date range and category filter diff --git a/backend/jobs/sync_occupancy.py b/backend/jobs/sync_occupancy.py new file mode 100644 index 0000000..b0adfa1 --- /dev/null +++ b/backend/jobs/sync_occupancy.py @@ -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 diff --git a/backend/scheduler.py b/backend/scheduler.py index f710340..26bde98 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -73,6 +73,11 @@ async def run_scheduled_fetch_current_rates(): # run_fetch_current_rates is a coroutine — await it directly; # run_in_executor would return the coroutine object unawaited 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() else: logger.debug("Newbook current rates sync skipped (disabled)") diff --git a/backend/services/newbook_rates_client.py b/backend/services/newbook_rates_client.py index 80685e1..e6ff616 100644 --- a/backend/services/newbook_rates_client.py +++ b/backend/services/newbook_rates_client.py @@ -129,6 +129,32 @@ class NewbookRatesClient: 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( self, category_id: str, diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index e3adb2c..e31a070 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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, BedDouble } from 'lucide-react' +import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown } from 'lucide-react' import api from '../api' const TABS = [ @@ -203,6 +203,23 @@ function RoomCategoriesCard() { 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 (
@@ -243,10 +260,11 @@ function RoomCategoriesCard() { Included Category Rooms + Order - {categories.map(cat => ( + {categories.map((cat, idx) => ( {cat.site_name} {cat.room_count} + + + + + + ))}