kitchen/backend/services/newbook_sync.py
jtricerolph 8d688b459d Initial kitchen scaffold — Phase 1 kitchen port (build-verified 2026-07-11)
FastAPI backend (Python 3.11, MSSQL ODBC for SambaPOS, Azure DI OCR),
kitchen_db on central PG. React/TS/Vite frontend with navy sidebar layout.

Backend: auth.py (APP_SLUG=kitchen, SimpleNamespace — archive routes use
.kitchen_id/.is_admin without modification), main.py (51 migrations, scheduler,
internal router for KDS bookings feed), api/internal.py, full archive API
(31 routers: invoices, recipes, menus, sambapos, resos, newbook, disputes,
purchase_orders, etc.), models, migrations, OCR pipeline.
kitchen_id pinned to 1 (B1 — single hotel).

Frontend: AuthGate (app=kitchen, token shim for archive compat — B5b pending),
Layout (navy sidebar, 6 sections, Lucide icons, teal --app-primary),
App.tsx (Outlet pattern, UploadApp outside Layout), index.css (full :root block).
strict: false — archive components have type issues; build clean.

Note: 45 archive components call fetch('/api/...') without /kitchen/ prefix
(B5b). Runtime 404s; deferred until after initial testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 12:15:39 +00:00

774 lines
33 KiB
Python

