Add occupancy pick-up history + bookability history popup
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>
This commit is contained in:
parent
aef7d755f1
commit
2743b0e877
4 changed files with 372 additions and 52 deletions
|
|
@ -259,9 +259,10 @@ async def get_rate_matrix(
|
|||
rates_result = await db.execute(text(rates_query), rates_params)
|
||||
rates_rows = rates_result.fetchall()
|
||||
|
||||
# Fetch occupancy data from newbook_occupancy_report_data
|
||||
# Fetch occupancy data — latest snapshot per category/date
|
||||
occupancy_query = """
|
||||
SELECT category_id, date, occupied, available, maintenance
|
||||
SELECT DISTINCT ON (category_id, date)
|
||||
category_id, date, occupied, available, maintenance
|
||||
FROM newbook_occupancy_report_data
|
||||
WHERE date >= :from_date AND date <= :to_date
|
||||
"""
|
||||
|
|
@ -271,6 +272,8 @@ async def get_rate_matrix(
|
|||
occupancy_query += " AND category_id = :category_id"
|
||||
occupancy_params["category_id"] = category_id
|
||||
|
||||
occupancy_query += " ORDER BY category_id, date, valid_from DESC"
|
||||
|
||||
occupancy_result = await db.execute(text(occupancy_query), occupancy_params)
|
||||
occupancy_rows = occupancy_result.fetchall()
|
||||
|
||||
|
|
@ -728,6 +731,56 @@ def _refresh_date_sync(rate_date: date):
|
|||
db.close()
|
||||
|
||||
|
||||
# ============================================
|
||||
# OCCUPANCY HISTORY ENDPOINT
|
||||
# ============================================
|
||||
|
||||
@router.get("/occupancy-history/{category_id}/{rate_date}")
|
||||
async def get_occupancy_history(
|
||||
category_id: str,
|
||||
rate_date: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Get occupancy snapshot history for a specific category and stay date.
|
||||
|
||||
Returns all snapshots where occupied/available/maintenance changed,
|
||||
ordered oldest-first so the frontend can build a pick-up timeline.
|
||||
"""
|
||||
try:
|
||||
target_date = date.fromisoformat(rate_date)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT valid_from, last_verified_at, occupied, available, maintenance, occupancy_pct
|
||||
FROM newbook_occupancy_report_data
|
||||
WHERE date = :date AND category_id = :category_id
|
||||
ORDER BY valid_from ASC
|
||||
"""),
|
||||
{"date": target_date, "category_id": category_id}
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
return {
|
||||
"category_id": category_id,
|
||||
"rate_date": rate_date,
|
||||
"snapshots": [
|
||||
{
|
||||
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
|
||||
"last_verified_at": row.last_verified_at.isoformat() if row.last_verified_at else None,
|
||||
"occupied": row.occupied,
|
||||
"available": row.available,
|
||||
"maintenance": row.maintenance,
|
||||
"occupancy_pct": float(row.occupancy_pct) if row.occupancy_pct is not None else None,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/refresh-date/{rate_date}")
|
||||
async def refresh_single_date(
|
||||
rate_date: str,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
"""
|
||||
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
|
||||
|
|
@ -10,9 +15,76 @@ 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 upsert
|
||||
Fetch the Newbook occupancy report from today forward and snapshot
|
||||
per-category, per-date availability into newbook_occupancy_report_data.
|
||||
"""
|
||||
from database import AsyncSessionLocal
|
||||
|
|
@ -28,7 +100,8 @@ async def run_sync_occupancy(days_ahead: int = 365):
|
|||
|
||||
logger.info(f"Occupancy report: {len(report)} categories, {from_date} to {to_date}")
|
||||
vat = float(client.vat_rate or 0)
|
||||
records = 0
|
||||
inserted = 0
|
||||
verified = 0
|
||||
|
||||
for category in report:
|
||||
category_id = str(category.get("category_id") or "")
|
||||
|
|
@ -38,57 +111,31 @@ async def run_sync_occupancy(days_ahead: int = 365):
|
|||
|
||||
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)
|
||||
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")
|
||||
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),
|
||||
}
|
||||
result = await _save_occupancy_snapshot(
|
||||
db, category_id, category_name, report_date,
|
||||
occupied, available, maintenance, allotted,
|
||||
revenue_gross, revenue_net, occupancy_pct
|
||||
)
|
||||
records += 1
|
||||
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: {records} records upserted")
|
||||
return records
|
||||
logger.info(f"Occupancy sync complete: {inserted} new snapshots, {verified} unchanged")
|
||||
return inserted + verified
|
||||
|
|
|
|||
|
|
@ -225,6 +225,42 @@ CREATE TABLE IF NOT EXISTS newbook_occupancy_report_data (
|
|||
|
||||
CREATE INDEX IF NOT EXISTS idx_occupancy_report_date ON newbook_occupancy_report_data(date);
|
||||
|
||||
-- Migrate occupancy table to snapshot model (drop unique constraint, add valid_from / last_verified_at)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE t.relname = 'newbook_occupancy_report_data'
|
||||
AND c.contype = 'u'
|
||||
AND c.conname = 'newbook_occupancy_report_data_date_category_id_key'
|
||||
) THEN
|
||||
ALTER TABLE newbook_occupancy_report_data
|
||||
DROP CONSTRAINT newbook_occupancy_report_data_date_category_id_key;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'valid_from'
|
||||
) THEN
|
||||
ALTER TABLE newbook_occupancy_report_data ADD COLUMN valid_from TIMESTAMPTZ;
|
||||
UPDATE newbook_occupancy_report_data SET valid_from = fetched_at WHERE valid_from IS NULL;
|
||||
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN valid_from SET DEFAULT NOW();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'last_verified_at'
|
||||
) THEN
|
||||
ALTER TABLE newbook_occupancy_report_data ADD COLUMN last_verified_at TIMESTAMPTZ;
|
||||
UPDATE newbook_occupancy_report_data SET last_verified_at = fetched_at WHERE last_verified_at IS NULL;
|
||||
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN last_verified_at SET DEFAULT NOW();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_occupancy_report_latest
|
||||
ON newbook_occupancy_report_data(date, category_id, valid_from DESC);
|
||||
|
||||
-- ============================================
|
||||
-- DIRECT COMPETITOR HOTEL CONFIGS
|
||||
-- ============================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue