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>
This commit is contained in:
jtricerolph 2026-07-12 12:15:39 +00:00
commit 8d688b459d
10003 changed files with 1928395 additions and 0 deletions

View file

@ -0,0 +1,959 @@
"""
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