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:
commit
8d688b459d
10003 changed files with 1928395 additions and 0 deletions
959
backend/services/newbook_api.py
Normal file
959
backend/services/newbook_api.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue