forecasting/backend/jobs/data_sync.py
jtricerolph aeb99650bd NewBook credentials from central Settings service
Stack-wide NewBook config lives in the Settings app (LXC 116) and is
fetched live via SETTINGS_URL/SETTINGS_SECRET — same pattern as cashup,
room-planner and maintenance. App-local system_config credentials remain
as a fallback for standalone/dev use. The app's Settings → Newbook page
no longer edits credentials; it points to the central app and keeps
Test Connection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 19:36:41 +00:00

1260 lines
No EOL
56 KiB
Python

"""
Data sync job - pulls data from Newbook and Resos APIs
"""
import json
import logging
from datetime import date, datetime, timedelta
from typing import Optional, Dict, Set
from sqlalchemy import text
from database import SyncSessionLocal
from services.newbook_client import NewbookClient
from services.resos_client import ResosClient
logger = logging.getLogger(__name__)
def get_config_value(db, key: str) -> Optional[str]:
"""Get a configuration value from system_config table."""
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = :key"),
{"key": key}
)
row = result.fetchone()
return row.config_value if row else None
def load_newbook_credentials(db) -> dict:
"""
Load Newbook API credentials — central Settings service first (stack-wide
config), falling back to the app-local database config.
"""
from services.central_settings import get_newbook_credentials_sync
central = get_newbook_credentials_sync()
if central:
return central
import base64
def decrypt(value: str) -> str:
"""Decrypt base64 encoded value"""
if not value:
return None
try:
return base64.b64decode(value.encode()).decode()
except:
return value
# Get credentials (some are encrypted)
api_key_result = db.execute(
text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = 'newbook_api_key'")
)
api_key_row = api_key_result.fetchone()
password_result = db.execute(
text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = 'newbook_password'")
)
password_row = password_result.fetchone()
username = get_config_value(db, 'newbook_username')
region = get_config_value(db, 'newbook_region')
api_key = None
if api_key_row and api_key_row.config_value:
api_key = decrypt(api_key_row.config_value) if api_key_row.is_encrypted else api_key_row.config_value
password = None
if password_row and password_row.config_value:
password = decrypt(password_row.config_value) if password_row.is_encrypted else password_row.config_value
return {
'api_key': api_key,
'username': username,
'password': password,
'region': region
}
def load_resos_credentials(db) -> dict:
"""Load Resos API credentials from database config."""
import base64
def decrypt(value: str) -> str:
"""Decrypt base64 encoded value"""
if not value:
return None
try:
return base64.b64decode(value.encode()).decode()
except:
return value
# Get API key (may be encrypted)
api_key_result = db.execute(
text("SELECT config_value, is_encrypted FROM system_config WHERE config_key = 'resos_api_key'")
)
api_key_row = api_key_result.fetchone()
api_key = None
if api_key_row and api_key_row.config_value:
api_key = decrypt(api_key_row.config_value) if api_key_row.is_encrypted else api_key_row.config_value
return {'api_key': api_key}
def load_gl_config(db) -> tuple:
"""
Load GL code configuration for identifying breakfast/dinner items.
Returns:
tuple: (breakfast_codes, dinner_codes, breakfast_vat, dinner_vat, gl_mapping)
"""
# Load configured GL codes
breakfast_gl_codes = get_config_value(db, 'newbook_breakfast_gl_codes') or ''
dinner_gl_codes = get_config_value(db, 'newbook_dinner_gl_codes') or ''
# Parse into sets
breakfast_codes = set(c.strip() for c in breakfast_gl_codes.split(',') if c.strip())
dinner_codes = set(c.strip() for c in dinner_gl_codes.split(',') if c.strip())
# Load VAT rates
breakfast_vat = float(get_config_value(db, 'newbook_breakfast_vat_rate') or 0.20)
dinner_vat = float(get_config_value(db, 'newbook_dinner_vat_rate') or 0.20)
# Build GL account ID → GL code mapping from cached lookup table
gl_mapping: Dict[str, str] = {}
result = db.execute(text("SELECT gl_account_id, gl_code FROM newbook_gl_accounts"))
for row in result.fetchall():
if row.gl_account_id and row.gl_code:
gl_mapping[row.gl_account_id] = row.gl_code
logger.info(f"Loaded GL config: {len(breakfast_codes)} breakfast codes, {len(dinner_codes)} dinner codes, {len(gl_mapping)} mappings")
return breakfast_codes, dinner_codes, breakfast_vat, dinner_vat, gl_mapping
def process_inventory_items(
inventory_items: list,
gl_mapping: Dict[str, str],
breakfast_codes: Set[str],
dinner_codes: Set[str],
breakfast_vat: float,
dinner_vat: float
) -> dict:
"""
Process inventory items and categorize by GL code.
Returns dict with keys: breakfast_gross, breakfast_net, dinner_gross, dinner_net, other_items
"""
breakfast_gross = 0.0
dinner_gross = 0.0
other_items = []
for item in inventory_items:
gl_account_id = item.get('gl_account_id')
# Try to get GL code from mapping, fall back to gl_account_code in the item
gl_code = gl_mapping.get(gl_account_id) or item.get('gl_account_code') or ''
amount = float(item.get('amount', 0) or 0)
if gl_code in breakfast_codes:
breakfast_gross += amount
elif gl_code in dinner_codes:
dinner_gross += amount
else:
# Store non-breakfast/dinner items for reference
other_items.append({
'item_name': item.get('item_name'),
'gl_account_id': gl_account_id,
'gl_code': gl_code,
'amount': amount
})
# Calculate net values (gross / (1 + VAT rate))
breakfast_net = breakfast_gross / (1 + breakfast_vat) if breakfast_vat else breakfast_gross
dinner_net = dinner_gross / (1 + dinner_vat) if dinner_vat else dinner_gross
return {
'breakfast_gross': round(breakfast_gross, 2),
'breakfast_net': round(breakfast_net, 2),
'dinner_gross': round(dinner_gross, 2),
'dinner_net': round(dinner_net, 2),
'other_items': other_items if other_items else None
}
async def run_data_sync(
full_sync: bool = False,
triggered_by: str = "scheduler"
):
"""
Main data sync job - runs Newbook bookings, Newbook occupancy report, and Resos sync.
Args:
full_sync: If True, pulls all bookings. If False, only pulls changes since last sync.
triggered_by: Who/what triggered this sync
"""
logger.info(f"Starting data sync (full_sync={full_sync})")
try:
# Sync Newbook booking data
await sync_newbook_data(full_sync=full_sync, triggered_by=triggered_by)
# Sync Newbook occupancy report (provides available rooms, maintenance, official revenue)
# Daily sync: -7 days (catch corrections) to +365 days (future availability for forecasting)
# Can't forecast beyond what's available - need to know maintenance/blocked rooms
occ_from_date = date.today() - timedelta(days=7)
occ_to_date = date.today() + timedelta(days=365)
await sync_newbook_occupancy_report(occ_from_date, occ_to_date, triggered_by)
# Sync Resos data (still uses date range for now)
from_date = date.today() - timedelta(days=7)
to_date = date.today() + timedelta(days=365)
await sync_resos_data(from_date, to_date, triggered_by)
logger.info("Data sync completed successfully")
except Exception as e:
logger.error(f"Data sync failed: {e}")
raise
async def sync_newbook_data(
full_sync: bool = False,
from_date: Optional[date] = None,
to_date: Optional[date] = None,
triggered_by: str = "scheduler"
):
"""
Sync hotel bookings from Newbook.
Uses list_type="all" which returns all bookings including cancelled.
- full_sync=True: Fetches entire booking database (initial backfill)
- full_sync=False: Fetches only bookings modified since last successful sync
- from_date/to_date: If provided, fetches bookings staying during this period
"""
import sys
print(f"[SYNC] Starting Newbook sync (full_sync={full_sync})", flush=True)
sys.stdout.flush()
if from_date and to_date:
logger.info(f"Starting Newbook sync for stay dates {from_date} to {to_date}")
else:
logger.info(f"Starting Newbook sync (full_sync={full_sync})")
db = next(iter([SyncSessionLocal()]))
# Load credentials from database
creds = load_newbook_credentials(db)
print(f"[SYNC] Loaded credentials: api_key={'set' if creds['api_key'] else 'empty'}, username={creds['username']}, region={creds['region']}", flush=True)
logger.info(f"Loaded Newbook credentials: api_key={'set' if creds['api_key'] else 'empty'}, username={creds['username']}, region={creds['region']}")
# Load GL configuration for inventory item categorization
breakfast_codes, dinner_codes, breakfast_vat, dinner_vat, gl_mapping = load_gl_config(db)
try:
# Get last successful sync timestamp for incremental sync
modified_since = None
if not full_sync:
result = db.execute(
text("""
SELECT completed_at FROM sync_log
WHERE source = 'newbook' AND status = 'success'
ORDER BY completed_at DESC LIMIT 1
""")
)
row = result.fetchone()
if row and row.completed_at:
modified_since = row.completed_at.isoformat()
logger.info(f"Incremental sync: fetching bookings modified since {modified_since}")
else:
logger.info("No previous successful sync found, performing full sync")
# Log sync start
db.execute(
text("""
INSERT INTO sync_log (sync_type, source, started_at, status, triggered_by)
VALUES ('bookings', 'newbook', NOW(), 'running', :triggered_by)
RETURNING id
"""),
{"triggered_by": triggered_by}
)
db.commit()
print("[SYNC] Creating NewbookClient...", flush=True)
async with NewbookClient(
api_key=creds['api_key'],
username=creds['username'],
password=creds['password'],
region=creds['region']
) as client:
# Test connection
print("[SYNC] Testing connection...", flush=True)
if not await client.test_connection():
print("[SYNC] Connection test FAILED!", flush=True)
raise Exception("Newbook connection failed")
print("[SYNC] Connection test passed", flush=True)
# Fetch bookings based on sync mode
print(f"[SYNC] Fetching bookings (modified_since={modified_since})...", flush=True)
if from_date and to_date:
# Date range sync - fetch bookings staying during this period
# This is useful for testing or targeted syncs
bookings = await client.get_bookings_by_stay_dates(
from_date=from_date,
to_date=to_date,
list_type="staying" # Gets all bookings staying during this period
)
print(f"[SYNC] Fetched {len(bookings)} bookings (stay dates: {from_date} to {to_date})", flush=True)
logger.info(f"Fetched {len(bookings)} bookings from Newbook (stay dates: {from_date} to {to_date})")
else:
# Standard sync - all bookings (optionally filtered by modification date)
bookings = await client.get_bookings(modified_since=modified_since)
print(f"[SYNC] Fetched {len(bookings)} bookings from Newbook", flush=True)
logger.info(f"Fetched {len(bookings)} bookings from Newbook")
records_created = 0
records_updated = 0
print(f"[SYNC] Starting to process {len(bookings)} bookings...", flush=True)
for i, booking in enumerate(bookings):
newbook_id = booking.get("booking_id")
# Progress every 100 bookings
if i > 0 and i % 100 == 0:
print(f"[SYNC] Processed {i}/{len(bookings)} bookings, {records_created} created", flush=True)
# Skip bookings without a valid ID
if not newbook_id:
logger.warning(f"Skipping booking without booking_id: {booking.get('booking_reference_id', 'unknown')}")
continue
# Convert to string for VARCHAR column
newbook_id = str(newbook_id)
try:
# Create sanitized copy of raw JSON (remove guest PII)
raw_booking = {k: v for k, v in booking.items() if k != "guests"}
raw_json_str = json.dumps(raw_booking)
# Parse arrival/departure - API returns "2026-02-16 15:00:00" format
arrival_raw = booking.get("booking_arrival")
departure_raw = booking.get("booking_departure")
arrival = arrival_raw.split(" ")[0] if arrival_raw else None # Extract date part only
departure = departure_raw.split(" ")[0] if departure_raw else None
status = booking.get("booking_status")
category_id = str(booking.get("category_id")) if booking.get("category_id") else None
category_name = booking.get("category_name")
# Upsert room category to lookup table
if category_id:
db.execute(
text("""
INSERT INTO room_categories (category_id, category_name)
VALUES (:category_id, :category_name)
ON CONFLICT (category_id) DO UPDATE SET
category_name = COALESCE(:category_name, room_categories.category_name),
updated_at = NOW()
"""),
{"category_id": category_id, "category_name": category_name}
)
# Upsert booking
result = db.execute(
text("""
INSERT INTO newbook_bookings (
newbook_id, booking_reference, arrival_date, departure_date,
nights, adults, children, infants, total_guests,
category_id, room_type, status, total_amount, tariff_name,
booking_source_name, raw_json, fetched_at
) VALUES (
:newbook_id, :reference, :arrival, :departure,
:nights, :adults, :children, :infants, :total_guests,
:category_id, :room_type, :status, :total, :tariff_name,
:source, :raw_json, NOW()
)
ON CONFLICT (newbook_id) DO UPDATE SET
status = :status,
total_amount = :total,
raw_json = :raw_json,
fetched_at = NOW()
"""),
{
"newbook_id": newbook_id,
"reference": booking.get("booking_reference_id"),
"arrival": arrival,
"departure": departure,
"nights": booking.get("booking_length"),
"adults": int(booking.get("booking_adults") or 0),
"children": int(booking.get("booking_children") or 0),
"infants": int(booking.get("booking_infants") or 0),
"total_guests": int(booking.get("booking_adults") or 0) + int(booking.get("booking_children") or 0),
"category_id": category_id,
"room_type": category_name,
"status": status,
"total": booking.get("booking_total"),
"tariff_name": booking.get("tariff_name"),
"source": booking.get("booking_source_name"),
"raw_json": raw_json_str
}
)
if result.rowcount > 0:
records_created += 1
# Get the booking's internal ID for child table references
booking_db_id = None
id_result = db.execute(
text("SELECT id FROM newbook_bookings WHERE newbook_id = :newbook_id"),
{"newbook_id": newbook_id}
)
id_row = id_result.fetchone()
if id_row:
booking_db_id = id_row.id
# Get inventory items and group by stay_date
inventory_items = booking.get("inventory_items", [])
inventory_by_date = {}
for item in inventory_items:
stay_date = item.get("stay_date")
if stay_date:
if stay_date not in inventory_by_date:
inventory_by_date[stay_date] = []
inventory_by_date[stay_date].append(item)
# Extract and store per-night tariff breakdown + inventory data
tariffs_quoted = booking.get("tariffs_quoted", [])
if booking_db_id and tariffs_quoted:
for tariff in tariffs_quoted:
stay_date = tariff.get("stay_date")
if stay_date:
# Process inventory items for this night using GL code matching
date_inventory = inventory_by_date.get(stay_date, [])
inv_data = process_inventory_items(
date_inventory,
gl_mapping,
breakfast_codes,
dinner_codes,
breakfast_vat,
dinner_vat
)
db.execute(
text("""
INSERT INTO newbook_booking_nights (
booking_id, stay_date, tariff_quoted_id, tariff_label,
tariff_type_id, tariff_applied_id, original_amount,
calculated_amount, charge_amount, taxes, occupant_charges,
breakfast_gross, breakfast_net, dinner_gross, dinner_net,
other_items, fetched_at
) VALUES (
:booking_id, :stay_date, :tariff_quoted_id, :tariff_label,
:tariff_type_id, :tariff_applied_id, :original_amount,
:calculated_amount, :charge_amount, :taxes, :occupant_charges,
:breakfast_gross, :breakfast_net, :dinner_gross, :dinner_net,
:other_items, NOW()
)
ON CONFLICT (booking_id, stay_date) DO UPDATE SET
tariff_label = :tariff_label,
original_amount = :original_amount,
calculated_amount = :calculated_amount,
charge_amount = :charge_amount,
taxes = :taxes,
occupant_charges = :occupant_charges,
breakfast_gross = :breakfast_gross,
breakfast_net = :breakfast_net,
dinner_gross = :dinner_gross,
dinner_net = :dinner_net,
other_items = :other_items,
fetched_at = NOW()
"""),
{
"booking_id": booking_db_id,
"stay_date": stay_date,
"tariff_quoted_id": tariff.get("tariff_quoted_id"),
"tariff_label": tariff.get("label"),
"tariff_type_id": tariff.get("type_id"),
"tariff_applied_id": tariff.get("tariff_applied_id"),
"original_amount": tariff.get("original_amount"),
"calculated_amount": tariff.get("calculated_amount"),
"charge_amount": tariff.get("charge_amount"),
"taxes": json.dumps(tariff.get("taxes", [])),
"occupant_charges": json.dumps(tariff.get("occupant_charges", [])),
"breakfast_gross": inv_data['breakfast_gross'],
"breakfast_net": inv_data['breakfast_net'],
"dinner_gross": inv_data['dinner_gross'],
"dinner_net": inv_data['dinner_net'],
"other_items": json.dumps(inv_data['other_items']) if inv_data['other_items'] else None
}
)
# Queue all stay dates for aggregation (arrival to departure-1)
# Each night the guest is "in house" needs recalculating
if arrival and departure:
arrival_date = date.fromisoformat(arrival) if isinstance(arrival, str) else arrival
departure_date = date.fromisoformat(departure) if isinstance(departure, str) else departure
current = arrival_date
while current < departure_date:
db.execute(
text("""
INSERT INTO aggregation_queue (date, source, reason, booking_id)
VALUES (:date, 'newbook', :reason, :booking_id)
ON CONFLICT (date, source, booking_id) DO UPDATE SET
queued_at = NOW(),
aggregated_at = NULL
"""),
{
"date": current,
"reason": f"booking_{status.lower() if status else 'modified'}",
"booking_id": str(newbook_id)
}
)
current += timedelta(days=1)
# Commit this booking immediately
db.commit()
except Exception as booking_error:
print(f"[SYNC] ERROR processing booking {newbook_id}: {booking_error}", flush=True)
logger.error(f"Error processing booking {newbook_id}: {booking_error}")
db.rollback() # Rollback only this booking's changes
continue # Skip this booking and continue with the next
# Update sync log
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'success',
records_fetched = :fetched, records_created = :created
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"fetched": len(bookings), "created": records_created}
)
db.commit()
print(f"[SYNC] Sync completed: {records_created} records processed", flush=True)
logger.info(f"Newbook sync completed: {records_created} records processed")
except Exception as e:
print(f"[SYNC] SYNC FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
logger.error(f"Newbook sync failed: {e}")
try:
db.rollback() # Rollback any failed transaction first
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'failed', error_message = :error
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"error": str(e)[:500]} # Truncate error message to avoid issues
)
db.commit()
except Exception as log_error:
logger.error(f"Failed to update sync_log: {log_error}")
raise
finally:
db.close()
def load_resos_custom_field_mappings(db) -> Dict[str, dict]:
"""
Load custom field mappings from resos_custom_field_mapping table.
Returns dict: {field_id: {"maps_to": "hotel_guest", "value_for_true": "Yes"}}
"""
result = db.execute(text("""
SELECT field_id, maps_to, value_for_true
FROM resos_custom_field_mapping
WHERE maps_to != 'ignore'
"""))
mappings = {}
for row in result.fetchall():
mappings[row.field_id] = {
"maps_to": row.maps_to,
"value_for_true": row.value_for_true
}
return mappings
async def sync_resos_data(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""
Sync restaurant bookings from Resos.
"""
logger.info(f"Starting Resos sync from {from_date} to {to_date}")
db = next(iter([SyncSessionLocal()]))
try:
# Log sync start
db.execute(
text("""
INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by)
VALUES ('bookings', 'resos', NOW(), 'running', :from_date, :to_date, :triggered_by)
"""),
{"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by}
)
db.commit()
# Load Resos credentials from database
resos_creds = load_resos_credentials(db)
if not resos_creds.get('api_key'):
raise Exception("Resos API key not configured in database")
# Load custom field mappings from database
cf_mappings = load_resos_custom_field_mappings(db)
logger.info(f"Loaded {len(cf_mappings)} Resos custom field mappings")
async with ResosClient(api_key=resos_creds['api_key']) as client:
# Test connection
if not await client.test_connection():
raise Exception("Resos connection failed")
# Fetch bookings
bookings = await client.get_bookings(from_date, to_date)
logger.info(f"Fetched {len(bookings)} bookings from Resos")
records_created = 0
for booking in bookings:
# Parse guest info
guest = booking.get("guest", {})
resos_id = booking.get("_id")
booking_date = booking.get("date")
status = booking.get("status")
# Extract custom fields using configured mappings
custom_fields = booking.get("customFields", [])
is_hotel_guest = None
is_dbb = None
is_package = None
hotel_booking_number = None
allergies = None
for cf in custom_fields:
# Get field ID - Resos may use 'id' or '_id'
field_id = cf.get("id") or cf.get("_id") or cf.get("fieldId")
# For radio/checkbox fields, use multipleChoiceValueName (human-readable label)
# Fall back to value field for text fields
field_value_label = cf.get("multipleChoiceValueName") or cf.get("value")
field_value = cf.get("value")
# Check if this field has a configured mapping
if field_id and field_id in cf_mappings:
mapping = cf_mappings[field_id]
maps_to = mapping["maps_to"]
value_for_true = mapping.get("value_for_true")
# Debug logging for matched mappings (first 5 records only)
if records_created < 5:
logger.info(f"Matched mapping: field_id={field_id}, maps_to={maps_to}, label={field_value_label}, value_for_true={value_for_true}")
if maps_to == "hotel_guest":
# For boolean fields, check label against value_for_true
if value_for_true:
is_hotel_guest = str(field_value_label) == str(value_for_true)
else:
is_hotel_guest = str(field_value_label).lower() in ("yes", "true", "1")
elif maps_to == "dbb":
if value_for_true:
is_dbb = str(field_value_label) == str(value_for_true)
else:
is_dbb = str(field_value_label).lower() in ("yes", "true", "1")
elif maps_to == "package":
if value_for_true:
is_package = str(field_value) == str(value_for_true)
else:
is_package = str(field_value).lower() in ("yes", "true", "1")
elif maps_to == "booking_number":
hotel_booking_number = str(field_value) if field_value else None
elif maps_to == "allergies":
allergies = str(field_value) if field_value else None
# Fallback to keyword matching if no mapping configured
elif not cf_mappings:
field_name = cf.get("name", "").lower()
if "hotel" in field_name and "guest" in field_name:
is_hotel_guest = str(field_value).lower() in ("yes", "true", "1")
elif "dbb" in field_name or "dinner bed breakfast" in field_name:
is_dbb = str(field_value).lower() in ("yes", "true", "1")
elif "package" in field_name:
is_package = str(field_value).lower() in ("yes", "true", "1")
elif "booking" in field_name and "number" in field_name:
hotel_booking_number = str(field_value) if field_value else None
elif "allerg" in field_name:
allergies = str(field_value) if field_value else None
# Upsert booking with full data
db.execute(
text("""
INSERT INTO resos_bookings (
resos_id, booking_date, booking_time, covers,
status, source, opening_hour_id, table_name, table_area,
is_hotel_guest, is_dbb, is_package, hotel_booking_number, allergies,
notes, fetched_at
) VALUES (
:resos_id, :booking_date, :booking_time, :covers,
:status, :source, :opening_hour_id, :table_name, :table_area,
:is_hotel_guest, :is_dbb, :is_package, :hotel_booking_number, :allergies,
:notes, NOW()
)
ON CONFLICT (resos_id) DO UPDATE SET
status = :status,
covers = :covers,
is_hotel_guest = COALESCE(:is_hotel_guest, resos_bookings.is_hotel_guest),
is_dbb = COALESCE(:is_dbb, resos_bookings.is_dbb),
is_package = COALESCE(:is_package, resos_bookings.is_package),
fetched_at = NOW()
"""),
{
"resos_id": resos_id,
"booking_date": booking_date,
"booking_time": booking.get("time"),
"covers": booking.get("people"),
"status": status,
"source": booking.get("source"),
"opening_hour_id": booking.get("openingHourId"),
"table_name": booking.get("tables", [{}])[0].get("name") if booking.get("tables") else None,
"table_area": booking.get("tables", [{}])[0].get("area", {}).get("name") if booking.get("tables") else None,
"is_hotel_guest": is_hotel_guest,
"is_dbb": is_dbb,
"is_package": is_package,
"hotel_booking_number": hotel_booking_number,
"allergies": allergies,
"notes": str(booking.get("restaurantNotes", []))
}
)
records_created += 1
# Queue date for aggregation
if booking_date:
db.execute(
text("""
INSERT INTO aggregation_queue (date, source, reason, booking_id)
VALUES (:date, 'resos', :reason, :booking_id)
ON CONFLICT (date, source, booking_id) DO UPDATE SET
queued_at = NOW(),
aggregated_at = NULL
"""),
{
"date": booking_date,
"reason": f"booking_{status.lower() if status else 'modified'}",
"booking_id": str(resos_id)
}
)
db.commit()
# Update sync log
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'success',
records_fetched = :fetched, records_created = :created
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'resos' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"fetched": len(bookings), "created": records_created}
)
db.commit()
logger.info(f"Resos sync completed: {records_created} records processed")
except Exception as e:
logger.error(f"Resos sync failed: {e}")
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'failed', error_message = :error
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'resos' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"error": str(e)}
)
db.commit()
raise
finally:
db.close()
async def sync_newbook_occupancy_report(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""
Sync occupancy report from Newbook's reports_occupancy endpoint.
This provides:
- Available rooms per category (total capacity minus maintenance/offline)
- Official occupied rooms
- Maintenance/offline room counts
- Official revenue figures (gross)
The 'available' field is crucial for accurate occupancy % calculations
as it accounts for rooms taken offline for maintenance.
API returns all categories with all dates in a single response (no pagination).
"""
import sys
print(f"[SYNC] Starting Newbook occupancy report sync ({from_date} to {to_date})", flush=True)
sys.stdout.flush()
logger.info(f"Starting Newbook occupancy report sync from {from_date} to {to_date}")
db = next(iter([SyncSessionLocal()]))
# Load credentials
creds = load_newbook_credentials(db)
# Get accommodation VAT rate for calculating net revenue
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'")
)
row = result.fetchone()
accommodation_vat = float(row.config_value) if row and row.config_value else 0.20
try:
# Log sync start
db.execute(
text("""
INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by)
VALUES ('occupancy_report', 'newbook', NOW(), 'running', :from_date, :to_date, :triggered_by)
"""),
{"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by}
)
db.commit()
print("[SYNC] Creating NewbookClient for occupancy report...", flush=True)
async with NewbookClient(
api_key=creds['api_key'],
username=creds['username'],
password=creds['password'],
region=creds['region']
) as client:
# Test connection
if not await client.test_connection():
raise Exception("Newbook connection failed")
records_created = 0
# Track daily totals as we process categories
# {date: {available: X, occupied: X, maintenance: X, revenue_gross: X, revenue_net: X}}
daily_totals = {}
print(f"[SYNC] Fetching occupancy report: {from_date} to {to_date}...", flush=True)
report_data = await client.get_occupancy_report(from_date, to_date)
print(f"[SYNC] Received {len(report_data)} categories", flush=True)
# Response format is a list of categories, each with nested occupancy by date
for category in report_data:
category_id = str(category.get("category_id", ""))
category_name = category.get("category_name", "")
occupancy_data = category.get("occupancy", {})
if not category_id:
logger.warning(f"Skipping category without ID: {category}")
continue
# Process each date in the occupancy data
for date_str, day_data in occupancy_data.items():
try:
# Parse date (could be "2024-08-01" format)
report_date = date.fromisoformat(date_str) if isinstance(date_str, str) else date_str
available = int(day_data.get("available", 0) or 0)
occupied = int(day_data.get("occupied", 0) or 0)
maintenance = int(day_data.get("maintenance", 0) or 0)
allotted = int(day_data.get("allotted", 0) or 0)
revenue_gross = float(day_data.get("revenue_gross", 0) or 0)
# Calculate net revenue (gross / (1 + VAT rate))
# Use provided revenue_net if available, otherwise calculate
revenue_net = day_data.get("revenue_net")
if revenue_net is None:
revenue_net = revenue_gross / (1 + accommodation_vat) if accommodation_vat else revenue_gross
else:
revenue_net = float(revenue_net)
# Calculate occupancy percentage
occupancy_pct = (occupied / available * 100) if available > 0 else 0
# Upsert into newbook_occupancy_report_data
db.execute(
text("""
INSERT INTO newbook_occupancy_report_data (
date, category_id, category_name,
available, occupied, maintenance, allotted,
revenue_gross, revenue_net, occupancy_pct, fetched_at
) VALUES (
:date, :category_id, :category_name,
:available, :occupied, :maintenance, :allotted,
:revenue_gross, :revenue_net, :occupancy_pct, NOW()
)
ON CONFLICT (date, category_id) DO UPDATE SET
category_name = :category_name,
available = :available,
occupied = :occupied,
maintenance = :maintenance,
allotted = :allotted,
revenue_gross = :revenue_gross,
revenue_net = :revenue_net,
occupancy_pct = :occupancy_pct,
fetched_at = NOW()
"""),
{
"date": report_date,
"category_id": category_id,
"category_name": category_name,
"available": available,
"occupied": occupied,
"maintenance": maintenance,
"allotted": allotted,
"revenue_gross": round(revenue_gross, 2),
"revenue_net": round(revenue_net, 2),
"occupancy_pct": round(occupancy_pct, 2)
}
)
records_created += 1
# Accumulate daily totals
if report_date not in daily_totals:
daily_totals[report_date] = {
"available": 0,
"occupied": 0,
"maintenance": 0,
"revenue_gross": 0.0,
"revenue_net": 0.0
}
daily_totals[report_date]["available"] += available
daily_totals[report_date]["occupied"] += occupied
daily_totals[report_date]["maintenance"] += maintenance
daily_totals[report_date]["revenue_gross"] += revenue_gross
daily_totals[report_date]["revenue_net"] += revenue_net
except Exception as day_error:
logger.error(f"Error processing occupancy for {category_id} on {date_str}: {day_error}")
continue
db.commit()
# Update sync log
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'success',
records_fetched = :fetched, records_created = :created
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND sync_type = 'occupancy_report' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"fetched": records_created, "created": records_created}
)
db.commit()
print(f"[SYNC] Occupancy report sync completed: {records_created} category records, {len(daily_totals)} daily totals", flush=True)
logger.info(f"Newbook occupancy report sync completed: {records_created} category records, {len(daily_totals)} daily totals")
except Exception as e:
print(f"[SYNC] Occupancy report sync FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
logger.error(f"Newbook occupancy report sync failed: {e}")
try:
db.rollback()
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'failed', error_message = :error
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND sync_type = 'occupancy_report' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"error": str(e)[:500]}
)
db.commit()
except Exception as log_error:
logger.error(f"Failed to update sync_log: {log_error}")
raise
finally:
db.close()
async def sync_newbook_earned_revenue(
from_date: date,
to_date: date,
triggered_by: str = "scheduler"
):
"""
Sync earned revenue from Newbook's report_earned_revenue endpoint.
This provides official financial figures by GL account - the declared
accounting revenue that flows into the books.
Uses accommodation_gl_codes config to identify which GL accounts are
room revenue vs other types (F&B, etc.).
Fetches day-by-day (API only returns daily breakdown when requesting single days).
Schedule: Historical backfill + daily last 7 days to catch adjustments.
"""
import sys
print(f"[SYNC] Starting Newbook earned revenue sync ({from_date} to {to_date})", flush=True)
sys.stdout.flush()
logger.info(f"Starting Newbook earned revenue sync from {from_date} to {to_date}")
db = next(iter([SyncSessionLocal()]))
# Load credentials
creds = load_newbook_credentials(db)
# Load accommodation GL codes configuration
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_gl_codes'")
)
row = result.fetchone()
accommodation_gl_codes_str = row.config_value if row and row.config_value else ""
accommodation_gl_codes = set(c.strip() for c in accommodation_gl_codes_str.split(',') if c.strip())
if not accommodation_gl_codes:
logger.warning("No accommodation_gl_codes configured - all revenue will be marked as 'other'")
print("[SYNC] WARNING: No accommodation_gl_codes configured", flush=True)
# Get accommodation VAT rate for calculating net if not provided
result = db.execute(
text("SELECT config_value FROM system_config WHERE config_key = 'accommodation_vat_rate'")
)
row = result.fetchone()
accommodation_vat = float(row.config_value) if row and row.config_value else 0.20
try:
# Log sync start
db.execute(
text("""
INSERT INTO sync_log (sync_type, source, started_at, status, date_from, date_to, triggered_by)
VALUES ('earned_revenue', 'newbook', NOW(), 'running', :from_date, :to_date, :triggered_by)
"""),
{"from_date": from_date, "to_date": to_date, "triggered_by": triggered_by}
)
db.commit()
print("[SYNC] Creating NewbookClient for earned revenue...", flush=True)
async with NewbookClient(
api_key=creds['api_key'],
username=creds['username'],
password=creds['password'],
region=creds['region']
) as client:
# Test connection
if not await client.test_connection():
raise Exception("Newbook connection failed")
records_created = 0
days_processed = 0
# Track daily accommodation totals for updating daily_occupancy
daily_accommodation = {} # {date: {gross: X, net: X}}
# Track unique GL accounts for caching (for Settings page reference)
gl_accounts_seen = {} # {gl_account_id: {gl_code, gl_name, last_date, total}}
# Fetch earned revenue (returns dict keyed by date)
print(f"[SYNC] Fetching earned revenue: {from_date} to {to_date}...", flush=True)
revenue_data = await client.get_earned_revenue(from_date, to_date)
print(f"[SYNC] Received data for {len(revenue_data)} days", flush=True)
for date_str, day_data in revenue_data.items():
try:
revenue_date = date.fromisoformat(date_str)
days_processed += 1
# Initialize daily totals
if revenue_date not in daily_accommodation:
daily_accommodation[revenue_date] = {"gross": 0.0, "net": 0.0}
# Process GL accounts
# API may return list directly or nested in dict
if isinstance(day_data, list):
gl_accounts = day_data
elif isinstance(day_data, dict):
gl_accounts = day_data.get("gl_accounts", []) or day_data.get("data", [])
else:
gl_accounts = []
for gl_item in gl_accounts:
# API field mapping (Newbook reports_earned_revenue response):
# - gl_account_id: internal ID
# - gl_account_code: actual GL code (e.g., "7001")
# - gl_account_description: human-readable name
# - earned_revenue: gross amount (inc. tax)
# - earned_revenue_ex: net amount (exc. tax)
# - earned_revenue_tax: tax amount
gl_account_id = str(gl_item.get("gl_account_id", ""))
gl_code = str(gl_item.get("gl_account_code", ""))
gl_name = gl_item.get("gl_account_description", "")
amount_gross = float(gl_item.get("earned_revenue", 0) or 0)
amount_net = float(gl_item.get("earned_revenue_ex", 0) or 0)
# Determine revenue type based on GL code
if gl_code in accommodation_gl_codes:
revenue_type = "accommodation"
daily_accommodation[revenue_date]["gross"] += amount_gross
daily_accommodation[revenue_date]["net"] += amount_net
else:
# Future: add food_gl_codes, beverage_gl_codes config
revenue_type = "other"
# Upsert into newbook_earned_revenue_data
db.execute(
text("""
INSERT INTO newbook_earned_revenue_data (
date, gl_account_id, gl_code, gl_name,
amount_gross, amount_net, revenue_type, fetched_at
) VALUES (
:date, :gl_account_id, :gl_code, :gl_name,
:amount_gross, :amount_net, :revenue_type, NOW()
)
ON CONFLICT (date, gl_account_id) DO UPDATE SET
gl_code = :gl_code,
gl_name = :gl_name,
amount_gross = :amount_gross,
amount_net = :amount_net,
revenue_type = :revenue_type,
fetched_at = NOW()
"""),
{
"date": revenue_date,
"gl_account_id": gl_account_id,
"gl_code": gl_code,
"gl_name": gl_name,
"amount_gross": round(amount_gross, 2),
"amount_net": round(amount_net, 2),
"revenue_type": revenue_type
}
)
records_created += 1
# Track GL account for caching
if gl_account_id and gl_account_id not in gl_accounts_seen:
gl_accounts_seen[gl_account_id] = {
"gl_code": gl_code,
"gl_name": gl_name,
"last_date": revenue_date,
"total": amount_gross
}
elif gl_account_id:
gl_accounts_seen[gl_account_id]["total"] += amount_gross
if revenue_date > gl_accounts_seen[gl_account_id]["last_date"]:
gl_accounts_seen[gl_account_id]["last_date"] = revenue_date
except Exception as day_error:
logger.error(f"Error processing earned revenue for {date_str}: {day_error}")
continue
db.commit()
# Cache GL accounts for Settings page reference
print(f"[SYNC] Caching {len(gl_accounts_seen)} GL accounts...", flush=True)
for gl_id, gl_info in gl_accounts_seen.items():
db.execute(
text("""
INSERT INTO newbook_gl_accounts (
gl_account_id, gl_code, gl_name, last_seen_date, total_amount, fetched_at
) VALUES (
:gl_account_id, :gl_code, :gl_name, :last_date, :total, NOW()
)
ON CONFLICT (gl_account_id) DO UPDATE SET
gl_code = :gl_code,
gl_name = :gl_name,
last_seen_date = GREATEST(newbook_gl_accounts.last_seen_date, :last_date),
total_amount = newbook_gl_accounts.total_amount + :total,
fetched_at = NOW()
"""),
{
"gl_account_id": gl_id,
"gl_code": gl_info["gl_code"],
"gl_name": gl_info["gl_name"],
"last_date": gl_info["last_date"],
"total": round(gl_info["total"], 2)
}
)
db.commit()
# Update sync log
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'success',
records_fetched = :fetched, records_created = :created
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND sync_type = 'earned_revenue' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"fetched": days_processed, "created": records_created}
)
db.commit()
print(f"[SYNC] Earned revenue sync completed: {records_created} GL records, {days_processed} days", flush=True)
logger.info(f"Newbook earned revenue sync completed: {records_created} GL records, {days_processed} days")
# Trigger revenue aggregation after successful sync
try:
from jobs.revenue_aggregation import aggregate_revenue
print("[SYNC] Running revenue aggregation...", flush=True)
result = await aggregate_revenue()
print(f"[SYNC] Revenue aggregation complete: {result.get('dates_processed', 0)} dates", flush=True)
except Exception as agg_err:
print(f"[SYNC] Revenue aggregation failed (non-fatal): {agg_err}", flush=True)
logger.warning(f"Revenue aggregation failed after sync: {agg_err}")
except Exception as e:
print(f"[SYNC] Earned revenue sync FAILED: {e}", flush=True)
import traceback
traceback.print_exc()
logger.error(f"Newbook earned revenue sync failed: {e}")
try:
db.rollback()
db.execute(
text("""
UPDATE sync_log
SET completed_at = NOW(), status = 'failed', error_message = :error
WHERE id = (
SELECT id FROM sync_log
WHERE source = 'newbook' AND sync_type = 'earned_revenue' AND status = 'running'
ORDER BY started_at DESC LIMIT 1
)
"""),
{"error": str(e)[:500]}
)
db.commit()
except Exception as log_error:
logger.error(f"Failed to update sync_log: {log_error}")
raise
finally:
db.close()