Schema: migrate newbook_occupancy_report_data from single-row upsert to
snapshot model (drop unique constraint, add valid_from / last_verified_at)
matching the pattern used by newbook_current_rates.
Backend: sync_occupancy now inserts a new row only when occupied/available/
maintenance figures change, otherwise bumps last_verified_at. New endpoint
GET /bookability/occupancy-history/{category_id}/{date} returns the timeline.
Rate matrix query updated to DISTINCT ON for the multi-row table.
Frontend: clicking any cell in the Bookability matrix opens a modal with
two stacked Plotly charts — rate history per tariff (step lines, green/red
markers for available/unavailable) and occupancy pick-up over time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
5.5 KiB
Python
141 lines
5.5 KiB
Python
"""
|
|
Occupancy report sync — populates newbook_occupancy_report_data so the
|
|
Bookability matrix can show availability alongside rates.
|
|
|
|
Uses snapshot model (mirrors newbook_current_rates): only inserts a new row
|
|
when occupied/available/maintenance figures change; otherwise bumps
|
|
last_verified_at on the existing row. This lets the history popup show
|
|
how pick-up evolved over time for a given stay date.
|
|
"""
|
|
import logging
|
|
from datetime import date, timedelta
|
|
|
|
from sqlalchemy import text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def _save_occupancy_snapshot(db, category_id, category_name, report_date,
|
|
occupied, available, maintenance, allotted,
|
|
revenue_gross, revenue_net, occupancy_pct):
|
|
"""
|
|
Insert a new occupancy snapshot only when figures have changed;
|
|
otherwise update last_verified_at on the latest row.
|
|
Returns 'inserted' or 'verified'.
|
|
"""
|
|
existing = (await db.execute(
|
|
text("""
|
|
SELECT id, occupied, available, maintenance
|
|
FROM newbook_occupancy_report_data
|
|
WHERE date = :date AND category_id = :category_id
|
|
ORDER BY valid_from DESC
|
|
LIMIT 1
|
|
"""),
|
|
{"date": report_date, "category_id": category_id}
|
|
)).fetchone()
|
|
|
|
changed = (
|
|
existing is None
|
|
or int(existing.occupied or 0) != occupied
|
|
or int(existing.available or 0) != available
|
|
or int(existing.maintenance or 0) != maintenance
|
|
)
|
|
|
|
if changed:
|
|
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,
|
|
valid_from, last_verified_at, fetched_at
|
|
) VALUES (
|
|
:date, :category_id, :category_name,
|
|
:available, :occupied, :maintenance, :allotted,
|
|
:revenue_gross, :revenue_net, :occupancy_pct,
|
|
NOW(), NOW(), 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),
|
|
}
|
|
)
|
|
return 'inserted'
|
|
else:
|
|
await db.execute(
|
|
text("""
|
|
UPDATE newbook_occupancy_report_data
|
|
SET last_verified_at = NOW(), fetched_at = NOW()
|
|
WHERE id = :id
|
|
"""),
|
|
{"id": existing.id}
|
|
)
|
|
return 'verified'
|
|
|
|
|
|
async def run_sync_occupancy(days_ahead: int = 365):
|
|
"""
|
|
Fetch the Newbook occupancy report from today forward and snapshot
|
|
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)
|
|
inserted = 0
|
|
verified = 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
|
|
|
|
result = await _save_occupancy_snapshot(
|
|
db, category_id, category_name, report_date,
|
|
occupied, available, maintenance, allotted,
|
|
revenue_gross, revenue_net, occupancy_pct
|
|
)
|
|
if result == 'inserted':
|
|
inserted += 1
|
|
else:
|
|
verified += 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: {inserted} new snapshots, {verified} unchanged")
|
|
return inserted + verified
|