"""
Newbook Data Sync Service
Handles synchronization of data between Newbook API and local database.
"""
import logging
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from sqlalchemy.dialects.postgresql import insert
from models.settings import KitchenSettings
from models.newbook import (
NewbookGLAccount, NewbookDailyRevenue, NewbookDailyOccupancy, NewbookSyncLog, NewbookRoomCategory
)
from services.newbook_api import NewbookAPIClient, NewbookAPIError
logger = logging.getLogger(__name__)
def expand_bookings_by_date(
bookings: list[dict],
date_from: date,
date_to: date,
dinner_gl_codes: list[str],
gl_account_id_to_code: dict[str, str]
) -> dict[date, list[dict]]:
"""
Expand multi-night bookings into per-date room breakdown.
Args:
bookings: List of bookings from get_bookings()
date_from: Start date for expansion
date_to: End date for expansion
dinner_gl_codes: GL codes that indicate dinner allocation (from settings)
gl_account_id_to_code: Mapping from Newbook gl_account_id to gl_code
Returns:
Dict mapping date -> list of room objects for that date
Example:
Booking: room 108, Jan 22-24 (2 nights)
Returns: {
date(2026, 1, 22): [{"room_number": "108", "booking_id": "33471", ...}],
date(2026, 1, 23): [{"room_number": "108", "booking_id": "33471", ...}]
}
"""
breakdown_by_date = {}
for booking in bookings:
check_in = datetime.fromisoformat(booking["check_in_date"]).date()
check_out = datetime.fromisoformat(booking["check_out_date"]).date()
# Iterate each night of the stay (check-in to day before check-out)
current_date = check_in
while current_date < check_out and current_date <= date_to:
if current_date >= date_from:
if current_date not in breakdown_by_date:
breakdown_by_date[current_date] = []
# Detect DBB using existing GL code logic (reuse from process_bookings_for_allocations)
# Check if booking has any inventory items matching dinner GL codes
inventory_items = booking.get("inventory_items", [])
is_dbb = False
for item in inventory_items:
gl_account_id = str(item.get("gl_account_id", ""))
gl_code = gl_account_id_to_code.get(gl_account_id, "")
if gl_code in dinner_gl_codes:
is_dbb = True
break
# Detect package from item_name (no GL code mapping for this yet)
is_package = any(
item.get("item_name", "").lower().find("package") >= 0
for item in inventory_items
)
breakdown_by_date[current_date].append({
"room_number": booking.get("site_name"), # e.g., "108"
"booking_id": booking.get("booking_id"), # e.g., "33471"
"bookings_group_id": booking.get("bookings_group_id"), # Group ID for related bookings
"is_dbb": is_dbb,
"is_package": is_package
})
current_date += timedelta(days=1)
return breakdown_by_date
class NewbookSyncService:
"""Service for syncing Newbook data to local database"""
# Default forecast period (days ahead)
FORECAST_DAYS = 60
def __init__(self, db: AsyncSession, kitchen_id: int):
self.db = db
self.kitchen_id = kitchen_id
self._settings: KitchenSettings = None
async def _get_settings(self) -> KitchenSettings:
"""Fetch and cache kitchen settings"""
if self._settings is None:
result = await self.db.execute(
select(KitchenSettings).where(KitchenSettings.kitchen_id == self.kitchen_id)
)
self._settings = result.scalar_one_or_none()
if not self._settings:
raise ValueError("Kitchen settings not found")
return self._settings
async def _get_client(self) -> NewbookAPIClient:
"""Create authenticated Newbook API client"""
settings = await self._get_settings()
if not all([
settings.newbook_api_username,
settings.newbook_api_password,
settings.newbook_api_key,
settings.newbook_api_region
]):
raise ValueError("Newbook API credentials not fully configured")
return NewbookAPIClient(
username=settings.newbook_api_username,
password=settings.newbook_api_password,
api_key=settings.newbook_api_key,
region=settings.newbook_api_region,
instance_id=settings.newbook_instance_id
)
async def _get_included_room_types(self) -> list[str] | None:
"""Get list of room type names that are included for occupancy calculations.
Returns None if no room categories are configured (include all).
Returns list of site_name values for categories with is_included=True.
"""
result = await self.db.execute(
select(NewbookRoomCategory).where(
NewbookRoomCategory.kitchen_id == self.kitchen_id
)
)
categories = list(result.scalars().all())
if not categories:
# No categories configured - include all
return None
# Return only included category names
included = [cat.site_name for cat in categories if cat.is_included]
logger.info(f"Included room types for occupancy: {included}")
return included if included else None
async def _log_sync(
self,
sync_type: str,
date_from: date = None,
date_to: date = None
) -> NewbookSyncLog:
"""Create a sync log entry"""
log = NewbookSyncLog(
kitchen_id=self.kitchen_id,
sync_type=sync_type,
date_from=date_from,
date_to=date_to,
status="running"
)
self.db.add(log)
await self.db.commit()
await self.db.refresh(log)
return log
async def _complete_sync(
self,
log: NewbookSyncLog,
status: str,
records: int = 0,
error: str = None
):
"""Update sync log on completion"""
log.completed_at = datetime.utcnow()
log.status = status
log.records_fetched = records
log.error_message = error
await self.db.commit()
async def sync_gl_accounts(self) -> list[NewbookGLAccount]:
"""
Fetch and sync GL accounts from Newbook.
Updates existing accounts, adds new ones.
Does NOT delete accounts (preserves user selections).
"""
log = await self._log_sync("gl_accounts")
try:
async with await self._get_client() as client:
accounts_data = await client.get_gl_accounts()
synced_accounts = []
for acc in accounts_data:
# Upsert pattern: update if exists, insert if not
stmt = insert(NewbookGLAccount).values(
kitchen_id=self.kitchen_id,
gl_account_id=acc["id"],
gl_code=acc["code"],
gl_name=acc["name"],
gl_type=acc["type"],
gl_group_id=acc.get("group_id"),
gl_group_name=acc.get("group_name"),
updated_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_gl_account",
set_={
"gl_code": acc["code"],
"gl_name": acc["name"],
"gl_type": acc["type"],
"gl_group_id": acc.get("group_id"),
"gl_group_name": acc.get("group_name"),
"updated_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
await self.db.commit()
# Fetch all accounts for return
result = await self.db.execute(
select(NewbookGLAccount).where(NewbookGLAccount.kitchen_id == self.kitchen_id)
)
synced_accounts = list(result.scalars().all())
await self._complete_sync(log, "success", len(synced_accounts))
logger.info(f"Synced {len(synced_accounts)} GL accounts for kitchen {self.kitchen_id}")
return synced_accounts
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
logger.error(f"GL account sync failed: {e}")
raise
async def sync_revenue(
self,
date_from: date,
date_to: date,
tracked_only: bool = True
) -> int:
"""
Fetch and sync earned revenue data.
Args:
date_from: Start date
date_to: End date
tracked_only: Only fetch for tracked GL accounts
Returns number of records synced
"""
log = await self._log_sync("revenue", date_from, date_to)
try:
# Get tracked GL accounts - we filter locally after fetching all data
# (Newbook API filtering by gl_account_ids is unreliable with code vs ID mismatch)
if tracked_only:
result = await self.db.execute(
select(NewbookGLAccount.id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id,
NewbookGLAccount.is_tracked == True
)
)
tracked_gl = {row[1]: row[0] for row in result.all()} # code -> local id
if not tracked_gl:
logger.warning("No tracked GL accounts, skipping revenue sync")
await self._complete_sync(log, "success", 0)
return 0
gl_map = tracked_gl
else:
# Get all GL accounts
result = await self.db.execute(
select(NewbookGLAccount.id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id
)
)
gl_map = {row[1]: row[0] for row in result.all()} # code -> local id
# Fetch all revenue from Newbook (filter locally by tracked accounts)
async with await self._get_client() as client:
revenue_data = await client.get_earned_revenue(date_from, date_to)
records_count = 0
for entry in revenue_data:
# entry["gl_account_id"] actually contains the gl_code from earned_revenue report
local_gl_id = gl_map.get(entry["gl_account_id"])
if not local_gl_id:
continue
entry_date = date.fromisoformat(entry["date"]) if isinstance(entry["date"], str) else entry["date"]
# Upsert revenue entry
stmt = insert(NewbookDailyRevenue).values(
kitchen_id=self.kitchen_id,
gl_account_id=local_gl_id,
date=entry_date,
amount_net=entry["amount_net"],
amount_gross=entry.get("amount_gross"),
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_revenue_per_day",
set_={
"amount_net": entry["amount_net"],
"amount_gross": entry.get("amount_gross"),
"fetched_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
records_count += 1
await self.db.commit()
await self._complete_sync(log, "success", records_count)
logger.info(f"Synced {records_count} revenue records for kitchen {self.kitchen_id}")
return records_count
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
logger.error(f"Revenue sync failed: {e}")
raise
async def sync_occupancy(
self,
date_from: date,
date_to: date,
is_forecast: bool = False
) -> int:
"""
Fetch and sync occupancy data with meal allocations.
Args:
date_from: Start date
date_to: End date
is_forecast: Mark as forecast data (for future dates)
"""
log = await self._log_sync("occupancy", date_from, date_to)
try:
settings = await self._get_settings()
# Parse breakfast/dinner GL codes from settings
breakfast_gl_codes = []
dinner_gl_codes = []
if settings.newbook_breakfast_gl_codes:
breakfast_gl_codes = [c.strip() for c in settings.newbook_breakfast_gl_codes.split(",") if c.strip()]
if settings.newbook_dinner_gl_codes:
dinner_gl_codes = [c.strip() for c in settings.newbook_dinner_gl_codes.split(",") if c.strip()]
# Get VAT rates from settings
breakfast_vat_rate = settings.newbook_breakfast_vat_rate
dinner_vat_rate = settings.newbook_dinner_vat_rate
# Build GL account ID to code mapping for allocation processing
gl_account_id_to_code = {}
if breakfast_gl_codes or dinner_gl_codes:
result = await self.db.execute(
select(NewbookGLAccount.gl_account_id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id
)
)
gl_account_id_to_code = {row[0]: row[1] for row in result.all()}
logger.info(f"Built GL account mapping with {len(gl_account_id_to_code)} entries")
# Get included room types for filtering
included_room_types = await self._get_included_room_types()
async with await self._get_client() as client:
# Fetch category_id to type mapping for guest filtering
_, category_id_to_type = await client.get_site_list()
# Fetch occupancy data
occupancy_data = await client.get_occupancy_report(date_from, date_to)
# Always fetch bookings to get guest counts and allocations
bookings = await client.get_bookings(date_from, date_to)
# Process bookings for guest counts (filtered by room type)
guests_by_date = client.process_bookings_for_guests(bookings, included_room_types, category_id_to_type)
# Process bookings for meal allocations (also filtered by room type)
allocations_by_date = {}
if breakfast_gl_codes or dinner_gl_codes:
allocations_by_date = client.process_bookings_for_allocations(
bookings, breakfast_gl_codes, dinner_gl_codes, gl_account_id_to_code,
breakfast_vat_rate, dinner_vat_rate
)
# Process bookings for arrival tracking (also filtered by room type)
arrivals_by_date = client.process_bookings_for_arrivals(bookings, included_room_types, category_id_to_type)
# Process bookings for room breakdown (for ResidentsTableChart)
breakdown_by_date = expand_bookings_by_date(
bookings,
date_from,
date_to,
dinner_gl_codes,
gl_account_id_to_code
)
# Diagnostic logging: check for duplicate room entries on same date
for check_date, rooms_on_date in breakdown_by_date.items():
room_to_bookings = {}
for room_entry in rooms_on_date:
room_num = room_entry.get("room_number")
if room_num:
if room_num not in room_to_bookings:
room_to_bookings[room_num] = []
room_to_bookings[room_num].append(room_entry.get("booking_id"))
# Report rooms with multiple bookings on same date
for room_num, booking_ids in room_to_bookings.items():
if len(booking_ids) > 1:
logger.warning(f"Date {check_date}, Room {room_num}: Multiple bookings: {booking_ids}")
# Also log detailed info about bookings with same room number
room_booking_details = {}
for booking in bookings:
room_num = booking.get("site_name")
booking_id = booking.get("booking_id")
if room_num and booking_id:
if room_num not in room_booking_details:
room_booking_details[room_num] = []
room_booking_details[room_num].append({
'booking_id': booking_id,
'booking_reference': booking.get("booking_reference"),
'check_in': booking.get("check_in_date"),
'check_out': booking.get("check_out_date"),
'nights': booking.get("nights")
})
# Report rooms with multiple total bookings in the period
for room_num, booking_list in room_booking_details.items():
if len(booking_list) > 1:
logger.warning(f"Room {room_num} has {len(booking_list)} bookings in period {date_from} to {date_to}:")
for b in booking_list:
logger.warning(f" - Booking {b['booking_id']} (ref: {b['booking_reference']}): {b['check_in']} to {b['check_out']} ({b['nights']} nights)")
records_count = 0
today = date.today()
for entry in occupancy_data:
# Skip entries with no date
if not entry.get("date"):
continue
entry_date = date.fromisoformat(entry["date"]) if isinstance(entry["date"], str) else entry["date"]
# Skip if date parsing failed
if not entry_date:
continue
# Determine if this is forecast/current data (today or future)
# Today should be updatable, not locked as historical
entry_is_forecast = entry_date >= today
# Get allocations for this date if available
allocs = allocations_by_date.get(entry["date"], allocations_by_date.get(str(entry_date), {}))
# Get guest count from bookings (filtered by room type)
guest_count = guests_by_date.get(str(entry_date), guests_by_date.get(entry["date"]))
# Get arrivals for this date
arrivals = arrivals_by_date.get(str(entry_date), arrivals_by_date.get(entry["date"], {}))
# Get rooms breakdown for this date
rooms_breakdown = breakdown_by_date.get(entry_date, [])
# Log first few entries for debugging
if records_count < 3:
logger.info(f"Occupancy entry: date={entry_date}, guest_count={guest_count}, occupied_rooms={entry.get('occupied_rooms')}, is_forecast={entry_is_forecast}")
# For past dates, only insert if not exists (don't overwrite)
# For future dates, always update
if entry_is_forecast:
stmt = insert(NewbookDailyOccupancy).values(
kitchen_id=self.kitchen_id,
date=entry_date,
total_rooms=entry.get("total_rooms"),
occupied_rooms=entry.get("occupied_rooms"),
occupancy_percentage=entry.get("occupancy_percentage"),
total_guests=guest_count,
breakfast_allocation_qty=allocs.get("breakfast_qty"),
breakfast_allocation_netvalue=allocs.get("breakfast_netvalue"),
dinner_allocation_qty=allocs.get("dinner_qty"),
dinner_allocation_netvalue=allocs.get("dinner_netvalue"),
arrival_count=arrivals.get("count"),
arrival_booking_ids=arrivals.get("ids"),
arrival_booking_details=arrivals.get("details"),
rooms_breakdown=rooms_breakdown,
is_forecast=True,
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_occupancy_per_day",
set_={
"total_rooms": entry.get("total_rooms"),
"occupied_rooms": entry.get("occupied_rooms"),
"occupancy_percentage": entry.get("occupancy_percentage"),
"total_guests": guest_count,
"breakfast_allocation_qty": allocs.get("breakfast_qty"),
"breakfast_allocation_netvalue": allocs.get("breakfast_netvalue"),
"dinner_allocation_qty": allocs.get("dinner_qty"),
"dinner_allocation_netvalue": allocs.get("dinner_netvalue"),
"arrival_count": arrivals.get("count"),
"arrival_booking_ids": arrivals.get("ids"),
"arrival_booking_details": arrivals.get("details"),
"rooms_breakdown": rooms_breakdown,
"is_forecast": True,
"fetched_at": datetime.utcnow()
}
)
else:
# Past dates - insert if new, or update is_forecast flag if already exists
# This ensures dates that were previously forecast get marked as historical
stmt = insert(NewbookDailyOccupancy).values(
kitchen_id=self.kitchen_id,
date=entry_date,
total_rooms=entry.get("total_rooms"),
occupied_rooms=entry.get("occupied_rooms"),
occupancy_percentage=entry.get("occupancy_percentage"),
total_guests=guest_count,
breakfast_allocation_qty=allocs.get("breakfast_qty"),
breakfast_allocation_netvalue=allocs.get("breakfast_netvalue"),
dinner_allocation_qty=allocs.get("dinner_qty"),
dinner_allocation_netvalue=allocs.get("dinner_netvalue"),
arrival_count=arrivals.get("count"),
arrival_booking_ids=arrivals.get("ids"),
arrival_booking_details=arrivals.get("details"),
rooms_breakdown=rooms_breakdown,
is_forecast=False,
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_occupancy_per_day",
set_={
"rooms_breakdown": rooms_breakdown,
"is_forecast": False,
"fetched_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
records_count += 1
await self.db.commit()
await self._complete_sync(log, "success", records_count)
logger.info(f"Synced {records_count} occupancy records for kitchen {self.kitchen_id}")
return records_count
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
logger.error(f"Occupancy sync failed: {e}")
raise
async def run_daily_sync(self) -> dict:
"""
Run the daily automatic sync job.
- Backfills any missing historical data (last 30 days)
- Updates forecast period (next 60 days)
"""
today = date.today()
results = {
"revenue_historical": 0,
"occupancy_historical": 0,
"occupancy_forecast": 0,
}
try:
# Historical data (last 30 days)
hist_from = today - timedelta(days=30)
results["revenue_historical"] = await self.sync_revenue(hist_from, today)
results["occupancy_historical"] = await self.sync_occupancy(hist_from, today, is_forecast=False)
# Forecast data (next 60 days)
forecast_to = today + timedelta(days=self.FORECAST_DAYS)
results["occupancy_forecast"] = await self.sync_occupancy(today, forecast_to, is_forecast=True)
# Update last sync timestamp
settings = await self._get_settings()
settings.newbook_last_sync = datetime.utcnow()
await self.db.commit()
logger.info(f"Daily sync completed for kitchen {self.kitchen_id}: {results}")
except Exception as e:
logger.error(f"Daily sync failed for kitchen {self.kitchen_id}: {e}")
raise
return results
async def run_upcoming_sync(self) -> dict:
"""
Run upcoming sync for next 7 days only.
This is designed to run more frequently (e.g., every 15 minutes) to keep
the most important upcoming room data fresh for ResidentsTableChart.
"""
today = date.today()
next_week = today + timedelta(days=7)
# Sync next 7 days of occupancy
result = await self.sync_occupancy(today, next_week, is_forecast=True)
# Update last upcoming sync timestamp
settings = await self._get_settings()
settings.newbook_last_upcoming_sync = datetime.utcnow()
await self.db.commit()
return {
'upcoming': result
}
async def sync_forecast_period(self) -> dict:
"""
Manual sync for forecast period only (next ~2 months).
Called from settings UI button.
"""
today = date.today()
forecast_to = today + timedelta(days=self.FORECAST_DAYS)
results = {
"occupancy": await self.sync_occupancy(today, forecast_to, is_forecast=True),
}
return results
async def sync_historical_range(self, date_from: date, date_to: date) -> dict:
"""
Manual sync for specific historical date range.
Called from settings UI date picker.
Note: For historical data, this will force update existing records.
"""
log = await self._log_sync("historical_manual", date_from, date_to)
try:
settings = await self._get_settings()
# Parse breakfast/dinner GL codes for meal allocation counts
breakfast_gl_codes = []
dinner_gl_codes = []
if settings.newbook_breakfast_gl_codes:
breakfast_gl_codes = [c.strip() for c in settings.newbook_breakfast_gl_codes.split(",") if c.strip()]
if settings.newbook_dinner_gl_codes:
dinner_gl_codes = [c.strip() for c in settings.newbook_dinner_gl_codes.split(",") if c.strip()]
# Get VAT rates (needed for allocation processing)
breakfast_vat_rate = settings.newbook_breakfast_vat_rate
dinner_vat_rate = settings.newbook_dinner_vat_rate
# Build GL account ID to code mapping for allocation processing
gl_account_id_to_code = {}
if breakfast_gl_codes or dinner_gl_codes:
result = await self.db.execute(
select(NewbookGLAccount.gl_account_id, NewbookGLAccount.gl_code).where(
NewbookGLAccount.kitchen_id == self.kitchen_id
)
)
gl_account_id_to_code = {row[0]: row[1] for row in result.all()}
# Sync revenue from earned revenue report (actual revenue)
revenue_count = await self.sync_revenue(date_from, date_to)
# Get included room types for filtering guest counts
included_room_types = await self._get_included_room_types()
# For historical sync: get occupancy, guest counts, and meal allocation COUNTS
# (values come from earned_revenue report, but counts are useful for analysis)
async with await self._get_client() as client:
# Fetch category_id to type mapping for guest filtering
_, category_id_to_type = await client.get_site_list()
occupancy_data = await client.get_occupancy_report(date_from, date_to)
# Fetch bookings for guest counts and meal allocation counts
bookings = await client.get_bookings(date_from, date_to)
# Process bookings for guest counts (filtered by room type)
guests_by_date = client.process_bookings_for_guests(bookings, included_room_types, category_id_to_type)
# Process bookings for meal allocation counts (PAX only, values from earned_revenue)
allocations_by_date = {}
if breakfast_gl_codes or dinner_gl_codes:
allocations_by_date = client.process_bookings_for_allocations(
bookings, breakfast_gl_codes, dinner_gl_codes, gl_account_id_to_code,
breakfast_vat_rate, dinner_vat_rate
)
# Process bookings for arrival tracking (also filtered by room type)
arrivals_by_date = client.process_bookings_for_arrivals(bookings, included_room_types, category_id_to_type)
occupancy_count = 0
for entry in occupancy_data:
entry_date = date.fromisoformat(entry["date"]) if isinstance(entry["date"], str) else entry["date"]
allocs = allocations_by_date.get(entry["date"], allocations_by_date.get(str(entry_date), {}))
# Get guest count from bookings (filtered by room type)
guest_count = guests_by_date.get(str(entry_date), guests_by_date.get(entry["date"]))
# Get arrivals for this date
arrivals = arrivals_by_date.get(str(entry_date), arrivals_by_date.get(entry["date"], {}))
# Log first few entries for debugging
if occupancy_count < 3:
logger.info(f"Historical occupancy entry: date={entry_date}, guest_count={guest_count}, occupied_rooms={entry.get('occupied_rooms')}, breakfast_qty={allocs.get('breakfast_qty')}")
# Force update for manual historical sync
# Store meal allocation QTY (pax counts) but NOT values (values come from earned_revenue)
stmt = insert(NewbookDailyOccupancy).values(
kitchen_id=self.kitchen_id,
date=entry_date,
total_rooms=entry.get("total_rooms"),
occupied_rooms=entry.get("occupied_rooms"),
occupancy_percentage=entry.get("occupancy_percentage"),
total_guests=guest_count,
breakfast_allocation_qty=allocs.get("breakfast_qty"),
breakfast_allocation_netvalue=None, # Historical: use earned_revenue for actual values
dinner_allocation_qty=allocs.get("dinner_qty"),
dinner_allocation_netvalue=None, # Historical: use earned_revenue for actual values
arrival_count=arrivals.get("count"),
arrival_booking_ids=arrivals.get("ids"),
arrival_booking_details=arrivals.get("details"),
is_forecast=False,
fetched_at=datetime.utcnow()
).on_conflict_do_update(
constraint="uq_newbook_occupancy_per_day",
set_={
"total_rooms": entry.get("total_rooms"),
"occupied_rooms": entry.get("occupied_rooms"),
"occupancy_percentage": entry.get("occupancy_percentage"),
"total_guests": guest_count,
"breakfast_allocation_qty": allocs.get("breakfast_qty"),
"breakfast_allocation_netvalue": None,
"dinner_allocation_qty": allocs.get("dinner_qty"),
"dinner_allocation_netvalue": None,
"arrival_count": arrivals.get("count"),
"arrival_booking_ids": arrivals.get("ids"),
"arrival_booking_details": arrivals.get("details"),
"is_forecast": False,
"fetched_at": datetime.utcnow()
}
)
await self.db.execute(stmt)
occupancy_count += 1
await self.db.commit()
results = {
"revenue": revenue_count,
"occupancy": occupancy_count
}
await self._complete_sync(log, "success", revenue_count + occupancy_count)
return results
except Exception as e:
await self._complete_sync(log, "failed", error=str(e))
raise