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>
774 lines
33 KiB
Python
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
|