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>
959 lines
41 KiB
Python
959 lines
41 KiB
Python
"""
|
|
Newbook API Client Service
|
|
|
|
Handles authentication and API calls to Newbook PMS.
|
|
Base URL: https://api.newbook.cloud/rest/
|
|
Auth: HTTP Basic Auth (username:password) + API Key + Region in request body
|
|
"""
|
|
import logging
|
|
import httpx
|
|
from datetime import date
|
|
from typing import Optional, Any
|
|
from decimal import Decimal
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Single base URL for all regions - region is passed in request body
|
|
NEWBOOK_BASE_URL = "https://api.newbook.cloud/rest/"
|
|
|
|
# Valid regions (passed in request body, not URL)
|
|
VALID_REGIONS = ["au", "ap", "eu", "us", "uk"]
|
|
|
|
|
|
class NewbookAPIError(Exception):
|
|
"""Custom exception for Newbook API errors"""
|
|
def __init__(self, message: str, status_code: int = None, response_data: dict = None):
|
|
self.message = message
|
|
self.status_code = status_code
|
|
self.response_data = response_data
|
|
super().__init__(self.message)
|
|
|
|
|
|
class NewbookAPIClient:
|
|
"""
|
|
Async client for Newbook REST API.
|
|
|
|
Usage:
|
|
async with NewbookAPIClient(username, password, api_key, region, instance_id) as client:
|
|
accounts = await client.get_gl_accounts()
|
|
revenue = await client.get_earned_revenue(date_from, date_to)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
username: str,
|
|
password: str,
|
|
api_key: str,
|
|
region: str = "au",
|
|
instance_id: str = None
|
|
):
|
|
self.username = username
|
|
self.password = password
|
|
self.api_key = api_key
|
|
self.region = region # Passed in request body
|
|
self.instance_id = instance_id
|
|
self.base_url = NEWBOOK_BASE_URL
|
|
self._client: httpx.AsyncClient = None
|
|
|
|
async def __aenter__(self):
|
|
self._client = httpx.AsyncClient(
|
|
auth=(self.username, self.password),
|
|
timeout=httpx.Timeout(30.0, connect=15.0),
|
|
follow_redirects=True,
|
|
)
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
if self._client:
|
|
await self._client.aclose()
|
|
|
|
async def _request(self, endpoint: str, payload: dict = None) -> dict:
|
|
"""
|
|
Make an authenticated request to Newbook API.
|
|
|
|
All requests include api_key in the body.
|
|
"""
|
|
if payload is None:
|
|
payload = {}
|
|
|
|
# Always include region and api_key in request body
|
|
payload["region"] = self.region
|
|
payload["api_key"] = self.api_key
|
|
|
|
# Include instance_id if configured
|
|
if self.instance_id:
|
|
payload["instance_id"] = self.instance_id
|
|
|
|
url = f"{self.base_url}{endpoint}"
|
|
|
|
try:
|
|
logger.info(f"Newbook API request: POST {endpoint}")
|
|
response = await self._client.post(url, json=payload)
|
|
|
|
if response.status_code == 401:
|
|
raise NewbookAPIError("Authentication failed. Check username/password.", 401)
|
|
|
|
if response.status_code == 403:
|
|
raise NewbookAPIError("Access denied. Check API key and permissions.", 403)
|
|
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
# Check for Newbook-specific error responses
|
|
if isinstance(data, dict) and data.get("success") is False:
|
|
error_msg = data.get("message", "Unknown Newbook API error")
|
|
raise NewbookAPIError(error_msg, response.status_code, data)
|
|
|
|
return data
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"Newbook API HTTP error: {e.response.status_code}")
|
|
raise NewbookAPIError(f"HTTP {e.response.status_code}: {str(e)}", e.response.status_code)
|
|
except httpx.RequestError as e:
|
|
logger.error(f"Newbook API request error: {e}")
|
|
raise NewbookAPIError(f"Request failed: {str(e)}")
|
|
|
|
async def test_connection(self) -> bool:
|
|
"""Test API connection by fetching GL accounts (lightweight call)"""
|
|
try:
|
|
await self.get_gl_accounts()
|
|
return True
|
|
except NewbookAPIError:
|
|
return False
|
|
|
|
async def get_gl_accounts(self) -> list[dict]:
|
|
"""
|
|
Fetch list of GL accounts from Newbook.
|
|
|
|
Endpoint: gl_account_list
|
|
|
|
Note: Newbook returns individual GL accounts with group info.
|
|
Each item has both gl_account_id/gl_account_name (individual)
|
|
and gl_group_id/gl_group_name (category).
|
|
|
|
Returns list of individual GL accounts with:
|
|
- id: GL account ID (gl_account_id)
|
|
- code: Account code
|
|
- name: Account name (gl_account_name)
|
|
- group_id: Parent group ID (gl_group_id)
|
|
- group_name: Parent group name (gl_group_name)
|
|
- type: Account type
|
|
"""
|
|
response = await self._request("gl_account_list")
|
|
|
|
# Normalize response format
|
|
accounts = {} # Use dict to dedupe by gl_account_id
|
|
items = response.get("data", response) if isinstance(response, dict) else response
|
|
|
|
if isinstance(items, list):
|
|
for item in items:
|
|
# Get individual account ID (prefer gl_account_id, fall back to id)
|
|
gl_account_id = str(item.get("gl_account_id", item.get("id", "")))
|
|
gl_account_name = item.get("gl_account_name", item.get("name", ""))
|
|
|
|
# Get group info for categorization
|
|
gl_group_id = str(item.get("gl_group_id", ""))
|
|
gl_group_name = item.get("gl_group_name", "")
|
|
|
|
if not gl_account_id or gl_account_id in accounts:
|
|
continue
|
|
|
|
# Extract code from account name or use account ID
|
|
code = item.get("gl_account_code", item.get("code", ""))
|
|
if not code and " - " in gl_account_name:
|
|
code = gl_account_name.split(" - ")[0].strip()
|
|
|
|
accounts[gl_account_id] = {
|
|
"id": gl_account_id,
|
|
"code": code,
|
|
"name": gl_account_name,
|
|
"group_id": gl_group_id,
|
|
"group_name": gl_group_name,
|
|
"type": item.get("gl_type", item.get("type", ""))
|
|
}
|
|
|
|
result = list(accounts.values())
|
|
logger.info(f"Fetched {len(result)} GL accounts from Newbook")
|
|
return result
|
|
|
|
async def get_earned_revenue(
|
|
self,
|
|
date_from: date,
|
|
date_to: date,
|
|
gl_account_ids: list[str] = None
|
|
) -> list[dict]:
|
|
"""
|
|
Fetch earned revenue report - requests one day at a time to get daily breakdown.
|
|
|
|
Endpoint: reports_earned_revenue
|
|
|
|
Note: Newbook returns period totals per GL account, not daily breakdown.
|
|
We request each day individually to get daily revenue per GL account.
|
|
Rate limited to ~80 requests/min to stay under Newbook's 100/min limit.
|
|
|
|
Args:
|
|
date_from: Start date
|
|
date_to: End date
|
|
gl_account_ids: Optional list of GL account IDs to filter
|
|
|
|
Returns list of daily revenue entries:
|
|
- date: Date (ISO string)
|
|
- gl_account_id: GL Account ID
|
|
- gl_account_name: GL Account name
|
|
- amount_net: Net amount (exc tax)
|
|
- amount_gross: Gross amount (inc tax) if available
|
|
"""
|
|
import asyncio
|
|
from datetime import timedelta
|
|
|
|
revenue_entries = []
|
|
current_date = date_from
|
|
request_count = 0
|
|
|
|
# Rate limit: ~80 requests/min to stay under 100/min limit
|
|
# 0.75 seconds between requests = 80 requests/min
|
|
RATE_LIMIT_DELAY = 0.75
|
|
|
|
# Request each day individually to get daily breakdown
|
|
while current_date <= date_to:
|
|
payload = {
|
|
"period_from": current_date.isoformat(),
|
|
"period_to": current_date.isoformat(),
|
|
}
|
|
|
|
if gl_account_ids:
|
|
payload["gl_account_ids"] = gl_account_ids
|
|
|
|
try:
|
|
response = await self._request("reports_earned_revenue", payload)
|
|
request_count += 1
|
|
|
|
items = response.get("data", response) if isinstance(response, dict) else response
|
|
|
|
# Log first response to debug field names
|
|
if request_count == 1:
|
|
logger.info(f"Earned revenue first response sample: {items[:2] if isinstance(items, list) else items}")
|
|
|
|
if isinstance(items, list):
|
|
for item in items:
|
|
# Newbook returns: earned_revenue_ex (net), earned_revenue (gross)
|
|
# Also check legacy field names as fallback
|
|
amount_net = Decimal(str(
|
|
item.get("earned_revenue_ex") or
|
|
item.get("amount_net") or
|
|
item.get("amount", 0) or 0
|
|
))
|
|
# Skip zero amounts
|
|
if amount_net == 0:
|
|
continue
|
|
|
|
# GL account code is in gl_account_code field
|
|
gl_code = str(
|
|
item.get("gl_account_code") or
|
|
item.get("gl_account_id", "")
|
|
)
|
|
gl_name = (
|
|
item.get("gl_account_description") or
|
|
item.get("gl_account_name", "")
|
|
)
|
|
|
|
entry = {
|
|
"date": current_date.isoformat(),
|
|
"gl_account_id": gl_code, # Note: this is actually the code, not internal ID
|
|
"gl_account_name": gl_name,
|
|
"amount_net": amount_net,
|
|
"amount_gross": None
|
|
}
|
|
# Gross amount is earned_revenue (inc tax)
|
|
gross = item.get("earned_revenue") or item.get("amount_gross")
|
|
if gross:
|
|
entry["amount_gross"] = Decimal(str(gross))
|
|
revenue_entries.append(entry)
|
|
|
|
except NewbookAPIError as e:
|
|
logger.warning(f"Failed to fetch revenue for {current_date}: {e}")
|
|
|
|
current_date += timedelta(days=1)
|
|
|
|
# Rate limiting delay between requests
|
|
if current_date <= date_to:
|
|
await asyncio.sleep(RATE_LIMIT_DELAY)
|
|
|
|
logger.info(f"Fetched {len(revenue_entries)} revenue entries from Newbook ({date_from} to {date_to}, {request_count} requests)")
|
|
return revenue_entries
|
|
|
|
async def get_occupancy_report(
|
|
self,
|
|
date_from: date,
|
|
date_to: date
|
|
) -> list[dict]:
|
|
"""
|
|
Fetch occupancy report.
|
|
|
|
Endpoint: reports_occupancy
|
|
|
|
Note: Newbook returns data grouped by room category. Each category has
|
|
an 'occupancy' dict with dates as keys. We aggregate across all categories
|
|
to get daily totals.
|
|
|
|
Returns list of daily occupancy data:
|
|
- date
|
|
- total_rooms
|
|
- occupied_rooms
|
|
- occupancy_percentage
|
|
- total_guests (estimated from occupied rooms)
|
|
"""
|
|
payload = {
|
|
"period_from": date_from.isoformat(),
|
|
"period_to": date_to.isoformat(),
|
|
}
|
|
|
|
response = await self._request("reports_occupancy", payload)
|
|
|
|
# Aggregate occupancy across all room categories by date
|
|
daily_totals = {} # date -> {available, occupied, maintenance, adults, children}
|
|
|
|
items = response.get("data", response) if isinstance(response, dict) else response
|
|
|
|
if isinstance(items, list):
|
|
for category in items:
|
|
# Each category has an 'occupancy' dict with dates as keys
|
|
category_occupancy = category.get("occupancy", {})
|
|
|
|
if isinstance(category_occupancy, dict):
|
|
for date_str, day_data in category_occupancy.items():
|
|
# Debug: log first day's data structure to see guest field format
|
|
if not daily_totals:
|
|
logger.info(f"Occupancy day_data sample keys: {list(day_data.keys()) if isinstance(day_data, dict) else 'not dict'}")
|
|
logger.info(f"Occupancy day_data sample: {day_data}")
|
|
|
|
if date_str not in daily_totals:
|
|
daily_totals[date_str] = {
|
|
"available": 0,
|
|
"occupied": 0,
|
|
"maintenance": 0,
|
|
"adults": 0,
|
|
"children": 0
|
|
}
|
|
|
|
daily_totals[date_str]["available"] += day_data.get("available", 0) or 0
|
|
daily_totals[date_str]["occupied"] += day_data.get("occupied", 0) or 0
|
|
daily_totals[date_str]["maintenance"] += day_data.get("maintenance", 0) or 0
|
|
|
|
# Parse guest counts - can be direct fields or in arrays
|
|
# Handle array format: [adults, children, infants] or [{type, count}, ...]
|
|
guests_data = day_data.get("guests", day_data.get("people", None))
|
|
|
|
if isinstance(guests_data, list):
|
|
if len(guests_data) >= 2:
|
|
# Check if it's [adults, children, infants] format (numbers)
|
|
if isinstance(guests_data[0], (int, float)):
|
|
daily_totals[date_str]["adults"] += int(guests_data[0] or 0)
|
|
daily_totals[date_str]["children"] += int(guests_data[1] or 0)
|
|
# Ignore infants at index 2
|
|
# Check if it's [{type, count}, ...] format
|
|
elif isinstance(guests_data[0], dict):
|
|
for guest_item in guests_data:
|
|
guest_type = str(guest_item.get("type", guest_item.get("name", ""))).lower()
|
|
count = int(guest_item.get("count", guest_item.get("quantity", 0)) or 0)
|
|
if "adult" in guest_type:
|
|
daily_totals[date_str]["adults"] += count
|
|
elif "child" in guest_type:
|
|
daily_totals[date_str]["children"] += count
|
|
# Ignore infants
|
|
else:
|
|
# Try direct fields
|
|
daily_totals[date_str]["adults"] += int(day_data.get("adults", 0) or 0)
|
|
daily_totals[date_str]["children"] += int(day_data.get("children", 0) or 0)
|
|
|
|
# Convert to list format
|
|
occupancy_data = []
|
|
for date_str, totals in sorted(daily_totals.items()):
|
|
total_rooms = totals["available"]
|
|
occupied_rooms = totals["occupied"]
|
|
total_guests = totals["adults"] + totals["children"]
|
|
|
|
# Calculate occupancy percentage
|
|
occupancy_pct = None
|
|
if total_rooms > 0:
|
|
occupancy_pct = Decimal(str(round(occupied_rooms / total_rooms * 100, 2)))
|
|
|
|
# Fall back to occupied_rooms if no guest data
|
|
if total_guests == 0:
|
|
total_guests = occupied_rooms
|
|
|
|
occupancy_data.append({
|
|
"date": date_str,
|
|
"total_rooms": total_rooms,
|
|
"occupied_rooms": occupied_rooms,
|
|
"occupancy_percentage": occupancy_pct,
|
|
"total_guests": total_guests,
|
|
})
|
|
|
|
logger.info(f"Fetched {len(occupancy_data)} daily occupancy records from Newbook (aggregated from {len(items) if items else 0} categories)")
|
|
return occupancy_data
|
|
|
|
async def get_bookings(
|
|
self,
|
|
date_from: date,
|
|
date_to: date,
|
|
include_cancelled: bool = False
|
|
) -> list[dict]:
|
|
"""
|
|
Fetch bookings list with inventory items.
|
|
|
|
Endpoint: bookings_list
|
|
|
|
Note: Newbook paginates results (default 100, max 1000 per request).
|
|
This method automatically fetches all pages.
|
|
|
|
Args:
|
|
date_from: Check-in date from
|
|
date_to: Check-in date to
|
|
include_cancelled: Include cancelled bookings
|
|
|
|
Returns list of bookings with inventory items
|
|
"""
|
|
bookings = []
|
|
data_offset = 0
|
|
data_limit = 1000 # Max allowed by Newbook
|
|
total_fetched = 0
|
|
|
|
while True:
|
|
payload = {
|
|
"period_from": date_from.isoformat(),
|
|
"period_to": date_to.isoformat(),
|
|
"list_type": "staying", # Get bookings staying on these dates
|
|
"data_offset": data_offset,
|
|
"data_limit": data_limit,
|
|
}
|
|
|
|
response = await self._request("bookings_list", payload)
|
|
|
|
# Get pagination info from response
|
|
data_total = response.get("data_total", 0)
|
|
data_count = response.get("data_count", 0)
|
|
|
|
items = response.get("data", response) if isinstance(response, dict) else response
|
|
|
|
if isinstance(items, list):
|
|
for item in items:
|
|
status = item.get("status", "").lower()
|
|
if not include_cancelled and status == "cancelled":
|
|
continue
|
|
|
|
# Parse guest count - use booking_adults + booking_children directly
|
|
# Newbook provides these as separate fields, excluding infants (who don't eat full meals)
|
|
adults = int(item.get("booking_adults", 0) or 0)
|
|
children = int(item.get("booking_children", 0) or 0)
|
|
infants = int(item.get("booking_infants", 0) or 0)
|
|
num_guests = adults + children
|
|
|
|
# Fall back to 1 only if no guest data at all (single occupancy assumed)
|
|
# Don't fall back if there are only infants - they don't count for meals
|
|
if num_guests == 0 and infants == 0:
|
|
num_guests = 1
|
|
|
|
# Log first booking's structure to debug
|
|
if len(bookings) == 0:
|
|
logger.info(f"First booking guest data - adults: {item.get('booking_adults')}, children: {item.get('booking_children')}, infants: {item.get('booking_infants')} => counted: {num_guests}")
|
|
logger.info(f"First booking dates - arrival: {item.get('booking_arrival')}, departure: {item.get('booking_departure')}")
|
|
logger.info(f"First booking category_id: {item.get('category_id')}")
|
|
|
|
# Get room category/type - use category_id which maps to room types
|
|
# category_id is the numeric ID that corresponds to room types like "Standard", "Suite"
|
|
category_id = item.get("category_id") or item.get("site_category_id") or ""
|
|
room_type_name = (
|
|
item.get("site_type") or
|
|
item.get("category_name") or
|
|
item.get("room_type_name") or
|
|
""
|
|
)
|
|
|
|
# Log first booking's room fields to debug matching
|
|
if len(bookings) == 0:
|
|
logger.info(f"First booking room fields - category_id: {category_id}, site_type: {item.get('site_type')}, site_name: {item.get('site_name')}")
|
|
|
|
# Get booking group ID for related bookings (e.g., family/party traveling together)
|
|
bookings_group_id = item.get("bookings_group_id")
|
|
if bookings_group_id:
|
|
bookings_group_id = str(bookings_group_id)
|
|
|
|
bookings.append({
|
|
"booking_id": str(item.get("id", item.get("booking_id", ""))),
|
|
"booking_reference": item.get("reference", item.get("booking_reference", item.get("booking_reference_id"))),
|
|
"bookings_group_id": bookings_group_id, # Group ID for related bookings
|
|
"check_in_date": item.get("booking_arrival", item.get("check_in", item.get("check_in_date"))),
|
|
"check_out_date": item.get("booking_departure", item.get("check_out", item.get("check_out_date"))),
|
|
"nights": item.get("booking_length", item.get("nights")),
|
|
"room_type": room_type_name, # Room type name if available
|
|
"category_id": str(category_id) if category_id else "", # Category ID for filtering
|
|
"site_id": item.get("site_id"), # Individual room ID
|
|
"site_name": item.get("site_name"), # Room number (e.g. "108")
|
|
"num_guests": num_guests,
|
|
"booking_adults": int(item.get("booking_adults", 0) or 0), # Debug
|
|
"booking_children": int(item.get("booking_children", 0) or 0), # Debug
|
|
"booking_infants": int(item.get("booking_infants", 0) or 0), # Debug
|
|
"num_rooms": item.get("rooms", item.get("num_rooms", 1)),
|
|
"total_amount": Decimal(str(item.get("total", 0))) if item.get("total") else None,
|
|
"status": status,
|
|
"inventory_items": item.get("inventory_items", item.get("items", []))
|
|
})
|
|
|
|
total_fetched += len(items)
|
|
|
|
# Check if we've fetched all records
|
|
if data_count == 0 or total_fetched >= data_total:
|
|
break
|
|
|
|
# Move to next page
|
|
data_offset += data_limit
|
|
logger.info(f"Fetching next page of bookings (offset: {data_offset}, total: {data_total})")
|
|
|
|
logger.info(f"Fetched {len(bookings)} bookings from Newbook (total available: {data_total})")
|
|
return bookings
|
|
|
|
def process_bookings_for_allocations(
|
|
self,
|
|
bookings: list[dict],
|
|
breakfast_gl_codes: list[str],
|
|
dinner_gl_codes: list[str],
|
|
gl_account_id_to_code: dict[str, str] = None,
|
|
breakfast_vat_rate: Decimal = None,
|
|
dinner_vat_rate: Decimal = None
|
|
) -> dict[str, dict]:
|
|
"""
|
|
Process bookings inventory items to calculate meal allocations per date.
|
|
|
|
Args:
|
|
bookings: List of bookings with inventory_items
|
|
breakfast_gl_codes: GL codes that indicate breakfast allocation
|
|
dinner_gl_codes: GL codes that indicate dinner allocation
|
|
gl_account_id_to_code: Mapping from Newbook gl_account_id to gl_code
|
|
breakfast_vat_rate: VAT rate for breakfast (e.g., 0.10 for 10%)
|
|
dinner_vat_rate: VAT rate for dinner (e.g., 0.10 for 10%)
|
|
|
|
Returns dict keyed by date with:
|
|
- breakfast_qty: Total breakfast allocations
|
|
- breakfast_netvalue: Total breakfast net value (exc VAT)
|
|
- dinner_qty: Total dinner allocations
|
|
- dinner_netvalue: Total dinner net value (exc VAT)
|
|
"""
|
|
allocations_by_date = {}
|
|
gl_account_id_to_code = gl_account_id_to_code or {}
|
|
|
|
# Default VAT rates if not provided
|
|
breakfast_vat_rate = breakfast_vat_rate or Decimal("0.10")
|
|
dinner_vat_rate = dinner_vat_rate or Decimal("0.10")
|
|
|
|
logger.info(f"Processing {len(bookings)} bookings for allocations")
|
|
logger.info(f"Breakfast GL codes: {breakfast_gl_codes}, Dinner GL codes: {dinner_gl_codes}")
|
|
logger.info(f"VAT rates - Breakfast: {breakfast_vat_rate}, Dinner: {dinner_vat_rate}")
|
|
logger.info(f"GL account ID to code mapping has {len(gl_account_id_to_code)} entries")
|
|
|
|
for booking in bookings:
|
|
inventory_items = booking.get("inventory_items", [])
|
|
if not inventory_items:
|
|
continue
|
|
|
|
# Get guest count for this booking - PAX should be based on guests, not item qty
|
|
booking_guests = booking.get("num_guests", 1) or 1
|
|
|
|
for item in inventory_items:
|
|
# Newbook inventory items use gl_account_id (internal ID), not gl_code
|
|
gl_account_id = str(item.get("gl_account_id", ""))
|
|
# Translate to gl_code using our mapping
|
|
gl_code = gl_account_id_to_code.get(gl_account_id, "")
|
|
|
|
# Date is in stay_date field
|
|
item_date = item.get("stay_date", item.get("date", item.get("item_date")))
|
|
# PAX is the number of guests in the booking, not the inventory item qty
|
|
# (inventory items are typically 1 per booking per day, but represent all guests)
|
|
pax = booking_guests
|
|
# Amount field - Newbook returns gross (inc VAT)
|
|
gross_amount = Decimal(str(item.get("amount", item.get("net_amount", 0)) or 0))
|
|
|
|
if not item_date or not gl_code:
|
|
continue
|
|
|
|
if item_date not in allocations_by_date:
|
|
allocations_by_date[item_date] = {
|
|
"breakfast_qty": 0,
|
|
"breakfast_netvalue": Decimal("0"),
|
|
"dinner_qty": 0,
|
|
"dinner_netvalue": Decimal("0"),
|
|
}
|
|
|
|
# Check if this item matches breakfast or dinner GL codes
|
|
# Calculate net from gross: net = gross / (1 + vat_rate)
|
|
if gl_code in breakfast_gl_codes:
|
|
net_amount = gross_amount / (1 + breakfast_vat_rate)
|
|
allocations_by_date[item_date]["breakfast_qty"] += pax
|
|
allocations_by_date[item_date]["breakfast_netvalue"] += net_amount.quantize(Decimal("0.01"))
|
|
elif gl_code in dinner_gl_codes:
|
|
net_amount = gross_amount / (1 + dinner_vat_rate)
|
|
allocations_by_date[item_date]["dinner_qty"] += pax
|
|
allocations_by_date[item_date]["dinner_netvalue"] += net_amount.quantize(Decimal("0.01"))
|
|
|
|
logger.info(f"Found allocations for {len(allocations_by_date)} dates")
|
|
return allocations_by_date
|
|
|
|
async def get_site_list(self) -> list[dict]:
|
|
"""
|
|
Fetch site/room categories from Newbook and aggregate by room type.
|
|
|
|
Endpoint: site_list
|
|
|
|
Returns list of unique room types (aggregated from individual sites):
|
|
- id: Type name (used as ID since types don't have IDs)
|
|
- name: Type name (e.g., "Standard Room", "Overflow")
|
|
- type: Same as name
|
|
- count: Number of sites/rooms of this type
|
|
"""
|
|
response = await self._request("site_list")
|
|
|
|
# Log the raw response structure to understand it
|
|
logger.info(f"site_list raw response type: {type(response)}")
|
|
if isinstance(response, dict):
|
|
logger.info(f"site_list response keys: {response.keys()}")
|
|
|
|
items = response.get("data", response) if isinstance(response, dict) else response
|
|
|
|
# Log first few items to understand structure
|
|
if isinstance(items, list) and len(items) > 0:
|
|
logger.info(f"site_list first item keys: {items[0].keys() if isinstance(items[0], dict) else 'not a dict'}")
|
|
logger.info(f"site_list first 3 items: {items[:3]}")
|
|
|
|
# Aggregate by room type name AND build category_id -> type mapping
|
|
type_counts = {} # type_name -> count
|
|
category_id_to_type = {} # category_id -> room_type (for booking filtering)
|
|
if isinstance(items, list):
|
|
for item in items:
|
|
# Get category_id (what bookings use to identify room type)
|
|
category_id = item.get("category_id") or item.get("site_category_id")
|
|
|
|
# Try various field names that might contain the room type/category
|
|
room_type = (
|
|
item.get("site_type") or
|
|
item.get("type") or
|
|
item.get("category") or
|
|
item.get("category_name") or
|
|
item.get("room_type") or
|
|
item.get("site_category") or
|
|
""
|
|
)
|
|
|
|
# Fall back to site_name if no type found (shouldn't happen normally)
|
|
if not room_type:
|
|
room_type = item.get("site_name", item.get("name", "Unknown"))
|
|
logger.debug(f"No type field found, using site_name: {room_type}")
|
|
|
|
if room_type:
|
|
type_counts[room_type] = type_counts.get(room_type, 0) + 1
|
|
|
|
# Build mapping from category_id to type
|
|
if category_id and room_type:
|
|
category_id_to_type[str(category_id)] = room_type
|
|
|
|
# Convert to list format
|
|
categories = []
|
|
for type_name, count in sorted(type_counts.items()):
|
|
categories.append({
|
|
"id": type_name, # Use type name as ID
|
|
"name": type_name,
|
|
"type": type_name,
|
|
"count": count,
|
|
})
|
|
|
|
logger.info(f"Fetched {len(categories)} unique room types from Newbook (from {sum(type_counts.values())} sites)")
|
|
logger.info(f"Built category_id to type mapping: {category_id_to_type}")
|
|
return categories, category_id_to_type
|
|
|
|
def process_bookings_for_guests(
|
|
self,
|
|
bookings: list[dict],
|
|
included_room_types: list[str] = None,
|
|
category_id_to_type: dict[str, str] = None
|
|
) -> dict[str, int]:
|
|
"""
|
|
Process bookings to count total guests per stay date.
|
|
|
|
Args:
|
|
bookings: List of bookings from get_bookings()
|
|
included_room_types: List of room type names to include (None = all)
|
|
category_id_to_type: Mapping from category_id (e.g. "1") to room type (e.g. "Standard")
|
|
|
|
Returns dict keyed by date string with guest count
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
|
|
guests_by_date = {}
|
|
total_bookings_processed = 0
|
|
total_guests_counted = 0
|
|
bookings_filtered_out = 0
|
|
category_id_to_type = category_id_to_type or {}
|
|
|
|
logger.info(f"Processing {len(bookings)} bookings for guest counts")
|
|
if included_room_types:
|
|
logger.info(f"Filtering to room types: {included_room_types}")
|
|
if category_id_to_type:
|
|
logger.info(f"Using category_id to type mapping: {category_id_to_type}")
|
|
|
|
# Resolve room types for all bookings using the mapping
|
|
resolved_room_types = set()
|
|
for b in bookings:
|
|
category_id = b.get("category_id", "")
|
|
resolved_type = category_id_to_type.get(category_id, b.get("room_type", category_id))
|
|
resolved_room_types.add(resolved_type)
|
|
|
|
logger.info(f"Resolved room types in bookings: {resolved_room_types}")
|
|
|
|
# Log matching analysis if filtering
|
|
if included_room_types:
|
|
matching = resolved_room_types & set(included_room_types)
|
|
non_matching = resolved_room_types - set(included_room_types)
|
|
logger.info(f"Room type matching: {len(matching)} match, {len(non_matching)} don't match")
|
|
if non_matching:
|
|
logger.info(f"Non-matching room types: {non_matching}")
|
|
|
|
for booking in bookings:
|
|
# Get category_id and resolve to room type
|
|
category_id = booking.get("category_id", "")
|
|
room_type = category_id_to_type.get(category_id, booking.get("room_type", category_id))
|
|
|
|
# Filter by room type if specified
|
|
if included_room_types and room_type not in included_room_types:
|
|
bookings_filtered_out += 1
|
|
continue
|
|
|
|
# Get guest count and stay dates
|
|
# Don't use "or 1" - trust the computed value (0 is valid for infant-only bookings)
|
|
num_guests = booking.get("num_guests", 0)
|
|
check_in = booking.get("check_in_date")
|
|
check_out = booking.get("check_out_date")
|
|
nights = booking.get("nights", 1)
|
|
|
|
if not check_in:
|
|
continue
|
|
|
|
total_bookings_processed += 1
|
|
total_guests_counted += num_guests
|
|
|
|
# Parse check-in date
|
|
if isinstance(check_in, str):
|
|
try:
|
|
check_in_date = datetime.fromisoformat(check_in.split("T")[0]).date()
|
|
except ValueError:
|
|
continue
|
|
else:
|
|
check_in_date = check_in
|
|
|
|
# Calculate stay dates (guest is present from check-in through day before check-out)
|
|
if check_out:
|
|
if isinstance(check_out, str):
|
|
try:
|
|
check_out_date = datetime.fromisoformat(check_out.split("T")[0]).date()
|
|
except ValueError:
|
|
check_out_date = check_in_date + timedelta(days=nights or 1)
|
|
else:
|
|
check_out_date = check_out
|
|
else:
|
|
check_out_date = check_in_date + timedelta(days=nights or 1)
|
|
|
|
# Add guests to each stay date (not including checkout day)
|
|
current_date = check_in_date
|
|
while current_date < check_out_date:
|
|
date_str = current_date.isoformat()
|
|
if date_str not in guests_by_date:
|
|
guests_by_date[date_str] = 0
|
|
guests_by_date[date_str] += num_guests
|
|
current_date += timedelta(days=1)
|
|
|
|
logger.info(f"Guest count summary: processed {total_bookings_processed} bookings, {total_guests_counted} total guests, {bookings_filtered_out} filtered by room type")
|
|
logger.info(f"Calculated guest counts for {len(guests_by_date)} dates")
|
|
if guests_by_date:
|
|
sample_dates = list(guests_by_date.items())[:3]
|
|
logger.info(f"Sample guest counts: {sample_dates}")
|
|
return guests_by_date
|
|
|
|
def process_bookings_for_arrivals(
|
|
self,
|
|
bookings: list[dict],
|
|
included_room_types: list[str] = None,
|
|
category_id_to_type: dict[str, str] = None
|
|
) -> dict[str, dict]:
|
|
"""
|
|
Process bookings to extract arrival information per check-in date.
|
|
|
|
Args:
|
|
bookings: List of bookings from get_bookings()
|
|
included_room_types: Optional room type filter (same as guest count filtering)
|
|
category_id_to_type: Mapping from category_id to room type name
|
|
|
|
Returns dict keyed by check-in date string with:
|
|
{
|
|
"2026-01-20": {
|
|
"count": 6,
|
|
"ids": ["12345", "12346", ...],
|
|
"details": [
|
|
{
|
|
"booking_id": "12345",
|
|
"booking_reference": "NB-001",
|
|
"num_guests": 2,
|
|
"room_type": "Standard",
|
|
"status": "confirmed"
|
|
},
|
|
...
|
|
]
|
|
}
|
|
}
|
|
"""
|
|
from datetime import datetime
|
|
|
|
arrivals_by_date = {}
|
|
category_id_to_type = category_id_to_type or {}
|
|
|
|
logger.info(f"Processing {len(bookings)} bookings for arrival tracking")
|
|
if included_room_types:
|
|
logger.info(f"Filtering to room types: {included_room_types}")
|
|
|
|
for booking in bookings:
|
|
# Apply room type filter (consistent with guest count filtering)
|
|
category_id = booking.get("category_id", "")
|
|
room_type = category_id_to_type.get(category_id, booking.get("room_type", category_id))
|
|
|
|
if included_room_types and room_type not in included_room_types:
|
|
continue
|
|
|
|
# Skip cancelled bookings
|
|
if booking.get("status", "").lower() == "cancelled":
|
|
continue
|
|
|
|
# Get check-in date (this is the arrival date)
|
|
check_in = booking.get("check_in_date")
|
|
if not check_in:
|
|
continue
|
|
|
|
# Parse to date string
|
|
if isinstance(check_in, str):
|
|
try:
|
|
check_in_date = datetime.fromisoformat(check_in.split("T")[0]).date()
|
|
except ValueError:
|
|
continue
|
|
else:
|
|
check_in_date = check_in
|
|
|
|
date_str = check_in_date.isoformat()
|
|
|
|
# Initialize date entry if needed
|
|
if date_str not in arrivals_by_date:
|
|
arrivals_by_date[date_str] = {
|
|
"count": 0,
|
|
"ids": [],
|
|
"details": []
|
|
}
|
|
|
|
# Add arrival
|
|
arrivals_by_date[date_str]["count"] += 1
|
|
arrivals_by_date[date_str]["ids"].append(str(booking.get("booking_id", "")))
|
|
arrivals_by_date[date_str]["details"].append({
|
|
"booking_id": str(booking.get("booking_id", "")),
|
|
"booking_reference": booking.get("booking_reference", ""),
|
|
"num_guests": booking.get("num_guests", 0),
|
|
"room_type": room_type,
|
|
"status": booking.get("status", "")
|
|
})
|
|
|
|
logger.info(f"Found arrivals for {len(arrivals_by_date)} dates")
|
|
if arrivals_by_date:
|
|
sample = list(arrivals_by_date.items())[:3]
|
|
logger.info(f"Sample arrivals: {sample}")
|
|
|
|
return arrivals_by_date
|
|
|
|
async def get_charges_list(
|
|
self,
|
|
date_from: date,
|
|
date_to: date,
|
|
account_for: str = None
|
|
) -> list[dict]:
|
|
"""
|
|
Fetch charges list from Newbook with pagination support.
|
|
|
|
Endpoint: charges_list
|
|
|
|
Args:
|
|
date_from: Period start (charges raised or voided within this period)
|
|
date_to: Period end
|
|
account_for: Optional filter by account type (leads, guests, bookings, companies, travel_agents)
|
|
|
|
Returns list of charges with:
|
|
- id: Charge ID
|
|
- gl_account_id: GL Account ID
|
|
- gl_account_code: GL Account code
|
|
- description: Charge description (e.g., "Ticket: 22900 - 1 x Venison Bourguignon")
|
|
- amount_ex_tax: Net amount (exc tax)
|
|
- amount_inc_tax: Gross amount (inc tax)
|
|
- generated_when: When the charge was created
|
|
- voided_when: When the charge was voided (None if not voided)
|
|
- voided_by: Who voided it ("0" if not voided)
|
|
"""
|
|
charges = []
|
|
data_offset = 0
|
|
data_limit = 1000 # Max allowed by Newbook
|
|
total_fetched = 0
|
|
|
|
while True:
|
|
payload = {
|
|
"period_from": f"{date_from.isoformat()} 00:00:00",
|
|
"period_to": f"{date_to.isoformat()} 23:59:59",
|
|
"data_offset": data_offset,
|
|
"data_limit": data_limit,
|
|
}
|
|
|
|
if account_for:
|
|
payload["account_for"] = account_for
|
|
|
|
logger.info(f"Fetching charges: {date_from} to {date_to}, offset={data_offset}, limit={data_limit}")
|
|
|
|
response = await self._request("charges_list", payload)
|
|
|
|
# Get pagination info from response
|
|
data_total = response.get("data_total", 0)
|
|
data_count = response.get("data_count", 0)
|
|
|
|
items = response.get("data", response) if isinstance(response, dict) else response
|
|
|
|
if isinstance(items, list):
|
|
for item in items:
|
|
charges.append({
|
|
"id": item.get("id"),
|
|
"account_id": item.get("account_id"),
|
|
"account_for": item.get("account_for"),
|
|
"gl_account_id": str(item.get("gl_account_id", "")),
|
|
"gl_account_code": item.get("gl_account_code", ""),
|
|
"gl_category_id": item.get("gl_category_id"),
|
|
"description": item.get("description", ""),
|
|
"amount": Decimal(str(item.get("amount", 0) or 0)),
|
|
"amount_ex_tax": Decimal(str(item.get("amount_ex_tax", 0) or 0)),
|
|
"amount_inc_tax": Decimal(str(item.get("amount_inc_tax", 0) or 0)),
|
|
"tax": Decimal(str(item.get("tax", 0) or 0)),
|
|
"generated_when": item.get("generated_when"),
|
|
"voided_when": item.get("voided_when"),
|
|
"voided_by": str(item.get("voided_by", "0")),
|
|
})
|
|
|
|
total_fetched += len(items)
|
|
|
|
logger.info(f"Charges page: got {data_count} items, total available: {data_total}, fetched so far: {total_fetched}")
|
|
|
|
# Check if we've fetched all records
|
|
if data_count == 0 or total_fetched >= data_total or len(items) < data_limit:
|
|
break
|
|
|
|
# Move to next page
|
|
data_offset += data_limit
|
|
|
|
logger.info(f"Fetched {len(charges)} total charges from Newbook ({date_from} to {date_to})")
|
|
return charges
|