Forecasting app: hybrid port to HNF stack
Python FastAPI ML backend kept intact; auth replaced with central hnf_session cookie verification. Frontend rebuilt on React 18 + TS + Vite with stack design system, Plotly charts retained. Shared Postgres via DATABASE_URL; schema applied on startup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
75d2c1fa9d
103 changed files with 70316 additions and 0 deletions
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Services
|
||||
539
backend/services/backup_service.py
Normal file
539
backend/services/backup_service.py
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"""
|
||||
Backup and Restore Service
|
||||
|
||||
Handles creating full backups of the database and files, and restoring from backups.
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Backup storage directory
|
||||
BACKUP_DIR = Path("/app/data/backups")
|
||||
DATA_DIR = Path("/app/data")
|
||||
|
||||
|
||||
class BackupService:
|
||||
"""Service for managing backups and restores"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.ensure_backup_dir()
|
||||
|
||||
def ensure_backup_dir(self):
|
||||
"""Ensure backup directory exists"""
|
||||
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
async def ensure_backup_table_exists(self):
|
||||
"""Create backup_history table if it doesn't exist"""
|
||||
await self.db.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS backup_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
backup_type VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
filename VARCHAR(255),
|
||||
file_path TEXT,
|
||||
file_size_bytes BIGINT,
|
||||
snapshot_count INTEGER,
|
||||
file_count INTEGER,
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
completed_at TIMESTAMP,
|
||||
error_message TEXT,
|
||||
created_by VARCHAR(100)
|
||||
)
|
||||
"""))
|
||||
await self.db.commit()
|
||||
|
||||
async def get_backup_settings(self) -> Dict[str, Any]:
|
||||
"""Get backup configuration from system_config"""
|
||||
settings = {
|
||||
'backup_frequency': 'manual',
|
||||
'backup_retention_count': 7,
|
||||
'backup_destination': 'local',
|
||||
'backup_time': None,
|
||||
'backup_last_run_at': None,
|
||||
'backup_last_status': None
|
||||
}
|
||||
|
||||
result = await self.db.execute(text("""
|
||||
SELECT config_key, config_value
|
||||
FROM system_config
|
||||
WHERE config_key LIKE 'backup_%'
|
||||
"""))
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
settings[row.config_key] = row.config_value
|
||||
|
||||
return settings
|
||||
|
||||
async def update_backup_settings(self, updates: Dict[str, Any]) -> bool:
|
||||
"""Update backup configuration"""
|
||||
try:
|
||||
for key, value in updates.items():
|
||||
if not key.startswith('backup_'):
|
||||
key = f'backup_{key}'
|
||||
|
||||
await self.db.execute(text("""
|
||||
INSERT INTO system_config (config_key, config_value, updated_at)
|
||||
VALUES (:key, :value, NOW())
|
||||
ON CONFLICT (config_key) DO UPDATE
|
||||
SET config_value = :value, updated_at = NOW()
|
||||
"""), {'key': key, 'value': str(value)})
|
||||
|
||||
await self.db.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update backup settings: {e}")
|
||||
await self.db.rollback()
|
||||
return False
|
||||
|
||||
async def create_backup(
|
||||
self,
|
||||
backup_type: str = 'manual',
|
||||
created_by: Optional[str] = None
|
||||
) -> Tuple[bool, str, Optional[int]]:
|
||||
"""
|
||||
Create a full backup of database and files
|
||||
|
||||
Returns: (success, message, backup_id)
|
||||
"""
|
||||
backup_id = None
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"backup_{timestamp}.zip"
|
||||
filepath = BACKUP_DIR / filename
|
||||
|
||||
try:
|
||||
# Create backup record
|
||||
result = await self.db.execute(text("""
|
||||
INSERT INTO backup_history (
|
||||
backup_type, status, filename, started_at, created_by
|
||||
) VALUES (
|
||||
:backup_type, 'running', :filename, NOW(), :created_by
|
||||
) RETURNING id
|
||||
"""), {
|
||||
'backup_type': backup_type,
|
||||
'filename': filename,
|
||||
'created_by': created_by
|
||||
})
|
||||
await self.db.commit()
|
||||
row = result.fetchone()
|
||||
backup_id = row.id if row else None
|
||||
|
||||
# Create temporary directory for backup contents
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# 1. Create PostgreSQL dump
|
||||
db_url = os.getenv('DATABASE_URL', 'postgresql://forecast:forecast_secret@localhost:5432/forecast')
|
||||
db_parts = db_url.replace('postgresql://', '').split('@')
|
||||
user_pass = db_parts[0].split(':')
|
||||
host_db = db_parts[1].split('/')
|
||||
|
||||
db_dump_path = temp_path / 'database.sql'
|
||||
env = os.environ.copy()
|
||||
env['PGPASSWORD'] = user_pass[1] if len(user_pass) > 1 else ''
|
||||
|
||||
pg_dump_cmd = [
|
||||
'pg_dump',
|
||||
'-h', host_db[0].split(':')[0],
|
||||
'-U', user_pass[0],
|
||||
'-d', host_db[1] if len(host_db) > 1 else 'forecast',
|
||||
'-f', str(db_dump_path),
|
||||
'--no-owner',
|
||||
'--no-acl'
|
||||
]
|
||||
|
||||
subprocess.run(pg_dump_cmd, env=env, check=True, capture_output=True)
|
||||
|
||||
# 2. Create JSON export
|
||||
db_json_path = temp_path / 'database.json'
|
||||
snapshot_count = await self._export_database_json(db_json_path)
|
||||
|
||||
# 3. Copy files
|
||||
files_dir = temp_path / 'files'
|
||||
file_count = self._copy_data_files(files_dir)
|
||||
|
||||
# 4. Create metadata
|
||||
metadata = {
|
||||
'version': '1.0',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'snapshot_count': snapshot_count,
|
||||
'file_count': file_count,
|
||||
'database_url': db_url.split('@')[1] # Only host/db, not credentials
|
||||
}
|
||||
metadata_path = temp_path / 'metadata.json'
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
# 5. Create ZIP file
|
||||
with zipfile.ZipFile(filepath, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for file in temp_path.rglob('*'):
|
||||
if file.is_file():
|
||||
arcname = file.relative_to(temp_path)
|
||||
zf.write(file, arcname)
|
||||
|
||||
# Update backup record with success
|
||||
file_size = filepath.stat().st_size
|
||||
await self.db.execute(text("""
|
||||
UPDATE backup_history
|
||||
SET status = 'success',
|
||||
file_path = :file_path,
|
||||
file_size_bytes = :file_size,
|
||||
snapshot_count = :snapshot_count,
|
||||
file_count = :file_count,
|
||||
completed_at = NOW()
|
||||
WHERE id = :backup_id
|
||||
"""), {
|
||||
'backup_id': backup_id,
|
||||
'file_path': str(filepath),
|
||||
'file_size': file_size,
|
||||
'snapshot_count': snapshot_count,
|
||||
'file_count': file_count
|
||||
})
|
||||
await self.db.commit()
|
||||
|
||||
# Update last backup settings
|
||||
await self.update_backup_settings({
|
||||
'backup_last_run_at': datetime.now().isoformat(),
|
||||
'backup_last_status': 'success'
|
||||
})
|
||||
|
||||
# Enforce retention policy
|
||||
await self._enforce_retention()
|
||||
|
||||
return True, f"Backup created successfully: {filename}", backup_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backup creation failed: {e}")
|
||||
|
||||
# Update backup record with failure
|
||||
if backup_id:
|
||||
await self.db.execute(text("""
|
||||
UPDATE backup_history
|
||||
SET status = 'failed',
|
||||
error_message = :error,
|
||||
completed_at = NOW()
|
||||
WHERE id = :backup_id
|
||||
"""), {
|
||||
'backup_id': backup_id,
|
||||
'error': str(e)
|
||||
})
|
||||
await self.db.commit()
|
||||
|
||||
# Update last backup status
|
||||
await self.update_backup_settings({
|
||||
'backup_last_run_at': datetime.now().isoformat(),
|
||||
'backup_last_status': 'failed'
|
||||
})
|
||||
|
||||
return False, f"Backup failed: {str(e)}", backup_id
|
||||
|
||||
async def _export_database_json(self, output_path: Path) -> int:
|
||||
"""Export database tables to JSON format"""
|
||||
export_data = {
|
||||
'exported_at': datetime.now().isoformat(),
|
||||
'tables': {}
|
||||
}
|
||||
|
||||
# Tables to export
|
||||
tables = [
|
||||
'forecast_snapshots',
|
||||
'special_dates',
|
||||
'newbook_bookings_data',
|
||||
'newbook_bookings_stats',
|
||||
'newbook_booking_pace',
|
||||
'newbook_occupancy_report_data',
|
||||
'newbook_room_categories',
|
||||
'monthly_budgets',
|
||||
'system_config',
|
||||
'users'
|
||||
]
|
||||
|
||||
snapshot_count = 0
|
||||
for table in tables:
|
||||
try:
|
||||
result = await self.db.execute(text(f"SELECT * FROM {table}"))
|
||||
rows = result.fetchall()
|
||||
columns = result.keys()
|
||||
|
||||
export_data['tables'][table] = [
|
||||
{col: self._serialize_value(getattr(row, col)) for col in columns}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
if table == 'forecast_snapshots':
|
||||
snapshot_count = len(rows)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not export table {table}: {e}")
|
||||
export_data['tables'][table] = []
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump(export_data, f, indent=2, default=str)
|
||||
|
||||
return snapshot_count
|
||||
|
||||
def _serialize_value(self, value):
|
||||
"""Convert value to JSON-serializable format"""
|
||||
if isinstance(value, (datetime,)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
def _copy_data_files(self, dest_dir: Path) -> int:
|
||||
"""Copy all data files to backup directory"""
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_count = 0
|
||||
|
||||
# Skip backup directory itself
|
||||
for root, dirs, files in os.walk(DATA_DIR):
|
||||
# Remove backup directory from traversal
|
||||
dirs[:] = [d for d in dirs if d != 'backups']
|
||||
|
||||
for file in files:
|
||||
src_file = Path(root) / file
|
||||
rel_path = src_file.relative_to(DATA_DIR)
|
||||
dest_file = dest_dir / rel_path
|
||||
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src_file, dest_file)
|
||||
file_count += 1
|
||||
|
||||
return file_count
|
||||
|
||||
async def list_backups(self, limit: int = 50) -> list:
|
||||
"""List all backups, newest first"""
|
||||
result = await self.db.execute(text("""
|
||||
SELECT
|
||||
id, backup_type, status, filename, file_path,
|
||||
file_size_bytes, snapshot_count, file_count,
|
||||
started_at, completed_at, error_message, created_by
|
||||
FROM backup_history
|
||||
ORDER BY started_at DESC
|
||||
LIMIT :limit
|
||||
"""), {'limit': limit})
|
||||
|
||||
rows = result.fetchall()
|
||||
return [
|
||||
{
|
||||
'id': row.id,
|
||||
'backup_type': row.backup_type,
|
||||
'status': row.status,
|
||||
'filename': row.filename,
|
||||
'file_path': row.file_path,
|
||||
'file_size_bytes': row.file_size_bytes,
|
||||
'snapshot_count': row.snapshot_count,
|
||||
'file_count': row.file_count,
|
||||
'started_at': row.started_at.isoformat() if row.started_at else None,
|
||||
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
|
||||
'error_message': row.error_message,
|
||||
'created_by': row.created_by
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def get_backup(self, backup_id: int) -> Optional[Dict]:
|
||||
"""Get a specific backup by ID"""
|
||||
result = await self.db.execute(text("""
|
||||
SELECT
|
||||
id, backup_type, status, filename, file_path,
|
||||
file_size_bytes, snapshot_count, file_count,
|
||||
started_at, completed_at, error_message, created_by
|
||||
FROM backup_history
|
||||
WHERE id = :backup_id
|
||||
"""), {'backup_id': backup_id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': row.id,
|
||||
'backup_type': row.backup_type,
|
||||
'status': row.status,
|
||||
'filename': row.filename,
|
||||
'file_path': row.file_path,
|
||||
'file_size_bytes': row.file_size_bytes,
|
||||
'snapshot_count': row.snapshot_count,
|
||||
'file_count': row.file_count,
|
||||
'started_at': row.started_at.isoformat() if row.started_at else None,
|
||||
'completed_at': row.completed_at.isoformat() if row.completed_at else None,
|
||||
'error_message': row.error_message,
|
||||
'created_by': row.created_by
|
||||
}
|
||||
|
||||
async def delete_backup(self, backup_id: int) -> Tuple[bool, str]:
|
||||
"""Delete a backup file and record"""
|
||||
try:
|
||||
# Get backup info
|
||||
backup = await self.get_backup(backup_id)
|
||||
if not backup:
|
||||
return False, "Backup not found"
|
||||
|
||||
# Delete file if it exists
|
||||
if backup['file_path']:
|
||||
file_path = Path(backup['file_path'])
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
# Delete database record
|
||||
await self.db.execute(text("""
|
||||
DELETE FROM backup_history WHERE id = :backup_id
|
||||
"""), {'backup_id': backup_id})
|
||||
await self.db.commit()
|
||||
|
||||
return True, "Backup deleted successfully"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete backup: {e}")
|
||||
await self.db.rollback()
|
||||
return False, f"Failed to delete backup: {str(e)}"
|
||||
|
||||
async def restore_from_backup(self, backup_id: int) -> Tuple[bool, str]:
|
||||
"""Restore database and files from a backup"""
|
||||
try:
|
||||
# Get backup info
|
||||
backup = await self.get_backup(backup_id)
|
||||
if not backup:
|
||||
return False, "Backup not found"
|
||||
|
||||
if backup['status'] != 'success':
|
||||
return False, "Cannot restore from failed backup"
|
||||
|
||||
backup_path = Path(backup['file_path'])
|
||||
if not backup_path.exists():
|
||||
return False, "Backup file not found"
|
||||
|
||||
return await self._restore_from_file(backup_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Restore failed: {e}")
|
||||
return False, f"Restore failed: {str(e)}"
|
||||
|
||||
async def restore_from_upload(self, file_content: bytes, filename: str) -> Tuple[bool, str]:
|
||||
"""Restore from an uploaded backup file"""
|
||||
try:
|
||||
# Save uploaded file temporarily
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_file:
|
||||
temp_file.write(file_content)
|
||||
temp_path = Path(temp_file.name)
|
||||
|
||||
try:
|
||||
# Validate ZIP file
|
||||
if not zipfile.is_zipfile(temp_path):
|
||||
return False, "Invalid backup file (not a ZIP file)"
|
||||
|
||||
return await self._restore_from_file(temp_path)
|
||||
finally:
|
||||
# Clean up temp file
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Upload restore failed: {e}")
|
||||
return False, f"Restore failed: {str(e)}"
|
||||
|
||||
async def _restore_from_file(self, backup_path: Path) -> Tuple[bool, str]:
|
||||
"""Internal method to restore from a backup ZIP file"""
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Extract ZIP
|
||||
with zipfile.ZipFile(backup_path, 'r') as zf:
|
||||
zf.extractall(temp_path)
|
||||
|
||||
# Validate required files
|
||||
db_sql_path = temp_path / 'database.sql'
|
||||
if not db_sql_path.exists():
|
||||
return False, "Invalid backup: missing database.sql"
|
||||
|
||||
# Restore database
|
||||
db_url = os.getenv('DATABASE_URL', 'postgresql://forecast:forecast_secret@localhost:5432/forecast')
|
||||
db_parts = db_url.replace('postgresql://', '').split('@')
|
||||
user_pass = db_parts[0].split(':')
|
||||
host_db = db_parts[1].split('/')
|
||||
|
||||
env = os.environ.copy()
|
||||
env['PGPASSWORD'] = user_pass[1] if len(user_pass) > 1 else ''
|
||||
|
||||
psql_cmd = [
|
||||
'psql',
|
||||
'-h', host_db[0].split(':')[0],
|
||||
'-U', user_pass[0],
|
||||
'-d', host_db[1] if len(host_db) > 1 else 'forecast',
|
||||
'-f', str(db_sql_path)
|
||||
]
|
||||
|
||||
result = subprocess.run(psql_cmd, env=env, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Database restore failed: {result.stderr}")
|
||||
return False, f"Database restore failed: {result.stderr}"
|
||||
|
||||
# Restore files
|
||||
files_dir = temp_path / 'files'
|
||||
if files_dir.exists():
|
||||
file_count = 0
|
||||
for root, dirs, files in os.walk(files_dir):
|
||||
for file in files:
|
||||
src_file = Path(root) / file
|
||||
rel_path = src_file.relative_to(files_dir)
|
||||
dest_file = DATA_DIR / rel_path
|
||||
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Don't overwrite existing files
|
||||
if not dest_file.exists():
|
||||
shutil.copy2(src_file, dest_file)
|
||||
file_count += 1
|
||||
|
||||
logger.info(f"Restored {file_count} files")
|
||||
|
||||
return True, "Backup restored successfully"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Restore from file failed: {e}")
|
||||
return False, f"Restore failed: {str(e)}"
|
||||
|
||||
async def _enforce_retention(self):
|
||||
"""Delete old backups beyond retention count"""
|
||||
try:
|
||||
settings = await self.get_backup_settings()
|
||||
retention_count = int(settings.get('backup_retention_count', 7))
|
||||
|
||||
# Get backups to delete (beyond retention count)
|
||||
result = await self.db.execute(text("""
|
||||
SELECT id, file_path
|
||||
FROM backup_history
|
||||
WHERE status = 'success'
|
||||
ORDER BY started_at DESC
|
||||
OFFSET :retention_count
|
||||
"""), {'retention_count': retention_count})
|
||||
|
||||
rows = result.fetchall()
|
||||
for row in rows:
|
||||
# Delete file
|
||||
if row.file_path:
|
||||
file_path = Path(row.file_path)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
# Delete record
|
||||
await self.db.execute(text("""
|
||||
DELETE FROM backup_history WHERE id = :backup_id
|
||||
"""), {'backup_id': row.id})
|
||||
|
||||
await self.db.commit()
|
||||
logger.info(f"Retention policy: deleted {len(rows)} old backups")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to enforce retention policy: {e}")
|
||||
829
backend/services/booking_scraper.py
Normal file
829
backend/services/booking_scraper.py
Normal file
|
|
@ -0,0 +1,829 @@
|
|||
"""
|
||||
Booking.com Rate Scraper Service
|
||||
|
||||
Main service for scraping competitor rates from booking.com.
|
||||
Uses pluggable backends (Playwright local, proxy, Apify) via factory pattern.
|
||||
|
||||
Features:
|
||||
- Location-based search (1 query = 40+ hotels)
|
||||
- Hotel discovery and tier management
|
||||
- Rate extraction with availability status
|
||||
- Anti-scrape detection and pause/resume
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .scraper_backends import ScraperBackend, PlaywrightLocalBackend, HotelData, RateData, AvailabilityStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_scraper_backend(db: Session) -> ScraperBackend:
|
||||
"""
|
||||
Factory to get configured scraper backend.
|
||||
|
||||
Reads backend type from system_config and returns appropriate instance.
|
||||
"""
|
||||
# Get backend configuration
|
||||
result = db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_backend'")
|
||||
).fetchone()
|
||||
|
||||
backend_type = result.config_value if result and result.config_value else 'playwright_local'
|
||||
|
||||
if backend_type == 'playwright_local':
|
||||
return PlaywrightLocalBackend()
|
||||
|
||||
elif backend_type == 'playwright_proxy':
|
||||
# Get proxy config
|
||||
proxy_result = db.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value FROM system_config
|
||||
WHERE config_key IN ('booking_scraper_proxy_url', 'booking_scraper_proxy_username', 'booking_scraper_proxy_password')
|
||||
""")
|
||||
)
|
||||
proxy_config = {row.config_key: row.config_value for row in proxy_result.fetchall()}
|
||||
return PlaywrightLocalBackend(proxy_config=proxy_config)
|
||||
|
||||
elif backend_type == 'apify':
|
||||
# Future: Apify backend
|
||||
raise NotImplementedError("Apify backend not yet implemented")
|
||||
|
||||
else:
|
||||
logger.warning(f"Unknown backend type '{backend_type}', falling back to playwright_local")
|
||||
return PlaywrightLocalBackend()
|
||||
|
||||
|
||||
def get_scrape_config(db: Session) -> Optional[Dict[str, Any]]:
|
||||
"""Get the active scrape location configuration."""
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT id, location_name, location_search_url, pages_to_scrape, adults
|
||||
FROM booking_scrape_config
|
||||
WHERE is_active = TRUE
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""")
|
||||
).fetchone()
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
return {
|
||||
'id': result.id,
|
||||
'location_name': result.location_name,
|
||||
'location_search_url': result.location_search_url,
|
||||
'pages_to_scrape': result.pages_to_scrape or 2,
|
||||
'adults': result.adults or 2,
|
||||
}
|
||||
|
||||
|
||||
async def is_scraper_paused(db: Session) -> bool:
|
||||
"""Check if scraper is currently paused due to blocking."""
|
||||
result = db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_paused'")
|
||||
).fetchone()
|
||||
|
||||
if not result or result.config_value != 'true':
|
||||
return False
|
||||
|
||||
# Check if pause period has expired
|
||||
pause_until_result = db.execute(
|
||||
text("SELECT config_value FROM system_config WHERE config_key = 'booking_scraper_pause_until'")
|
||||
).fetchone()
|
||||
|
||||
if pause_until_result and pause_until_result.config_value:
|
||||
try:
|
||||
pause_until = datetime.fromisoformat(pause_until_result.config_value)
|
||||
if datetime.now() >= pause_until:
|
||||
# Pause expired, reset
|
||||
db.execute(
|
||||
text("UPDATE system_config SET config_value = 'false' WHERE config_key = 'booking_scraper_paused'")
|
||||
)
|
||||
db.commit()
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def set_scraper_paused(db: Session, paused: bool, hours: int = 2):
|
||||
"""Set scraper pause status."""
|
||||
db.execute(
|
||||
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_paused'"),
|
||||
{'val': 'true' if paused else 'false'}
|
||||
)
|
||||
if paused:
|
||||
pause_until = (datetime.now() + timedelta(hours=hours)).isoformat()
|
||||
db.execute(
|
||||
text("UPDATE system_config SET config_value = :val WHERE config_key = 'booking_scraper_pause_until'"),
|
||||
{'val': pause_until}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def save_hotel(db: Session, hotel: HotelData) -> int:
|
||||
"""
|
||||
Save or update a hotel in the database.
|
||||
|
||||
Returns the hotel's database ID.
|
||||
"""
|
||||
# Check if hotel exists
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM booking_com_hotels WHERE booking_com_id = :bid"),
|
||||
{'bid': hotel.booking_com_id}
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
# Update last_seen_at and any changed fields
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE booking_com_hotels SET
|
||||
name = COALESCE(:name, name),
|
||||
booking_com_url = COALESCE(:url, booking_com_url),
|
||||
star_rating = COALESCE(:stars, star_rating),
|
||||
review_score = COALESCE(:score, review_score),
|
||||
review_count = COALESCE(:count, review_count),
|
||||
last_seen_at = NOW()
|
||||
WHERE booking_com_id = :bid
|
||||
"""),
|
||||
{
|
||||
'bid': hotel.booking_com_id,
|
||||
'name': hotel.name,
|
||||
'url': hotel.booking_com_url,
|
||||
'stars': float(hotel.star_rating) if hotel.star_rating else None,
|
||||
'score': float(hotel.review_score) if hotel.review_score else None,
|
||||
'count': hotel.review_count,
|
||||
}
|
||||
)
|
||||
return existing.id
|
||||
else:
|
||||
# Insert new hotel (default tier is 'market')
|
||||
result = db.execute(
|
||||
text("""
|
||||
INSERT INTO booking_com_hotels
|
||||
(booking_com_id, name, booking_com_url, star_rating, review_score, review_count, tier)
|
||||
VALUES (:bid, :name, :url, :stars, :score, :count, 'market')
|
||||
RETURNING id
|
||||
"""),
|
||||
{
|
||||
'bid': hotel.booking_com_id,
|
||||
'name': hotel.name,
|
||||
'url': hotel.booking_com_url,
|
||||
'stars': float(hotel.star_rating) if hotel.star_rating else None,
|
||||
'score': float(hotel.review_score) if hotel.review_score else None,
|
||||
'count': hotel.review_count,
|
||||
}
|
||||
)
|
||||
return result.fetchone().id
|
||||
|
||||
|
||||
def save_rate(db: Session, rate: RateData, hotel_id: int, batch_id: uuid.UUID):
|
||||
"""Save a rate to the database."""
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO booking_com_rates
|
||||
(hotel_id, rate_date, availability_status, rate_gross, currency, room_type,
|
||||
breakfast_included, free_cancellation, no_prepayment, rooms_left, scrape_batch_id)
|
||||
VALUES (:hotel_id, :rate_date, :status, :rate, :currency, :room_type,
|
||||
:breakfast, :cancel, :prepay, :rooms_left, :batch_id)
|
||||
"""),
|
||||
{
|
||||
'hotel_id': hotel_id,
|
||||
'rate_date': rate.rate_date,
|
||||
'status': rate.availability_status.value,
|
||||
'rate': float(rate.rate_gross) if rate.rate_gross else None,
|
||||
'currency': rate.currency,
|
||||
'room_type': rate.room_type,
|
||||
'breakfast': rate.breakfast_included,
|
||||
'cancel': rate.free_cancellation,
|
||||
'prepay': rate.no_prepayment,
|
||||
'rooms_left': rate.rooms_left,
|
||||
'batch_id': str(batch_id),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_scrape_batch(db: Session, scrape_type: str) -> uuid.UUID:
|
||||
"""Create a new scrape batch log entry."""
|
||||
batch_id = uuid.uuid4()
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO booking_scrape_log
|
||||
(batch_id, scrape_type, started_at, status)
|
||||
VALUES (:batch_id, :scrape_type, NOW(), 'running')
|
||||
"""),
|
||||
{'batch_id': str(batch_id), 'scrape_type': scrape_type}
|
||||
)
|
||||
db.commit()
|
||||
return batch_id
|
||||
|
||||
|
||||
def update_scrape_batch(
|
||||
db: Session,
|
||||
batch_id: uuid.UUID,
|
||||
status: str,
|
||||
hotels_found: int = 0,
|
||||
rates_scraped: int = 0,
|
||||
error_message: str = None,
|
||||
blocked: bool = False
|
||||
):
|
||||
"""Update scrape batch log with results."""
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE booking_scrape_log SET
|
||||
completed_at = CASE WHEN :status IN ('completed', 'failed', 'blocked') THEN NOW() ELSE NULL END,
|
||||
status = :status,
|
||||
hotels_found = :hotels,
|
||||
rates_scraped = :rates,
|
||||
error_message = :error,
|
||||
blocked_at = CASE WHEN :blocked THEN NOW() ELSE NULL END,
|
||||
resume_after = CASE WHEN :blocked THEN NOW() + INTERVAL '2 hours' ELSE NULL END
|
||||
WHERE batch_id = :batch_id
|
||||
"""),
|
||||
{
|
||||
'batch_id': str(batch_id),
|
||||
'status': status,
|
||||
'hotels': hotels_found,
|
||||
'rates': rates_scraped,
|
||||
'error': error_message,
|
||||
'blocked': blocked,
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def cleanup_stale_batches(db: Session, max_age_minutes: int = 60):
|
||||
"""
|
||||
Mark any 'running' scrape batches as 'failed' if they've been running
|
||||
longer than max_age_minutes. This handles orphaned batches from
|
||||
container restarts or crashes.
|
||||
"""
|
||||
result = db.execute(
|
||||
text("""
|
||||
UPDATE booking_scrape_log SET
|
||||
status = 'failed',
|
||||
completed_at = NOW(),
|
||||
error_message = 'Interrupted (container restart or timeout)'
|
||||
WHERE status = 'running'
|
||||
AND started_at < NOW() - INTERVAL ':mins minutes'
|
||||
RETURNING batch_id
|
||||
""".replace(':mins', str(int(max_age_minutes))))
|
||||
)
|
||||
cleaned = result.fetchall()
|
||||
db.commit()
|
||||
if cleaned:
|
||||
logger.info(f"Cleaned up {len(cleaned)} stale running scrape batch(es)")
|
||||
return len(cleaned)
|
||||
|
||||
|
||||
async def scrape_date(
|
||||
db: Session,
|
||||
rate_date: date,
|
||||
backend: ScraperBackend,
|
||||
config: Dict[str, Any],
|
||||
batch_id: uuid.UUID
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Scrape rates for a single date.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
rate_date: Date to scrape rates for
|
||||
backend: Scraper backend instance
|
||||
config: Scrape configuration
|
||||
batch_id: Current batch ID
|
||||
|
||||
Returns:
|
||||
Dict with 'success', 'blocked', 'hotels_count', 'rates_count'
|
||||
"""
|
||||
check_in = rate_date
|
||||
check_out = rate_date + timedelta(days=1) # Single night
|
||||
|
||||
result = await backend.scrape_location_search(
|
||||
location=config['location_name'],
|
||||
check_in=check_in,
|
||||
check_out=check_out,
|
||||
adults=config['adults'],
|
||||
pages=config['pages_to_scrape']
|
||||
)
|
||||
|
||||
if result.blocked:
|
||||
return {
|
||||
'success': False,
|
||||
'blocked': True,
|
||||
'block_reason': result.block_reason,
|
||||
'hotels_count': 0,
|
||||
'rates_count': 0,
|
||||
}
|
||||
|
||||
if not result.success:
|
||||
return {
|
||||
'success': False,
|
||||
'blocked': False,
|
||||
'error': result.error_message,
|
||||
'hotels_count': 0,
|
||||
'rates_count': 0,
|
||||
}
|
||||
|
||||
# Save hotels and rates
|
||||
hotels_saved = 0
|
||||
rates_saved = 0
|
||||
|
||||
for hotel, rate in zip(result.hotels, result.rates):
|
||||
if not hotel.booking_com_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
hotel_id = save_hotel(db, hotel)
|
||||
save_rate(db, rate, hotel_id, batch_id)
|
||||
hotels_saved += 1
|
||||
rates_saved += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Error saving hotel/rate: {e}")
|
||||
continue
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'blocked': False,
|
||||
'hotels_count': hotels_saved,
|
||||
'rates_count': rates_saved,
|
||||
}
|
||||
|
||||
|
||||
async def run_manual_scrape(
|
||||
db: Session,
|
||||
from_date: date,
|
||||
to_date: date = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run a manual scrape for testing/on-demand use.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
from_date: Start date
|
||||
to_date: End date (defaults to from_date for single day)
|
||||
|
||||
Returns:
|
||||
Dict with scrape results summary
|
||||
"""
|
||||
if to_date is None:
|
||||
to_date = from_date
|
||||
|
||||
# Check if paused
|
||||
if await is_scraper_paused(db):
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Scraper is currently paused due to blocking. Try again later.',
|
||||
}
|
||||
|
||||
# Get config
|
||||
config = get_scrape_config(db)
|
||||
if not config:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'No scrape location configured. Add a location in settings.',
|
||||
}
|
||||
|
||||
# Create batch
|
||||
batch_id = create_scrape_batch(db, 'manual')
|
||||
|
||||
# Get backend
|
||||
backend = get_scraper_backend(db)
|
||||
|
||||
total_hotels = 0
|
||||
total_rates = 0
|
||||
dates_completed = 0
|
||||
dates_failed = 0
|
||||
|
||||
try:
|
||||
current_date = from_date
|
||||
while current_date <= to_date:
|
||||
logger.info(f"Scraping date: {current_date}")
|
||||
|
||||
result = await scrape_date(db, current_date, backend, config, batch_id)
|
||||
|
||||
if result['blocked']:
|
||||
# Blocking detected - pause and exit
|
||||
await set_scraper_paused(db, True, hours=2)
|
||||
update_scrape_batch(
|
||||
db, batch_id,
|
||||
status='blocked',
|
||||
hotels_found=total_hotels,
|
||||
rates_scraped=total_rates,
|
||||
error_message=f"Blocked: {result.get('block_reason', 'unknown')}",
|
||||
blocked=True
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'blocked': True,
|
||||
'block_reason': result.get('block_reason'),
|
||||
'dates_completed': dates_completed,
|
||||
'dates_failed': dates_failed,
|
||||
'hotels_found': total_hotels,
|
||||
'rates_scraped': total_rates,
|
||||
}
|
||||
|
||||
if result['success']:
|
||||
total_hotels += result['hotels_count']
|
||||
total_rates += result['rates_count']
|
||||
dates_completed += 1
|
||||
else:
|
||||
dates_failed += 1
|
||||
logger.warning(f"Failed to scrape {current_date}: {result.get('error')}")
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Update batch as completed
|
||||
update_scrape_batch(
|
||||
db, batch_id,
|
||||
status='completed',
|
||||
hotels_found=total_hotels,
|
||||
rates_scraped=total_rates
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'blocked': False,
|
||||
'dates_completed': dates_completed,
|
||||
'dates_failed': dates_failed,
|
||||
'hotels_found': total_hotels,
|
||||
'rates_scraped': total_rates,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scrape error: {e}")
|
||||
update_scrape_batch(
|
||||
db, batch_id,
|
||||
status='failed',
|
||||
hotels_found=total_hotels,
|
||||
rates_scraped=total_rates,
|
||||
error_message=str(e)
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'dates_completed': dates_completed,
|
||||
'dates_failed': dates_failed,
|
||||
'hotels_found': total_hotels,
|
||||
'rates_scraped': total_rates,
|
||||
}
|
||||
finally:
|
||||
await backend.close()
|
||||
|
||||
|
||||
# ============================================
|
||||
# QUEUE MANAGEMENT
|
||||
# ============================================
|
||||
|
||||
def populate_queue(db: Session, dates: List[date], priorities: Dict[date, int] = None):
|
||||
"""
|
||||
Add dates to the scrape queue, skipping any already pending/processing.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
dates: Dates to add to the queue
|
||||
priorities: Optional priority map (higher = scraped first). Default: 0
|
||||
"""
|
||||
if not dates:
|
||||
return 0
|
||||
|
||||
added = 0
|
||||
for rate_date in dates:
|
||||
priority = (priorities or {}).get(rate_date, 0)
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO booking_scrape_queue (rate_date, status, priority)
|
||||
VALUES (:rate_date, 'pending', :priority)
|
||||
ON CONFLICT (rate_date, status) DO UPDATE SET
|
||||
priority = GREATEST(booking_scrape_queue.priority, :priority)
|
||||
"""),
|
||||
{'rate_date': rate_date, 'priority': priority}
|
||||
)
|
||||
added += 1
|
||||
except Exception:
|
||||
# Ignore duplicates or constraint issues
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Queue: added/updated {added} dates")
|
||||
return added
|
||||
|
||||
|
||||
def get_pending_queue_items(db: Session, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Get pending queue items ordered by priority (highest first), then date."""
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT id, rate_date, priority, attempts, max_attempts
|
||||
FROM booking_scrape_queue
|
||||
WHERE status = 'pending' AND attempts < max_attempts
|
||||
ORDER BY priority DESC, rate_date ASC
|
||||
LIMIT :limit
|
||||
"""),
|
||||
{'limit': limit}
|
||||
)
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
def mark_queue_item(db: Session, queue_id: int, status: str, error_message: str = None):
|
||||
"""Update a queue item's status."""
|
||||
if status == 'completed':
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE booking_scrape_queue SET
|
||||
status = 'completed',
|
||||
completed_at = NOW(),
|
||||
last_attempt_at = NOW(),
|
||||
attempts = attempts + 1
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{'id': queue_id}
|
||||
)
|
||||
elif status == 'failed':
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE booking_scrape_queue SET
|
||||
status = CASE
|
||||
WHEN attempts + 1 >= max_attempts THEN 'failed'
|
||||
ELSE 'pending'
|
||||
END,
|
||||
last_attempt_at = NOW(),
|
||||
attempts = attempts + 1,
|
||||
error_message = :error
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{'id': queue_id, 'error': error_message}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def clear_old_queue_items(db: Session, days: int = 7):
|
||||
"""Remove completed/failed queue items older than N days."""
|
||||
db.execute(
|
||||
text("""
|
||||
DELETE FROM booking_scrape_queue
|
||||
WHERE status IN ('completed', 'failed')
|
||||
AND created_at < NOW() - INTERVAL ':days days'
|
||||
""".replace(':days', str(int(days))))
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
async def process_queue(db: Session) -> Dict[str, Any]:
|
||||
"""
|
||||
Process pending items from the scrape queue.
|
||||
|
||||
Picks up pending items in priority order, scrapes each date,
|
||||
and handles blocking/retries.
|
||||
|
||||
Returns:
|
||||
Dict with processing results
|
||||
"""
|
||||
# Check if paused
|
||||
if await is_scraper_paused(db):
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Scraper is currently paused due to blocking.',
|
||||
}
|
||||
|
||||
# Get config
|
||||
config = get_scrape_config(db)
|
||||
if not config:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'No scrape location configured.',
|
||||
}
|
||||
|
||||
# Get pending items
|
||||
items = get_pending_queue_items(db, limit=200)
|
||||
if not items:
|
||||
return {'success': True, 'dates_completed': 0, 'message': 'Queue empty'}
|
||||
|
||||
# Create batch
|
||||
batch_id = create_scrape_batch(db, 'scheduled')
|
||||
|
||||
# Update batch with queue count
|
||||
db.execute(
|
||||
text("UPDATE booking_scrape_log SET dates_queued = :count WHERE batch_id = :bid"),
|
||||
{'count': len(items), 'bid': str(batch_id)}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Get backend
|
||||
backend = get_scraper_backend(db)
|
||||
|
||||
total_hotels = 0
|
||||
total_rates = 0
|
||||
dates_completed = 0
|
||||
dates_failed = 0
|
||||
|
||||
try:
|
||||
for item in items:
|
||||
rate_date = item['rate_date']
|
||||
queue_id = item['id']
|
||||
|
||||
logger.info(f"Queue processing: {rate_date} (priority={item['priority']}, attempt={item['attempts']+1})")
|
||||
|
||||
result = await scrape_date(db, rate_date, backend, config, batch_id)
|
||||
|
||||
if result['blocked']:
|
||||
# Mark this item as failed, pause, and stop
|
||||
mark_queue_item(db, queue_id, 'failed', f"Blocked: {result.get('block_reason')}")
|
||||
await set_scraper_paused(db, True, hours=2)
|
||||
update_scrape_batch(
|
||||
db, batch_id,
|
||||
status='blocked',
|
||||
hotels_found=total_hotels,
|
||||
rates_scraped=total_rates,
|
||||
error_message=f"Blocked: {result.get('block_reason', 'unknown')}",
|
||||
blocked=True
|
||||
)
|
||||
# Update dates counters
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE booking_scrape_log SET
|
||||
dates_completed = :completed,
|
||||
dates_failed = :failed
|
||||
WHERE batch_id = :bid
|
||||
"""),
|
||||
{'completed': dates_completed, 'failed': dates_failed + 1, 'bid': str(batch_id)}
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
'success': False,
|
||||
'blocked': True,
|
||||
'block_reason': result.get('block_reason'),
|
||||
'dates_completed': dates_completed,
|
||||
'dates_failed': dates_failed + 1,
|
||||
'hotels_found': total_hotels,
|
||||
'rates_scraped': total_rates,
|
||||
}
|
||||
|
||||
if result['success']:
|
||||
mark_queue_item(db, queue_id, 'completed')
|
||||
total_hotels += result['hotels_count']
|
||||
total_rates += result['rates_count']
|
||||
dates_completed += 1
|
||||
else:
|
||||
mark_queue_item(db, queue_id, 'failed', result.get('error'))
|
||||
dates_failed += 1
|
||||
logger.warning(f"Queue: failed to scrape {rate_date}: {result.get('error')}")
|
||||
|
||||
# Update batch as completed
|
||||
update_scrape_batch(
|
||||
db, batch_id,
|
||||
status='completed',
|
||||
hotels_found=total_hotels,
|
||||
rates_scraped=total_rates
|
||||
)
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE booking_scrape_log SET
|
||||
dates_completed = :completed,
|
||||
dates_failed = :failed
|
||||
WHERE batch_id = :bid
|
||||
"""),
|
||||
{'completed': dates_completed, 'failed': dates_failed, 'bid': str(batch_id)}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'blocked': False,
|
||||
'dates_completed': dates_completed,
|
||||
'dates_failed': dates_failed,
|
||||
'hotels_found': total_hotels,
|
||||
'rates_scraped': total_rates,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Queue processing error: {e}")
|
||||
update_scrape_batch(
|
||||
db, batch_id,
|
||||
status='failed',
|
||||
hotels_found=total_hotels,
|
||||
rates_scraped=total_rates,
|
||||
error_message=str(e)
|
||||
)
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'dates_completed': dates_completed,
|
||||
'dates_failed': dates_failed,
|
||||
}
|
||||
finally:
|
||||
await backend.close()
|
||||
|
||||
|
||||
def get_competitor_matrix(
|
||||
db: Session,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
include_market: bool = False
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get rate comparison matrix for competitors.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
from_date: Start date
|
||||
to_date: End date
|
||||
include_market: Include market tier hotels
|
||||
|
||||
Returns:
|
||||
List of rate records for matrix display
|
||||
"""
|
||||
tier_filter = "h.tier IN ('own', 'competitor')"
|
||||
if include_market:
|
||||
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
|
||||
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
r.rate_date,
|
||||
h.id AS hotel_id,
|
||||
h.name AS hotel_name,
|
||||
h.tier,
|
||||
h.display_order,
|
||||
h.star_rating,
|
||||
h.review_score,
|
||||
r.availability_status,
|
||||
r.rate_gross,
|
||||
r.room_type,
|
||||
r.breakfast_included,
|
||||
r.free_cancellation,
|
||||
r.no_prepayment,
|
||||
r.rooms_left,
|
||||
r.scraped_at
|
||||
FROM booking_latest_rates r
|
||||
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||
WHERE {tier_filter}
|
||||
AND h.is_active = TRUE
|
||||
AND r.rate_date BETWEEN :from_date AND :to_date
|
||||
ORDER BY r.rate_date, h.display_order, h.name
|
||||
"""),
|
||||
{'from_date': from_date, 'to_date': to_date}
|
||||
)
|
||||
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
def get_hotels_list(db: Session, tier: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of discovered hotels.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
tier: Filter by tier ('own', 'competitor', 'market') or None for all
|
||||
|
||||
Returns:
|
||||
List of hotel records
|
||||
"""
|
||||
where_clause = "WHERE is_active = TRUE"
|
||||
if tier:
|
||||
where_clause += f" AND tier = '{tier}'"
|
||||
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
id, booking_com_id, name, booking_com_url,
|
||||
star_rating, review_score, review_count,
|
||||
tier, display_order, notes,
|
||||
first_seen_at, last_seen_at
|
||||
FROM booking_com_hotels
|
||||
{where_clause}
|
||||
ORDER BY display_order, name
|
||||
""")
|
||||
)
|
||||
|
||||
return [dict(row._mapping) for row in result.fetchall()]
|
||||
|
||||
|
||||
def update_hotel_tier(db: Session, hotel_id: int, tier: str, display_order: int = None):
|
||||
"""Update a hotel's tier and display order."""
|
||||
if tier not in ('own', 'competitor', 'market'):
|
||||
raise ValueError(f"Invalid tier: {tier}")
|
||||
|
||||
params = {'hotel_id': hotel_id, 'tier': tier}
|
||||
set_clause = "tier = :tier"
|
||||
|
||||
if display_order is not None:
|
||||
set_clause += ", display_order = :order"
|
||||
params['order'] = display_order
|
||||
|
||||
db.execute(
|
||||
text(f"UPDATE booking_com_hotels SET {set_clause} WHERE id = :hotel_id"),
|
||||
params
|
||||
)
|
||||
db.commit()
|
||||
1
backend/services/forecasting/__init__.py
Normal file
1
backend/services/forecasting/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Forecasting models
|
||||
401
backend/services/forecasting/backtest.py
Normal file
401
backend/services/forecasting/backtest.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"""
|
||||
Backtesting service for forecast model evaluation.
|
||||
|
||||
Simulates historical forecasts using only data that would have been
|
||||
available at the time, then compares to actual outcomes.
|
||||
|
||||
This allows model accuracy evaluation without waiting for real-time
|
||||
data to accumulate.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional, Dict
|
||||
from sqlalchemy import text
|
||||
|
||||
from utils.time_alignment import get_prior_year_daily
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_backtest(
|
||||
db,
|
||||
metric_code: str,
|
||||
backtest_from: date,
|
||||
backtest_to: date,
|
||||
lead_times: List[int] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
Run backtesting for a metric over a date range.
|
||||
|
||||
For each date in the range, simulates what the forecast would have been
|
||||
at various lead times, using only data available at that time.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to backtest (e.g., 'hotel_room_nights')
|
||||
backtest_from: Start of backtest period
|
||||
backtest_to: End of backtest period
|
||||
lead_times: List of lead times to test (days out). Default: [7, 14, 21, 28]
|
||||
|
||||
Returns:
|
||||
Dict with backtest results and accuracy metrics
|
||||
"""
|
||||
if lead_times is None:
|
||||
lead_times = [7, 14, 21, 28]
|
||||
|
||||
logger.info(f"Running backtest for {metric_code} from {backtest_from} to {backtest_to}")
|
||||
|
||||
results = []
|
||||
total_rooms = 25 # Default capacity
|
||||
|
||||
# Get room capacity (SUM across all room categories for a single date)
|
||||
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
|
||||
rooms_result = db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(available), 25) as total_rooms
|
||||
FROM newbook_occupancy_report
|
||||
WHERE date = (
|
||||
SELECT MAX(date) FROM newbook_occupancy_report
|
||||
WHERE date <= :from_date
|
||||
)
|
||||
"""),
|
||||
{"from_date": backtest_from}
|
||||
)
|
||||
rooms_row = rooms_result.fetchone()
|
||||
if rooms_row and rooms_row.total_rooms:
|
||||
total_rooms = int(rooms_row.total_rooms)
|
||||
|
||||
# For each date in backtest range
|
||||
current_date = backtest_from
|
||||
while current_date <= backtest_to:
|
||||
# Get actual value for this date
|
||||
actual_result = db.execute(
|
||||
text("""
|
||||
SELECT actual_value
|
||||
FROM daily_metrics
|
||||
WHERE date = :target_date AND metric_code = :metric
|
||||
"""),
|
||||
{"target_date": current_date, "metric": metric_code}
|
||||
).fetchone()
|
||||
|
||||
actual_value = float(actual_result.actual_value) if actual_result and actual_result.actual_value else None
|
||||
|
||||
if actual_value is None:
|
||||
current_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
# For each lead time, simulate the forecast
|
||||
for lead_time in lead_times:
|
||||
# The "simulated today" is lead_time days before the target date
|
||||
simulated_today = current_date - timedelta(days=lead_time)
|
||||
|
||||
# Get OTB snapshot that would have been available
|
||||
# Look for snapshot closest to simulated_today
|
||||
otb_result = db.execute(
|
||||
text("""
|
||||
SELECT otb_value, snapshot_date, days_out
|
||||
FROM pickup_snapshots
|
||||
WHERE stay_date = :target_date
|
||||
AND metric_type = :metric
|
||||
AND snapshot_date <= :simulated_today
|
||||
ORDER BY snapshot_date DESC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{
|
||||
"target_date": current_date,
|
||||
"metric": metric_code,
|
||||
"simulated_today": simulated_today
|
||||
}
|
||||
).fetchone()
|
||||
|
||||
if not otb_result:
|
||||
continue
|
||||
|
||||
# Use 'is not None' - 0 is valid OTB data
|
||||
current_otb = float(otb_result.otb_value) if otb_result.otb_value is not None else 0
|
||||
actual_lead_time = otb_result.days_out or lead_time
|
||||
|
||||
# Get prior year comparison data (same day of week)
|
||||
prior_year_date = get_prior_year_daily(current_date)
|
||||
prior_year_simulated_today = get_prior_year_daily(simulated_today)
|
||||
|
||||
# Get prior year OTB at same lead time
|
||||
prior_otb_result = db.execute(
|
||||
text("""
|
||||
SELECT otb_value
|
||||
FROM pickup_snapshots
|
||||
WHERE stay_date = :prior_date
|
||||
AND metric_type = :metric
|
||||
AND snapshot_date <= :prior_simulated_today
|
||||
ORDER BY snapshot_date DESC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{
|
||||
"prior_date": prior_year_date,
|
||||
"metric": metric_code,
|
||||
"prior_simulated_today": prior_year_simulated_today
|
||||
}
|
||||
).fetchone()
|
||||
|
||||
# Use 'is not None' - 0 is valid OTB data
|
||||
prior_otb = float(prior_otb_result.otb_value) if prior_otb_result and prior_otb_result.otb_value is not None else None
|
||||
|
||||
# Get prior year final actual
|
||||
prior_final_result = db.execute(
|
||||
text("""
|
||||
SELECT actual_value
|
||||
FROM daily_metrics
|
||||
WHERE date = :prior_date AND metric_code = :metric
|
||||
"""),
|
||||
{"prior_date": prior_year_date, "metric": metric_code}
|
||||
).fetchone()
|
||||
|
||||
prior_final = float(prior_final_result.actual_value) if prior_final_result and prior_final_result.actual_value else None
|
||||
|
||||
# Calculate forecast using ADDITIVE method
|
||||
projected_value = current_otb
|
||||
projection_method = 'current_otb'
|
||||
|
||||
if prior_otb is not None and prior_final is not None:
|
||||
# Additive method: current + expected pickup
|
||||
prior_pickup = prior_final - prior_otb
|
||||
projected_value = current_otb + prior_pickup
|
||||
|
||||
# Floor at current OTB
|
||||
if projected_value < current_otb:
|
||||
projected_value = current_otb
|
||||
projection_method = 'additive_floor'
|
||||
else:
|
||||
projection_method = 'additive'
|
||||
|
||||
# Apply physical caps
|
||||
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
|
||||
projected_value = 100
|
||||
if metric_code == 'hotel_room_nights' and projected_value > total_rooms:
|
||||
projected_value = total_rooms
|
||||
|
||||
elif prior_final is not None and prior_final > 0:
|
||||
# Implied additive method
|
||||
if lead_time >= 28:
|
||||
estimated_pct = 0.35
|
||||
elif lead_time >= 14:
|
||||
estimated_pct = 0.55
|
||||
elif lead_time >= 7:
|
||||
estimated_pct = 0.75
|
||||
else:
|
||||
estimated_pct = 0.90
|
||||
|
||||
implied_prior_otb = prior_final * estimated_pct
|
||||
implied_pickup = prior_final - implied_prior_otb
|
||||
projected_value = current_otb + implied_pickup
|
||||
projected_value = max(projected_value, current_otb)
|
||||
projection_method = 'implied_additive'
|
||||
|
||||
# Apply caps
|
||||
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
|
||||
projected_value = 100
|
||||
if metric_code == 'hotel_room_nights' and projected_value > total_rooms:
|
||||
projected_value = total_rooms
|
||||
|
||||
# Calculate error metrics
|
||||
error = projected_value - actual_value
|
||||
abs_error = abs(error)
|
||||
pct_error = (error / actual_value * 100) if actual_value != 0 else None
|
||||
abs_pct_error = abs(pct_error) if pct_error is not None else None
|
||||
|
||||
result_record = {
|
||||
"target_date": current_date,
|
||||
"lead_time": lead_time,
|
||||
"actual_lead_time": actual_lead_time,
|
||||
"simulated_today": simulated_today,
|
||||
"current_otb": current_otb,
|
||||
"prior_otb": prior_otb,
|
||||
"prior_final": prior_final,
|
||||
"projected_value": round(projected_value, 2),
|
||||
"actual_value": actual_value,
|
||||
"error": round(error, 2),
|
||||
"abs_error": round(abs_error, 2),
|
||||
"pct_error": round(pct_error, 2) if pct_error is not None else None,
|
||||
"abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None,
|
||||
"projection_method": projection_method
|
||||
}
|
||||
results.append(result_record)
|
||||
|
||||
# Store in backtest_results table
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO backtest_results (
|
||||
target_date, metric_code, lead_time, simulated_today,
|
||||
current_otb, prior_otb, prior_final,
|
||||
projected_value, actual_value,
|
||||
error, abs_error, pct_error, abs_pct_error,
|
||||
projection_method, created_at
|
||||
) VALUES (
|
||||
:target_date, :metric, :lead_time, :simulated_today,
|
||||
:current_otb, :prior_otb, :prior_final,
|
||||
:projected_value, :actual_value,
|
||||
:error, :abs_error, :pct_error, :abs_pct_error,
|
||||
:projection_method, NOW()
|
||||
)
|
||||
ON CONFLICT (target_date, metric_code, lead_time) DO UPDATE SET
|
||||
projected_value = :projected_value,
|
||||
actual_value = :actual_value,
|
||||
error = :error,
|
||||
abs_error = :abs_error,
|
||||
pct_error = :pct_error,
|
||||
abs_pct_error = :abs_pct_error,
|
||||
projection_method = :projection_method,
|
||||
created_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"target_date": current_date,
|
||||
"metric": metric_code,
|
||||
"lead_time": lead_time,
|
||||
"simulated_today": simulated_today,
|
||||
"current_otb": current_otb,
|
||||
"prior_otb": prior_otb,
|
||||
"prior_final": prior_final,
|
||||
"projected_value": round(projected_value, 2),
|
||||
"actual_value": actual_value,
|
||||
"error": round(error, 2),
|
||||
"abs_error": round(abs_error, 2),
|
||||
"pct_error": round(pct_error, 2) if pct_error is not None else None,
|
||||
"abs_pct_error": round(abs_pct_error, 2) if abs_pct_error is not None else None,
|
||||
"projection_method": projection_method
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not store backtest result: {e}")
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Calculate summary statistics
|
||||
summary = calculate_backtest_summary(results, lead_times)
|
||||
|
||||
logger.info(f"Backtest complete: {len(results)} forecasts evaluated")
|
||||
|
||||
return {
|
||||
"metric_code": metric_code,
|
||||
"backtest_from": str(backtest_from),
|
||||
"backtest_to": str(backtest_to),
|
||||
"lead_times": lead_times,
|
||||
"total_forecasts": len(results),
|
||||
"results": results,
|
||||
"summary": summary
|
||||
}
|
||||
|
||||
|
||||
def calculate_backtest_summary(results: List[dict], lead_times: List[int]) -> Dict:
|
||||
"""
|
||||
Calculate summary accuracy metrics from backtest results.
|
||||
"""
|
||||
if not results:
|
||||
return {}
|
||||
|
||||
summary = {
|
||||
"overall": {},
|
||||
"by_lead_time": {}
|
||||
}
|
||||
|
||||
# Overall metrics
|
||||
all_errors = [r['abs_error'] for r in results if r['abs_error'] is not None]
|
||||
all_pct_errors = [r['abs_pct_error'] for r in results if r['abs_pct_error'] is not None]
|
||||
|
||||
if all_errors:
|
||||
summary["overall"] = {
|
||||
"mae": round(sum(all_errors) / len(all_errors), 2), # Mean Absolute Error
|
||||
"mape": round(sum(all_pct_errors) / len(all_pct_errors), 2) if all_pct_errors else None, # Mean Absolute Percentage Error
|
||||
"count": len(all_errors)
|
||||
}
|
||||
|
||||
# By lead time
|
||||
for lt in lead_times:
|
||||
lt_results = [r for r in results if r['lead_time'] == lt]
|
||||
lt_errors = [r['abs_error'] for r in lt_results if r['abs_error'] is not None]
|
||||
lt_pct_errors = [r['abs_pct_error'] for r in lt_results if r['abs_pct_error'] is not None]
|
||||
|
||||
if lt_errors:
|
||||
summary["by_lead_time"][lt] = {
|
||||
"mae": round(sum(lt_errors) / len(lt_errors), 2),
|
||||
"mape": round(sum(lt_pct_errors) / len(lt_pct_errors), 2) if lt_pct_errors else None,
|
||||
"count": len(lt_errors)
|
||||
}
|
||||
|
||||
# By projection method
|
||||
methods = set(r['projection_method'] for r in results)
|
||||
summary["by_method"] = {}
|
||||
for method in methods:
|
||||
method_results = [r for r in results if r['projection_method'] == method]
|
||||
method_errors = [r['abs_error'] for r in method_results if r['abs_error'] is not None]
|
||||
method_pct_errors = [r['abs_pct_error'] for r in method_results if r['abs_pct_error'] is not None]
|
||||
|
||||
if method_errors:
|
||||
summary["by_method"][method] = {
|
||||
"mae": round(sum(method_errors) / len(method_errors), 2),
|
||||
"mape": round(sum(method_pct_errors) / len(method_pct_errors), 2) if method_pct_errors else None,
|
||||
"count": len(method_errors)
|
||||
}
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
async def get_backtest_results(
|
||||
db,
|
||||
metric_code: str,
|
||||
from_date: Optional[date] = None,
|
||||
to_date: Optional[date] = None,
|
||||
lead_time: Optional[int] = None
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Retrieve stored backtest results.
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
target_date, metric_code, lead_time, simulated_today,
|
||||
current_otb, prior_otb, prior_final,
|
||||
projected_value, actual_value,
|
||||
error, abs_error, pct_error, abs_pct_error,
|
||||
projection_method, created_at
|
||||
FROM backtest_results
|
||||
WHERE metric_code = :metric
|
||||
"""
|
||||
params = {"metric": metric_code}
|
||||
|
||||
if from_date:
|
||||
query += " AND target_date >= :from_date"
|
||||
params["from_date"] = from_date
|
||||
|
||||
if to_date:
|
||||
query += " AND target_date <= :to_date"
|
||||
params["to_date"] = to_date
|
||||
|
||||
if lead_time:
|
||||
query += " AND lead_time = :lead_time"
|
||||
params["lead_time"] = lead_time
|
||||
|
||||
query += " ORDER BY target_date, lead_time"
|
||||
|
||||
result = db.execute(text(query), params)
|
||||
|
||||
return [
|
||||
{
|
||||
"target_date": str(row.target_date),
|
||||
"metric_code": row.metric_code,
|
||||
"lead_time": row.lead_time,
|
||||
"simulated_today": str(row.simulated_today) if row.simulated_today else None,
|
||||
"current_otb": float(row.current_otb) if row.current_otb is not None else None,
|
||||
"prior_otb": float(row.prior_otb) if row.prior_otb is not None else None,
|
||||
"prior_final": float(row.prior_final) if row.prior_final is not None else None,
|
||||
"projected_value": float(row.projected_value) if row.projected_value is not None else None,
|
||||
"actual_value": float(row.actual_value) if row.actual_value is not None else None,
|
||||
"error": float(row.error) if row.error is not None else None,
|
||||
"abs_error": float(row.abs_error) if row.abs_error is not None else None,
|
||||
"pct_error": float(row.pct_error) if row.pct_error is not None else None,
|
||||
"abs_pct_error": float(row.abs_pct_error) if row.abs_pct_error is not None else None,
|
||||
"projection_method": row.projection_method
|
||||
}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
159
backend/services/forecasting/blended_model.py
Normal file
159
backend/services/forecasting/blended_model.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
Centralized Blended Forecasting Model Service
|
||||
|
||||
This is the single source of truth for blended forecasts.
|
||||
Used by:
|
||||
- Weekly snapshots (saves to DB)
|
||||
- Frontend live previews (on-the-fly)
|
||||
- External apps (reads saved snapshots)
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_blended_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
save_to_db: bool = False,
|
||||
run_id: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Generate blended forecast by running Prophet, XGBoost, CatBoost and blending with accuracy weights.
|
||||
|
||||
This is the centralized blended model used everywhere in the application.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
|
||||
start_date: Start date for forecast
|
||||
end_date: End date for forecast
|
||||
save_to_db: If True, saves forecasts to database (for snapshots)
|
||||
run_id: Run ID for tracking (required if save_to_db=True)
|
||||
|
||||
Returns:
|
||||
List of forecast dicts with date and predicted_value
|
||||
"""
|
||||
logger.info(f"Running blended forecast for {metric_code}: {start_date} to {end_date}")
|
||||
|
||||
# Use simple equal-weight averaging to match frontend behavior
|
||||
# Frontend: (prophet + xgboost + catboost) / 3
|
||||
logger.info(f"Using simple equal-weight averaging for {metric_code}")
|
||||
|
||||
# Step 1: Run individual models
|
||||
forecasts_by_date = {}
|
||||
|
||||
# Run Prophet
|
||||
try:
|
||||
from services.forecasting.prophet_model import run_prophet_forecast
|
||||
prophet_forecasts = await run_prophet_forecast(db, metric_code, start_date, end_date)
|
||||
for fc in prophet_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value'])
|
||||
logger.info(f"Prophet generated {len(prophet_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"Prophet forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run XGBoost
|
||||
try:
|
||||
from services.forecasting.xgboost_model import run_xgboost_forecast
|
||||
xgboost_forecasts = await run_xgboost_forecast(db, metric_code, start_date, end_date)
|
||||
for fc in xgboost_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value'])
|
||||
logger.info(f"XGBoost generated {len(xgboost_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"XGBoost forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run CatBoost
|
||||
try:
|
||||
from services.forecasting.catboost_model import run_catboost_forecast
|
||||
catboost_forecasts = await run_catboost_forecast(db, metric_code, start_date, end_date)
|
||||
for fc in catboost_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value'])
|
||||
logger.info(f"CatBoost generated {len(catboost_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"CatBoost forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run Pickup-V2 for revenue metrics (net_accom, hotel_accommodation_rev)
|
||||
if metric_code in ('net_accom', 'hotel_accommodation_rev'):
|
||||
try:
|
||||
from services.forecasting.pickup_v2_model import run_pickup_v2_forecast
|
||||
pickup_v2_forecasts = await run_pickup_v2_forecast(db, 'net_accom', start_date, end_date)
|
||||
for fc in pickup_v2_forecasts:
|
||||
fc_date = str(fc['date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['pickup_v2'] = float(fc['predicted_value'])
|
||||
logger.info(f"Pickup-V2 generated {len(pickup_v2_forecasts)} forecasts for {metric_code}")
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Pickup-V2 forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit()
|
||||
|
||||
# Step 3: Calculate simple average blended forecast
|
||||
# Match frontend logic: (prophet + xgboost + catboost) / 3
|
||||
blended_forecasts = []
|
||||
for fc_date, model_forecasts in forecasts_by_date.items():
|
||||
# Need at least 2 models to blend
|
||||
if len(model_forecasts) < 2:
|
||||
continue
|
||||
|
||||
# Simple average of all available models
|
||||
blended_value = sum(model_forecasts.values()) / len(model_forecasts)
|
||||
|
||||
blended_forecasts.append({
|
||||
'date': fc_date,
|
||||
'predicted_value': round(blended_value, 2)
|
||||
})
|
||||
|
||||
logger.info(f"Generated {len(blended_forecasts)} blended forecasts for {metric_code}")
|
||||
|
||||
# Step 4: Optionally save to database (for snapshots)
|
||||
if save_to_db and run_id:
|
||||
try:
|
||||
for fc in blended_forecasts:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts
|
||||
(run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at)
|
||||
VALUES
|
||||
(:run_id, :forecast_date, :forecast_type, 'blended', :predicted_value, NOW())
|
||||
"""),
|
||||
{
|
||||
"run_id": run_id,
|
||||
"forecast_date": fc['date'],
|
||||
"forecast_type": metric_code,
|
||||
"predicted_value": fc['predicted_value']
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
logger.info(f"Saved {len(blended_forecasts)} blended forecasts to database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save blended forecasts to database: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
return blended_forecasts
|
||||
164
backend/services/forecasting/blended_tuned.py
Normal file
164
backend/services/forecasting/blended_tuned.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""
|
||||
Blended Tuned Model Service
|
||||
|
||||
This blends the production-tuned models using the exact same logic as the frontend.
|
||||
Ensures backend snapshots match frontend preview values.
|
||||
|
||||
Blending Logic:
|
||||
- Pace metrics (rooms/occupancy): Prophet + XGBoost + CatBoost + Pickup (25% each)
|
||||
- Other metrics: Prophet + XGBoost + CatBoost (33.3% each)
|
||||
|
||||
This is the single source of truth for blended forecast snapshots.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_blended_tuned_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
save_to_db: bool = False,
|
||||
run_id: Optional[str] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Generate blended forecast using production-tuned models.
|
||||
|
||||
This uses the exact same logic as the frontend Live Blended view to ensure
|
||||
backend snapshots match frontend preview values.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
|
||||
start_date: Start date for forecast
|
||||
end_date: End date for forecast
|
||||
save_to_db: If True, saves forecasts to database (for snapshots)
|
||||
run_id: Run ID for tracking (required if save_to_db=True)
|
||||
|
||||
Returns:
|
||||
List of forecast dicts with date and predicted_value
|
||||
"""
|
||||
logger.info(f"Running blended tuned forecast for {metric_code}: {start_date} to {end_date}")
|
||||
|
||||
# Check if metric is a pace metric (uses pickup)
|
||||
is_pace_metric = metric_code in ('hotel_occupancy_pct', 'hotel_room_nights')
|
||||
|
||||
# Step 1: Run individual tuned models
|
||||
forecasts_by_date = {}
|
||||
|
||||
# Run Prophet Tuned
|
||||
try:
|
||||
from services.forecasting.prophet_tuned import run_prophet_tuned_forecast
|
||||
prophet_forecasts = await run_prophet_tuned_forecast(db, metric_code, start_date, end_date)
|
||||
for fc in prophet_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value'])
|
||||
logger.info(f"Prophet tuned generated {len(prophet_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"Prophet tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run XGBoost Tuned
|
||||
try:
|
||||
from services.forecasting.xgboost_tuned import run_xgboost_tuned_forecast
|
||||
xgboost_forecasts = await run_xgboost_tuned_forecast(db, metric_code, start_date, end_date)
|
||||
for fc in xgboost_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value'])
|
||||
logger.info(f"XGBoost tuned generated {len(xgboost_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"XGBoost tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run CatBoost Tuned
|
||||
try:
|
||||
from services.forecasting.catboost_tuned import run_catboost_tuned_forecast
|
||||
catboost_forecasts = await run_catboost_tuned_forecast(db, metric_code, start_date, end_date)
|
||||
for fc in catboost_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value'])
|
||||
logger.info(f"CatBoost tuned generated {len(catboost_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"CatBoost tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run Pickup Tuned (only for pace metrics)
|
||||
if is_pace_metric:
|
||||
try:
|
||||
from services.forecasting.pickup_tuned import run_pickup_tuned_forecast
|
||||
pickup_forecasts = await run_pickup_tuned_forecast(db, metric_code, start_date, end_date)
|
||||
for fc in pickup_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['pickup'] = float(fc['predicted_value'])
|
||||
logger.info(f"Pickup tuned generated {len(pickup_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"Pickup tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Step 2: Calculate simple average blended forecast
|
||||
# Match frontend logic:
|
||||
# - Pace metrics: (prophet + xgboost + catboost + pickup) / 4
|
||||
# - Other metrics: (prophet + xgboost + catboost) / 3
|
||||
blended_forecasts = []
|
||||
for fc_date, model_forecasts in forecasts_by_date.items():
|
||||
# Need at least 2 models to blend
|
||||
if len(model_forecasts) < 2:
|
||||
continue
|
||||
|
||||
# Simple average of all available models
|
||||
blended_value = sum(model_forecasts.values()) / len(model_forecasts)
|
||||
|
||||
blended_forecasts.append({
|
||||
'date': fc_date,
|
||||
'predicted_value': round(blended_value, 2)
|
||||
})
|
||||
|
||||
logger.info(f"Generated {len(blended_forecasts)} blended tuned forecasts for {metric_code}")
|
||||
|
||||
# Step 3: Optionally save to database (for snapshots)
|
||||
if save_to_db and run_id:
|
||||
try:
|
||||
for fc in blended_forecasts:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts
|
||||
(run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at)
|
||||
VALUES
|
||||
(:run_id, :forecast_date, :forecast_type, 'blended_tuned', :predicted_value, NOW())
|
||||
"""),
|
||||
{
|
||||
"run_id": run_id,
|
||||
"forecast_date": fc['date'],
|
||||
"forecast_type": metric_code,
|
||||
"predicted_value": fc['predicted_value']
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
logger.info(f"Saved {len(blended_forecasts)} blended tuned forecasts to database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save blended tuned forecasts to database: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
return blended_forecasts
|
||||
336
backend/services/forecasting/blended_tuned_weighted.py
Normal file
336
backend/services/forecasting/blended_tuned_weighted.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""
|
||||
Blended Tuned Model Service (Accuracy-Weighted + 60/40 Prior Year/Budget)
|
||||
|
||||
Two-stage blending:
|
||||
1. MAPE-weighted model blend using backtest accuracy data
|
||||
2. 60/40 blend with prior year actual or budget
|
||||
|
||||
Stage 1 - Model Weighting (MAPE-based):
|
||||
- Query forecast_snapshots table for backtest MAPE scores
|
||||
- Calculate inverse-MAPE weights (lower MAPE = higher weight)
|
||||
- Pace metrics: weighted blend of Prophet + XGBoost + CatBoost + Pickup
|
||||
- Other metrics: weighted blend of Prophet + XGBoost + CatBoost
|
||||
|
||||
Stage 2 - 60/40 Blend:
|
||||
- Revenue metrics: 60% weighted model blend + 40% budget
|
||||
- Non-revenue metrics: 60% weighted model blend + 40% prior year actual
|
||||
|
||||
Falls back to 100% model blend if prior year/budget data unavailable.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_model_weights(db, metric_code: str, is_pace_metric: bool) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate accuracy-based weights for each model using MAPE scores from backtest data.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast
|
||||
is_pace_metric: Whether this is a pace metric (uses pickup)
|
||||
|
||||
Returns:
|
||||
Dict of model names to weights (normalized to sum to 1.0)
|
||||
"""
|
||||
# Map metric codes to forecast_snapshots metric codes
|
||||
metric_map = {
|
||||
'hotel_occupancy_pct': 'occupancy',
|
||||
'hotel_room_nights': 'rooms',
|
||||
'hotel_guests': 'guests',
|
||||
'hotel_arr': 'arr',
|
||||
'ave_guest_rate': 'ave_guest_rate',
|
||||
'net_accom': 'net_accom',
|
||||
'net_dry': 'net_dry',
|
||||
'net_wet': 'net_wet',
|
||||
'total_rev': 'total_rev',
|
||||
}
|
||||
|
||||
snapshot_metric = metric_map.get(metric_code, 'rooms')
|
||||
|
||||
try:
|
||||
# Query MAPE from forecast_snapshots where we have actuals
|
||||
# Calculate MAPE for each model separately
|
||||
models_to_query = ['prophet', 'xgboost', 'catboost']
|
||||
if is_pace_metric:
|
||||
models_to_query.append('pickup')
|
||||
|
||||
mape_scores = {}
|
||||
for model in models_to_query:
|
||||
query = text("""
|
||||
SELECT AVG(ABS((forecast_value - actual_value) / NULLIF(actual_value, 0)) * 100) as mape
|
||||
FROM forecast_snapshots
|
||||
WHERE actual_value IS NOT NULL
|
||||
AND actual_value != 0
|
||||
AND forecast_value IS NOT NULL
|
||||
AND metric_code = :metric_code
|
||||
AND model = :model
|
||||
""")
|
||||
result = await db.execute(query, {"metric_code": snapshot_metric, "model": model})
|
||||
row = result.fetchone()
|
||||
|
||||
if row and row.mape is not None:
|
||||
mape_scores[model] = float(row.mape)
|
||||
else:
|
||||
mape_scores[model] = 100 # Default high MAPE if no data
|
||||
|
||||
# Check if we have valid MAPE data
|
||||
if all(score == 100 for score in mape_scores.values()):
|
||||
logger.warning(f"No MAPE data found for {snapshot_metric}, using equal weights")
|
||||
# Fall back to equal weights
|
||||
if is_pace_metric:
|
||||
return {'prophet': 0.25, 'xgboost': 0.25, 'catboost': 0.25, 'pickup': 0.25}
|
||||
else:
|
||||
return {'prophet': 0.333, 'xgboost': 0.333, 'catboost': 0.334}
|
||||
|
||||
logger.info(f"MAPE scores for {snapshot_metric}: " +
|
||||
", ".join([f"{k}={v:.2f}%" for k, v in mape_scores.items()]))
|
||||
|
||||
# Calculate inverse-MAPE weights (lower MAPE = higher weight)
|
||||
weights = {model: 1.0 / max(mape, 0.1) for model, mape in mape_scores.items()}
|
||||
|
||||
# Normalize weights to sum to 1.0
|
||||
weight_sum = sum(weights.values())
|
||||
normalized_weights = {k: v / weight_sum for k, v in weights.items()}
|
||||
|
||||
logger.info(f"Normalized weights for {snapshot_metric}: " +
|
||||
", ".join([f"{k}={v:.4f}" for k, v in normalized_weights.items()]))
|
||||
return normalized_weights
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to calculate model weights: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# Fall back to equal weights
|
||||
if is_pace_metric:
|
||||
return {'prophet': 0.25, 'xgboost': 0.25, 'catboost': 0.25, 'pickup': 0.25}
|
||||
else:
|
||||
return {'prophet': 0.333, 'xgboost': 0.333, 'catboost': 0.334}
|
||||
|
||||
|
||||
async def run_blended_tuned_weighted_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
save_to_db: bool = False,
|
||||
run_id: Optional[str] = None,
|
||||
perception_date: Optional[date] = None,
|
||||
apply_60_40_blend: bool = True
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Generate blended forecast using production-tuned models with accuracy-based weighting.
|
||||
|
||||
This uses MAPE scores from the last 90 days to weight models by accuracy.
|
||||
Lower MAPE (more accurate) models receive higher weights.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
|
||||
start_date: Start date for forecast
|
||||
end_date: End date for forecast
|
||||
save_to_db: If True, saves forecasts to database (for snapshots)
|
||||
run_id: Run ID for tracking (required if save_to_db=True)
|
||||
perception_date: Optional date to generate forecast as-of (for backtesting)
|
||||
apply_60_40_blend: If True, applies 60/40 blend with budget/prior year (default True)
|
||||
|
||||
Returns:
|
||||
List of forecast dicts with date and predicted_value
|
||||
"""
|
||||
logger.info(f"Running blended tuned WEIGHTED forecast for {metric_code}: {start_date} to {end_date}")
|
||||
|
||||
# Check if metric is a pace metric (uses pickup)
|
||||
is_pace_metric = metric_code in ('hotel_occupancy_pct', 'hotel_room_nights')
|
||||
|
||||
# Get accuracy-based weights
|
||||
weights = await get_model_weights(db, metric_code, is_pace_metric)
|
||||
|
||||
# Step 1: Run individual tuned models
|
||||
forecasts_by_date = {}
|
||||
|
||||
# Run Prophet Tuned
|
||||
try:
|
||||
from services.forecasting.prophet_tuned import run_prophet_tuned_forecast
|
||||
prophet_forecasts = await run_prophet_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
|
||||
for fc in prophet_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['prophet'] = float(fc['predicted_value'])
|
||||
logger.info(f"Prophet tuned generated {len(prophet_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"Prophet tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run XGBoost Tuned
|
||||
try:
|
||||
from services.forecasting.xgboost_tuned import run_xgboost_tuned_forecast
|
||||
xgboost_forecasts = await run_xgboost_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
|
||||
for fc in xgboost_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['xgboost'] = float(fc['predicted_value'])
|
||||
logger.info(f"XGBoost tuned generated {len(xgboost_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"XGBoost tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run CatBoost Tuned
|
||||
try:
|
||||
from services.forecasting.catboost_tuned import run_catboost_tuned_forecast
|
||||
catboost_forecasts = await run_catboost_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
|
||||
for fc in catboost_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['catboost'] = float(fc['predicted_value'])
|
||||
logger.info(f"CatBoost tuned generated {len(catboost_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"CatBoost tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Run Pickup Tuned (only for pace metrics)
|
||||
if is_pace_metric:
|
||||
try:
|
||||
from services.forecasting.pickup_tuned import run_pickup_tuned_forecast
|
||||
pickup_forecasts = await run_pickup_tuned_forecast(db, metric_code, start_date, end_date, perception_date)
|
||||
for fc in pickup_forecasts:
|
||||
fc_date = str(fc['forecast_date'])
|
||||
if fc_date not in forecasts_by_date:
|
||||
forecasts_by_date[fc_date] = {}
|
||||
forecasts_by_date[fc_date]['pickup'] = float(fc['predicted_value'])
|
||||
logger.info(f"Pickup tuned generated {len(pickup_forecasts)} forecasts for {metric_code}")
|
||||
db.commit() # Commit after successful model run
|
||||
except Exception as e:
|
||||
logger.error(f"Pickup tuned forecast failed for {metric_code}: {e}")
|
||||
db.rollback()
|
||||
db.commit() # Start fresh transaction
|
||||
|
||||
# Step 2: Calculate MAPE-weighted model blend, then apply 60/40 with prior year/budget
|
||||
# Determine if this is a revenue metric (uses budget instead of prior year)
|
||||
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev', 'hotel_arr']
|
||||
is_revenue_metric = metric_code in revenue_metrics
|
||||
|
||||
blended_forecasts = []
|
||||
for fc_date, model_forecasts in forecasts_by_date.items():
|
||||
# Need at least 2 models to blend
|
||||
if len(model_forecasts) < 2:
|
||||
continue
|
||||
|
||||
# Apply accuracy-based weights to get weighted model blend
|
||||
weighted_sum = 0.0
|
||||
weight_total = 0.0
|
||||
|
||||
for model_name, forecast_value in model_forecasts.items():
|
||||
if model_name in weights:
|
||||
weighted_sum += forecast_value * weights[model_name]
|
||||
weight_total += weights[model_name]
|
||||
|
||||
# Calculate weighted model average
|
||||
if weight_total > 0:
|
||||
weighted_model_blend = weighted_sum / weight_total
|
||||
else:
|
||||
# Fall back to simple average if weights are missing
|
||||
weighted_model_blend = sum(model_forecasts.values()) / len(model_forecasts)
|
||||
|
||||
# Apply 60/40 blend with prior year or budget (if enabled)
|
||||
final_value = weighted_model_blend # Default: use model blend only
|
||||
|
||||
if apply_60_40_blend:
|
||||
try:
|
||||
forecast_date_obj = date.fromisoformat(fc_date)
|
||||
|
||||
if is_revenue_metric:
|
||||
# Revenue metrics: 60% model + 40% budget
|
||||
budget_query = text("""
|
||||
SELECT budget_value
|
||||
FROM daily_budgets
|
||||
WHERE date = :fc_date AND budget_type = :metric_code
|
||||
""")
|
||||
budget_result = await db.execute(budget_query, {
|
||||
"fc_date": forecast_date_obj,
|
||||
"metric_code": metric_code
|
||||
})
|
||||
budget_row = budget_result.fetchone()
|
||||
if budget_row and budget_row.budget_value is not None:
|
||||
budget_value = float(budget_row.budget_value)
|
||||
final_value = 0.6 * weighted_model_blend + 0.4 * budget_value
|
||||
logger.debug(f"{fc_date}: Model={weighted_model_blend:.2f}, Budget={budget_value:.2f}, Final={final_value:.2f}")
|
||||
else:
|
||||
# Non-revenue metrics: 60% model + 40% prior year
|
||||
# Map metric codes for daily_metrics table
|
||||
daily_metric_map = {
|
||||
'hotel_occupancy_pct': 'hotel_occupancy_pct',
|
||||
'hotel_room_nights': 'hotel_room_nights',
|
||||
'hotel_guests': 'hotel_guests',
|
||||
'ave_guest_rate': 'ave_guest_rate',
|
||||
}
|
||||
daily_metric_code = daily_metric_map.get(metric_code, metric_code)
|
||||
|
||||
# Get prior year date (same day of week, ~52 weeks back)
|
||||
from utils.time_alignment import get_prior_year_daily
|
||||
prior_year_date = get_prior_year_daily(forecast_date_obj)
|
||||
|
||||
prior_query = text("""
|
||||
SELECT actual_value
|
||||
FROM daily_metrics
|
||||
WHERE date = :prior_date AND metric_code = :metric_code
|
||||
""")
|
||||
prior_result = await db.execute(prior_query, {
|
||||
"prior_date": prior_year_date,
|
||||
"metric_code": daily_metric_code
|
||||
})
|
||||
prior_row = prior_result.fetchone()
|
||||
if prior_row and prior_row.actual_value is not None:
|
||||
prior_value = float(prior_row.actual_value)
|
||||
final_value = 0.6 * weighted_model_blend + 0.4 * prior_value
|
||||
logger.debug(f"{fc_date}: Model={weighted_model_blend:.2f}, PriorYear={prior_value:.2f}, Final={final_value:.2f}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not apply 60/40 blend for {fc_date}: {e}, using model blend only")
|
||||
final_value = weighted_model_blend
|
||||
|
||||
blended_forecasts.append({
|
||||
'date': fc_date,
|
||||
'predicted_value': round(final_value, 2)
|
||||
})
|
||||
|
||||
logger.info(f"Generated {len(blended_forecasts)} blended tuned WEIGHTED forecasts for {metric_code}")
|
||||
|
||||
# Step 3: Optionally save to database (for snapshots)
|
||||
if save_to_db and run_id:
|
||||
try:
|
||||
for fc in blended_forecasts:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts
|
||||
(run_id, forecast_date, forecast_type, model_type, predicted_value, generated_at)
|
||||
VALUES
|
||||
(:run_id, :forecast_date, :forecast_type, 'blended_tuned_weighted', :predicted_value, NOW())
|
||||
"""),
|
||||
{
|
||||
"run_id": run_id,
|
||||
"forecast_date": fc['date'],
|
||||
"forecast_type": metric_code,
|
||||
"predicted_value": fc['predicted_value']
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
logger.info(f"Saved {len(blended_forecasts)} blended tuned weighted forecasts to database")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save blended tuned weighted forecasts to database: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
return blended_forecasts
|
||||
254
backend/services/forecasting/budget_service.py
Normal file
254
backend/services/forecasting/budget_service.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
Budget distribution service
|
||||
Distributes monthly budgets to daily values using prior year patterns
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from calendar import monthrange
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map budget_type to column name in newbook_net_revenue_data
|
||||
BUDGET_TYPE_TO_COLUMN = {
|
||||
'net_accom': 'accommodation',
|
||||
'net_dry': 'dry',
|
||||
'net_wet': 'wet',
|
||||
}
|
||||
|
||||
|
||||
async def distribute_budget(
|
||||
db,
|
||||
year: int,
|
||||
month: int,
|
||||
budget_type: Optional[str] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Distribute monthly budget to daily values using DOW-aligned prior year patterns.
|
||||
|
||||
Uses 364-day offset (52 weeks) so that days of week align between years.
|
||||
This ensures weekday/weekend patterns are preserved in the distribution.
|
||||
|
||||
Logic:
|
||||
1. Get monthly budget from FD-provided values
|
||||
2. For each day in target month, find DOW-aligned date 364 days prior
|
||||
3. Get prior year actual values for those DOW-aligned dates
|
||||
4. Calculate percentages and distribute budget accordingly
|
||||
|
||||
Example:
|
||||
- Feb 1, 2026 (Sunday) - 364 days = Feb 2, 2025 (Sunday)
|
||||
- If Feb 2, 2025 was 4% of the DOW-aligned period total
|
||||
- Feb 1, 2026 daily budget = monthly_budget × 4%
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
year: Year to distribute for
|
||||
month: Month to distribute (1-12)
|
||||
budget_type: Optional specific budget type, or all if None
|
||||
|
||||
Returns:
|
||||
Dict with distribution results
|
||||
"""
|
||||
logger.info(f"Distributing budget for {year}-{month:02d} using DOW-aligned prior year patterns")
|
||||
|
||||
# Get monthly budgets
|
||||
query = """
|
||||
SELECT id, budget_type, budget_value
|
||||
FROM monthly_budgets
|
||||
WHERE year = :year AND month = :month
|
||||
"""
|
||||
params = {"year": year, "month": month}
|
||||
|
||||
if budget_type:
|
||||
query += " AND budget_type = :budget_type"
|
||||
params["budget_type"] = budget_type
|
||||
|
||||
result = await db.execute(text(query), params)
|
||||
monthly_budgets = result.fetchall()
|
||||
|
||||
if not monthly_budgets:
|
||||
logger.warning(f"No monthly budgets found for {year}-{month:02d}")
|
||||
return {"days_distributed": 0, "status": "no_budgets_found"}
|
||||
|
||||
# Get days in target month
|
||||
_, days_in_month = monthrange(year, month)
|
||||
|
||||
days_distributed = 0
|
||||
|
||||
for budget in monthly_budgets:
|
||||
budget_id = budget.id
|
||||
btype = budget.budget_type
|
||||
monthly_value = float(budget.budget_value)
|
||||
|
||||
# Get column name for this budget type
|
||||
column_name = BUDGET_TYPE_TO_COLUMN.get(btype)
|
||||
if not column_name:
|
||||
logger.warning(f"Unknown budget type: {btype}, skipping")
|
||||
continue
|
||||
|
||||
logger.info(f"Distributing {btype}: £{monthly_value:,.2f}")
|
||||
|
||||
# Build list of target dates and their DOW-aligned prior year dates
|
||||
target_dates = []
|
||||
prior_dates = []
|
||||
for day in range(1, days_in_month + 1):
|
||||
target_date = date(year, month, day)
|
||||
prior_date = target_date - timedelta(days=364) # 52 weeks back, DOW aligned
|
||||
target_dates.append(target_date)
|
||||
prior_dates.append(prior_date)
|
||||
|
||||
# Get prior year actual values for the DOW-aligned dates from newbook_net_revenue_data
|
||||
prior_result = await db.execute(
|
||||
text(f"""
|
||||
SELECT date, {column_name} as value
|
||||
FROM newbook_net_revenue_data
|
||||
WHERE date = ANY(:dates)
|
||||
AND {column_name} IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"dates": prior_dates}
|
||||
)
|
||||
prior_rows = prior_result.fetchall()
|
||||
prior_values = {row.date: float(row.value) for row in prior_rows}
|
||||
|
||||
# Calculate total of prior year values for percentage calculation
|
||||
total_prior = sum(prior_values.get(d, 0) for d in prior_dates)
|
||||
|
||||
logger.info(f"Prior year DOW-aligned total for {btype}: £{total_prior:,.2f} ({len(prior_values)} days with data)")
|
||||
|
||||
if total_prior > 0:
|
||||
# Distribute using DOW-aligned prior year percentages
|
||||
for target_date, prior_date in zip(target_dates, prior_dates):
|
||||
prior_value = prior_values.get(prior_date, 0)
|
||||
pct_of_total = prior_value / total_prior if total_prior > 0 else (1 / days_in_month)
|
||||
daily_budget = monthly_value * pct_of_total
|
||||
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO daily_budgets (
|
||||
date, budget_type, budget_value, distribution_method,
|
||||
prior_year_pct, monthly_budget_id, calculated_at
|
||||
) VALUES (
|
||||
:date, :btype, :value, 'dow_aligned',
|
||||
:pct, :budget_id, NOW()
|
||||
)
|
||||
ON CONFLICT (date, budget_type) DO UPDATE SET
|
||||
budget_value = :value,
|
||||
distribution_method = 'dow_aligned',
|
||||
prior_year_pct = :pct,
|
||||
calculated_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"date": target_date,
|
||||
"btype": btype,
|
||||
"value": round(daily_budget, 2),
|
||||
"pct": round(pct_of_total, 6),
|
||||
"budget_id": budget_id
|
||||
}
|
||||
)
|
||||
days_distributed += 1
|
||||
|
||||
logger.info(f"Distributed {btype} using DOW-aligned prior year patterns")
|
||||
else:
|
||||
# No prior year data - distribute evenly
|
||||
logger.warning(f"No prior year DOW-aligned data for {btype}, using even distribution")
|
||||
daily_budget = monthly_value / days_in_month
|
||||
|
||||
for target_date in target_dates:
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO daily_budgets (
|
||||
date, budget_type, budget_value, distribution_method,
|
||||
prior_year_pct, monthly_budget_id, calculated_at
|
||||
) VALUES (
|
||||
:date, :btype, :value, 'even',
|
||||
:pct, :budget_id, NOW()
|
||||
)
|
||||
ON CONFLICT (date, budget_type) DO UPDATE SET
|
||||
budget_value = :value,
|
||||
distribution_method = 'even',
|
||||
prior_year_pct = :pct,
|
||||
calculated_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"date": target_date,
|
||||
"btype": btype,
|
||||
"value": round(daily_budget, 2),
|
||||
"pct": round(1 / days_in_month, 6),
|
||||
"budget_id": budget_id
|
||||
}
|
||||
)
|
||||
days_distributed += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Budget distribution complete: {days_distributed} days")
|
||||
return {"days_distributed": days_distributed, "status": "success"}
|
||||
|
||||
|
||||
async def calculate_prior_year_percentages(db, metric_type: str):
|
||||
"""
|
||||
Calculate and store prior year daily percentages for budget distribution
|
||||
|
||||
For each month, calculates what percentage each day was of the monthly total
|
||||
"""
|
||||
logger.info(f"Calculating prior year percentages for {metric_type}")
|
||||
|
||||
# Get all daily values from prior year
|
||||
result = await db.execute(
|
||||
text("""
|
||||
WITH monthly_totals AS (
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM date) as year,
|
||||
EXTRACT(MONTH FROM date) as month,
|
||||
SUM(actual_value) as month_total
|
||||
FROM daily_metrics
|
||||
WHERE metric_code = :metric_type
|
||||
AND date >= CURRENT_DATE - INTERVAL '2 years'
|
||||
AND actual_value IS NOT NULL
|
||||
GROUP BY EXTRACT(YEAR FROM date), EXTRACT(MONTH FROM date)
|
||||
)
|
||||
SELECT
|
||||
dm.date,
|
||||
dm.actual_value,
|
||||
mt.month_total,
|
||||
dm.actual_value / NULLIF(mt.month_total, 0) as pct_of_month
|
||||
FROM daily_metrics dm
|
||||
JOIN monthly_totals mt ON
|
||||
EXTRACT(YEAR FROM dm.date) = mt.year AND
|
||||
EXTRACT(MONTH FROM dm.date) = mt.month
|
||||
WHERE dm.metric_code = :metric_type
|
||||
AND dm.actual_value IS NOT NULL
|
||||
ORDER BY dm.date
|
||||
"""),
|
||||
{"metric_type": metric_type}
|
||||
)
|
||||
|
||||
count = 0
|
||||
for row in result.fetchall():
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO prior_year_daily (
|
||||
date, metric_type, actual_value, month_total, pct_of_month, fetched_at
|
||||
) VALUES (
|
||||
:date, :metric, :actual, :month_total, :pct, NOW()
|
||||
)
|
||||
ON CONFLICT (date, metric_type) DO UPDATE SET
|
||||
actual_value = :actual,
|
||||
month_total = :month_total,
|
||||
pct_of_month = :pct,
|
||||
fetched_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"date": row.date,
|
||||
"metric": metric_type,
|
||||
"actual": row.actual_value,
|
||||
"month_total": row.month_total,
|
||||
"pct": row.pct_of_month
|
||||
}
|
||||
)
|
||||
count += 1
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Prior year percentages calculated: {count} records for {metric_type}")
|
||||
return count
|
||||
450
backend/services/forecasting/catboost_model.py
Normal file
450
backend/services/forecasting/catboost_model.py
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
"""
|
||||
CatBoost forecasting model
|
||||
Gradient boosting with native categorical feature support and better out-of-box performance.
|
||||
Similar to XGBoost but handles categorical features natively without encoding.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import json
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_features(df: pd.DataFrame, special_dates: set = None) -> pd.DataFrame:
|
||||
"""
|
||||
Create features for CatBoost model.
|
||||
|
||||
CatBoost handles categorical features natively, so we keep day_of_week as categorical
|
||||
instead of using cyclical encoding.
|
||||
"""
|
||||
df = df.copy()
|
||||
|
||||
# Date features - keep as categorical for CatBoost
|
||||
df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical
|
||||
df['month'] = df['ds'].dt.month.astype(str) # Categorical
|
||||
df['day_of_month'] = df['ds'].dt.day
|
||||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||||
df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int)
|
||||
|
||||
# Special dates / holidays
|
||||
if special_dates and len(special_dates) > 0:
|
||||
df['is_holiday'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_dates else 0)
|
||||
|
||||
# Days to nearest special date
|
||||
def days_to_nearest(d):
|
||||
if not special_dates:
|
||||
return 30
|
||||
future = [s for s in special_dates if s >= d]
|
||||
if not future:
|
||||
return 30
|
||||
return min((s - d).days for s in future)
|
||||
|
||||
df['days_to_holiday'] = df['ds'].dt.date.apply(days_to_nearest)
|
||||
else:
|
||||
df['is_holiday'] = 0
|
||||
df['days_to_holiday'] = 30
|
||||
|
||||
# Lag features
|
||||
for lag in [7, 14, 21, 28]:
|
||||
df[f'lag_{lag}'] = df['y'].shift(lag)
|
||||
|
||||
# Rolling averages
|
||||
for window in [7, 14, 28]:
|
||||
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
|
||||
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std().fillna(0)
|
||||
|
||||
# Year-over-year feature (364 days for DOW alignment)
|
||||
if len(df) > 364:
|
||||
df['lag_364'] = df['y'].shift(364)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
async def run_catboost_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
forecast_from: date,
|
||||
forecast_to: date,
|
||||
training_days: int = 2555, # ~7 years
|
||||
use_special_dates: bool = True,
|
||||
use_otb_data: bool = True
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Run CatBoost forecast for a metric.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast
|
||||
forecast_from: Start date for forecasts
|
||||
forecast_to: End date for forecasts
|
||||
training_days: Days of historical data to use
|
||||
use_special_dates: Include holiday features
|
||||
use_otb_data: Include OTB pickup features
|
||||
|
||||
Returns:
|
||||
List of forecast records
|
||||
"""
|
||||
try:
|
||||
from catboost import CatBoostRegressor
|
||||
|
||||
# Get historical data
|
||||
training_from = forecast_from - timedelta(days=training_days + 400) # Extra for lag features
|
||||
|
||||
# Revenue metrics use earned_revenue_data joined with gl_accounts
|
||||
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev']
|
||||
if metric_code in revenue_metrics:
|
||||
revenue_departments = {
|
||||
'net_accom': 'accommodation',
|
||||
'net_dry': 'dry',
|
||||
'net_wet': 'wet',
|
||||
'total_rev': None, # All departments
|
||||
}
|
||||
department = revenue_departments.get(metric_code)
|
||||
if department is None and metric_code != 'total_rev':
|
||||
logger.warning(f"Unknown revenue metric for CatBoost: {metric_code}")
|
||||
return []
|
||||
|
||||
if metric_code == 'total_rev':
|
||||
# Total revenue across all departments
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT date, SUM(amount_net) as actual_value
|
||||
FROM newbook_earned_revenue_data
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
GROUP BY date
|
||||
HAVING SUM(amount_net) IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
|
||||
)
|
||||
else:
|
||||
# Revenue by department
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT r.date, SUM(r.amount_net) as actual_value
|
||||
FROM newbook_earned_revenue_data r
|
||||
JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id
|
||||
WHERE r.date BETWEEN :from_date AND :to_date
|
||||
AND g.department = :department
|
||||
GROUP BY r.date
|
||||
HAVING SUM(r.amount_net) IS NOT NULL
|
||||
ORDER BY r.date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department}
|
||||
)
|
||||
else:
|
||||
# Hotel metrics use newbook_bookings_stats table
|
||||
metric_column_map = {
|
||||
'hotel_occupancy_pct': 'total_occupancy_pct',
|
||||
'hotel_room_nights': 'booking_count',
|
||||
'hotel_guests': 'guests_count',
|
||||
}
|
||||
|
||||
column_name = metric_column_map.get(metric_code)
|
||||
if not column_name:
|
||||
logger.warning(f"Unknown metric_code for CatBoost: {metric_code}")
|
||||
return []
|
||||
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
SELECT date, {column_name} as actual_value
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
AND {column_name} IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
if len(rows) < 60:
|
||||
logger.warning(f"Insufficient data for CatBoost: {metric_code} has {len(rows)} records")
|
||||
return []
|
||||
|
||||
# Prepare DataFrame
|
||||
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
|
||||
df = df.sort_values('ds').reset_index(drop=True)
|
||||
|
||||
# Load special dates if enabled
|
||||
special_dates = None
|
||||
if use_special_dates:
|
||||
special_dates = await _load_special_dates(db, forecast_from, forecast_to)
|
||||
|
||||
# Create features
|
||||
df = create_features(df, special_dates)
|
||||
|
||||
# Add OTB features if enabled
|
||||
if use_otb_data:
|
||||
otb_df = _load_otb_data(db, training_from, forecast_from)
|
||||
if otb_df is not None and len(otb_df) > 0:
|
||||
df = _add_otb_features(df, otb_df)
|
||||
logger.info("Added OTB features to CatBoost training data")
|
||||
|
||||
# Remove rows with NaN from lag features
|
||||
df = df.dropna(subset=['lag_7', 'lag_14', 'lag_21', 'lag_28'])
|
||||
|
||||
# Define feature columns
|
||||
categorical_features = ['day_of_week', 'month']
|
||||
|
||||
numerical_features = [
|
||||
'day_of_month', 'week_of_year', 'is_weekend',
|
||||
'is_holiday', 'days_to_holiday',
|
||||
'lag_7', 'lag_14', 'lag_21', 'lag_28',
|
||||
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
|
||||
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
|
||||
]
|
||||
|
||||
# Add lag_364 if available
|
||||
if 'lag_364' in df.columns and df['lag_364'].notna().sum() > 30:
|
||||
numerical_features.append('lag_364')
|
||||
|
||||
# Add OTB features if present
|
||||
otb_cols = ['otb_at_30d', 'otb_at_14d', 'otb_at_7d',
|
||||
'pickup_30d_to_14d', 'pickup_14d_to_7d',
|
||||
'otb_pct_at_30d', 'otb_pct_at_14d', 'otb_pct_at_7d']
|
||||
for col in otb_cols:
|
||||
if col in df.columns:
|
||||
numerical_features.append(col)
|
||||
|
||||
feature_cols = categorical_features + numerical_features
|
||||
|
||||
X = df[feature_cols].copy()
|
||||
y = df['y']
|
||||
|
||||
# Train CatBoost model
|
||||
model = CatBoostRegressor(
|
||||
iterations=200,
|
||||
depth=6,
|
||||
learning_rate=0.1,
|
||||
loss_function='RMSE',
|
||||
cat_features=categorical_features,
|
||||
verbose=False,
|
||||
random_seed=42
|
||||
)
|
||||
model.fit(X, y)
|
||||
|
||||
# Generate forecasts
|
||||
forecasts = []
|
||||
current_df = df.copy()
|
||||
|
||||
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
|
||||
# Create row for forecast date
|
||||
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
|
||||
current_df = pd.concat([current_df, new_row], ignore_index=True)
|
||||
current_df = create_features(current_df, special_dates)
|
||||
|
||||
# Add OTB features for future dates if available
|
||||
if use_otb_data:
|
||||
current_df = _add_otb_features(current_df, otb_df)
|
||||
|
||||
# Get features for prediction
|
||||
X_pred = current_df[feature_cols].iloc[-1:].copy()
|
||||
|
||||
# Forward fill any NaN values
|
||||
for col in numerical_features:
|
||||
if col in X_pred.columns:
|
||||
X_pred[col] = X_pred[col].ffill()
|
||||
if X_pred[col].isna().any():
|
||||
X_pred[col] = X_pred[col].fillna(0)
|
||||
|
||||
# Make prediction
|
||||
prediction = float(model.predict(X_pred)[0])
|
||||
|
||||
# Ensure non-negative
|
||||
prediction = max(0, prediction)
|
||||
|
||||
# Update y value for lag features
|
||||
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
|
||||
|
||||
forecast_record = {
|
||||
"forecast_date": forecast_date.date(),
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "catboost",
|
||||
"predicted_value": round(float(prediction), 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
# Store in database
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type, predicted_value, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type, :predicted_value, NOW()
|
||||
)
|
||||
"""),
|
||||
forecast_record
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Calculate feature importance for explainability
|
||||
try:
|
||||
feature_importance = dict(zip(feature_cols, model.feature_importances_.tolist()))
|
||||
top_features = sorted(
|
||||
[{"feature": k, "importance": v} for k, v in feature_importance.items()],
|
||||
key=lambda x: x["importance"], reverse=True
|
||||
)[:10]
|
||||
|
||||
logger.info(f"CatBoost top features: {[f['feature'] for f in top_features[:5]]}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Feature importance calculation failed: {e}")
|
||||
|
||||
logger.info(f"CatBoost forecast generated for {metric_code}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"CatBoost not installed: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"CatBoost forecast failed for {metric_code}: {e}")
|
||||
import traceback
|
||||
logger.error(traceback.format_exc())
|
||||
return []
|
||||
|
||||
|
||||
async def _load_special_dates(db, from_date: date, to_date: date) -> set:
|
||||
"""Load special dates from system_config."""
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT config_value FROM system_config
|
||||
WHERE config_key = 'special_dates'
|
||||
"""))
|
||||
row = result.fetchone()
|
||||
|
||||
if not row or not row.config_value:
|
||||
return set()
|
||||
|
||||
dates_json = json.loads(row.config_value)
|
||||
special_dates = set()
|
||||
|
||||
for item in dates_json:
|
||||
if isinstance(item, dict) and 'date' in item:
|
||||
try:
|
||||
d = pd.to_datetime(item['date']).date()
|
||||
special_dates.add(d)
|
||||
except:
|
||||
pass
|
||||
|
||||
logger.info(f"Loaded {len(special_dates)} special dates for CatBoost")
|
||||
return special_dates
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load special dates: {e}")
|
||||
return set()
|
||||
|
||||
|
||||
def _load_otb_data(db, from_date: date, to_date: date) -> Optional[pd.DataFrame]:
|
||||
"""Load OTB (On-The-Books) data."""
|
||||
try:
|
||||
result = db.execute(text("""
|
||||
SELECT
|
||||
arrival_date,
|
||||
d93 as otb_at_90d,
|
||||
d65 as otb_at_60d,
|
||||
d30 as otb_at_30d,
|
||||
d14 as otb_at_14d,
|
||||
d7 as otb_at_7d,
|
||||
d0 as final_bookings
|
||||
FROM newbook_booking_pace
|
||||
WHERE arrival_date BETWEEN :from_date AND :to_date
|
||||
ORDER BY arrival_date
|
||||
"""), {"from_date": from_date, "to_date": to_date})
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
df = pd.DataFrame([{
|
||||
"arrival_date": row.arrival_date,
|
||||
"otb_at_90d": float(row.otb_at_90d) if row.otb_at_90d else 0,
|
||||
"otb_at_60d": float(row.otb_at_60d) if row.otb_at_60d else 0,
|
||||
"otb_at_30d": float(row.otb_at_30d) if row.otb_at_30d else 0,
|
||||
"otb_at_14d": float(row.otb_at_14d) if row.otb_at_14d else 0,
|
||||
"otb_at_7d": float(row.otb_at_7d) if row.otb_at_7d else 0,
|
||||
"final_bookings": float(row.final_bookings) if row.final_bookings else 0
|
||||
} for row in rows])
|
||||
|
||||
return df
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load OTB data: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _add_otb_features(df: pd.DataFrame, otb_df: Optional[pd.DataFrame]) -> pd.DataFrame:
|
||||
"""Add OTB features to DataFrame."""
|
||||
df = df.copy()
|
||||
|
||||
if otb_df is None or len(otb_df) == 0:
|
||||
# No OTB data - set defaults
|
||||
df['otb_at_30d'] = 0
|
||||
df['otb_at_14d'] = 0
|
||||
df['otb_at_7d'] = 0
|
||||
df['pickup_30d_to_14d'] = 0
|
||||
df['pickup_14d_to_7d'] = 0
|
||||
df['otb_pct_at_30d'] = 0
|
||||
df['otb_pct_at_14d'] = 0
|
||||
df['otb_pct_at_7d'] = 0
|
||||
return df
|
||||
|
||||
# Create date column for merging
|
||||
df['date_only'] = df['ds'].dt.date
|
||||
|
||||
# Merge OTB data
|
||||
otb_df = otb_df.copy()
|
||||
otb_df['date_only'] = pd.to_datetime(otb_df['arrival_date']).dt.date
|
||||
|
||||
# Check if columns already exist (avoid duplicates)
|
||||
merge_cols = ['date_only']
|
||||
for col in ['otb_at_90d', 'otb_at_60d', 'otb_at_30d', 'otb_at_14d', 'otb_at_7d', 'final_bookings']:
|
||||
if col not in df.columns:
|
||||
merge_cols.append(col)
|
||||
|
||||
if len(merge_cols) > 1:
|
||||
df = df.merge(
|
||||
otb_df[merge_cols],
|
||||
on='date_only',
|
||||
how='left'
|
||||
)
|
||||
|
||||
# Fill NaN with 0
|
||||
for col in ['otb_at_90d', 'otb_at_60d', 'otb_at_30d', 'otb_at_14d', 'otb_at_7d', 'final_bookings']:
|
||||
if col in df.columns:
|
||||
df[col] = df[col].fillna(0)
|
||||
|
||||
# Calculate pickup between windows
|
||||
if 'pickup_30d_to_14d' not in df.columns:
|
||||
df['pickup_30d_to_14d'] = df['otb_at_14d'] - df['otb_at_30d']
|
||||
if 'pickup_14d_to_7d' not in df.columns:
|
||||
df['pickup_14d_to_7d'] = df['otb_at_7d'] - df['otb_at_14d']
|
||||
|
||||
# Calculate OTB as percentage of final (capped at 100%)
|
||||
if 'otb_pct_at_30d' not in df.columns:
|
||||
df['otb_pct_at_30d'] = np.where(
|
||||
df['final_bookings'] > 0,
|
||||
np.minimum(df['otb_at_30d'] / df['final_bookings'] * 100, 100),
|
||||
0
|
||||
)
|
||||
if 'otb_pct_at_14d' not in df.columns:
|
||||
df['otb_pct_at_14d'] = np.where(
|
||||
df['final_bookings'] > 0,
|
||||
np.minimum(df['otb_at_14d'] / df['final_bookings'] * 100, 100),
|
||||
0
|
||||
)
|
||||
if 'otb_pct_at_7d' not in df.columns:
|
||||
df['otb_pct_at_7d'] = np.where(
|
||||
df['final_bookings'] > 0,
|
||||
np.minimum(df['otb_at_7d'] / df['final_bookings'] * 100, 100),
|
||||
0
|
||||
)
|
||||
|
||||
# Drop temporary column
|
||||
df = df.drop(columns=['date_only'], errors='ignore')
|
||||
|
||||
return df
|
||||
449
backend/services/forecasting/catboost_tuned.py
Normal file
449
backend/services/forecasting/catboost_tuned.py
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
"""
|
||||
CatBoost Tuned Model Service
|
||||
|
||||
This is the production-tuned CatBoost model extracted from the catboost-preview endpoint.
|
||||
Uses the exact same logic as the frontend preview to ensure value consistency.
|
||||
|
||||
Features:
|
||||
- 2 years of training data
|
||||
- Native categorical feature support (day_of_week, month)
|
||||
- Pace features (OTB at different lead times) for room-based metrics
|
||||
- Time-based features
|
||||
- Lag features from prior year
|
||||
- OTB floor capping
|
||||
- Per-date bookable cap adjustments
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from catboost import CatBoostRegressor
|
||||
import warnings
|
||||
from sqlalchemy import text
|
||||
|
||||
from utils.capacity import get_bookable_cap
|
||||
from api.special_dates import resolve_special_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
|
||||
# Metric configuration mapping
|
||||
METRIC_COLUMN_MAP = {
|
||||
'occupancy': ('s.occupancy_pct', False, True),
|
||||
'rooms': ('s.booking_count', False, False),
|
||||
'guests': ('s.guest_count', False, False),
|
||||
'ave_guest_rate': ('s.arr_net', False, False),
|
||||
'arr': ('s.arr_net', False, False),
|
||||
'net_accom': ('r.accommodation', True, False),
|
||||
'net_dry': ('r.dry', True, False),
|
||||
'net_wet': ('r.wet', True, False),
|
||||
'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False),
|
||||
}
|
||||
|
||||
|
||||
def get_metric_query_parts(metric: str) -> tuple:
|
||||
"""Get SQL query parts for a metric. Returns: (column_expr, from_clause, is_percentage)"""
|
||||
if metric not in METRIC_COLUMN_MAP:
|
||||
metric = 'rooms'
|
||||
col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric]
|
||||
if needs_revenue:
|
||||
from_clause = """
|
||||
FROM newbook_bookings_stats s
|
||||
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
|
||||
"""
|
||||
else:
|
||||
from_clause = "FROM newbook_bookings_stats s"
|
||||
return col_expr, from_clause, is_pct
|
||||
|
||||
|
||||
def get_lead_time_column(lead_days: int) -> str:
|
||||
"""Map lead days to the appropriate column in newbook_booking_pace."""
|
||||
if lead_days <= 0:
|
||||
return "d0"
|
||||
elif lead_days <= 30:
|
||||
return f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
for col in weekly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d177"
|
||||
else:
|
||||
monthly_cols = [210, 240, 270, 300, 330, 365]
|
||||
for col in monthly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d365"
|
||||
|
||||
|
||||
def round_towards_reference(value: float, reference: Optional[float]) -> int:
|
||||
"""Round a forecast value towards a reference value (prior year actual)."""
|
||||
if reference is None:
|
||||
return round(value)
|
||||
if value < reference:
|
||||
return int(np.ceil(value))
|
||||
else:
|
||||
return int(np.floor(value))
|
||||
|
||||
|
||||
async def run_catboost_tuned_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
perception_date: Optional[date] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Generate CatBoost forecast using production-tuned model.
|
||||
|
||||
This uses the exact same logic as the catboost-preview endpoint to ensure
|
||||
backend snapshots match frontend preview values.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast
|
||||
start_date: Start date for forecast
|
||||
end_date: End date for forecast
|
||||
perception_date: Optional date to generate forecast as-of (for backtesting)
|
||||
|
||||
Returns:
|
||||
List of forecast dicts with forecast_date and predicted_value
|
||||
"""
|
||||
logger.info(f"Running CatBoost tuned forecast for {metric_code}: {start_date} to {end_date}")
|
||||
|
||||
# Map metric codes to preview endpoint metric names
|
||||
metric_map = {
|
||||
'hotel_occupancy_pct': 'occupancy',
|
||||
'hotel_room_nights': 'rooms',
|
||||
'hotel_guests': 'guests',
|
||||
'hotel_arr': 'arr',
|
||||
'ave_guest_rate': 'ave_guest_rate',
|
||||
'net_accom': 'net_accom',
|
||||
'net_dry': 'net_dry',
|
||||
'net_wet': 'net_wet',
|
||||
'total_rev': 'total_rev',
|
||||
}
|
||||
|
||||
metric = metric_map.get(metric_code, 'rooms')
|
||||
|
||||
# Use perception_date if provided, otherwise use actual today
|
||||
today = perception_date if perception_date else date.today()
|
||||
|
||||
# Get default bookable cap
|
||||
default_bookable_cap = await get_bookable_cap(db)
|
||||
|
||||
# Get metric column and query parts
|
||||
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
|
||||
is_room_based = metric in ('occupancy', 'rooms')
|
||||
|
||||
# Get historical data (2+ years for YoY features)
|
||||
history_start = today - timedelta(days=730)
|
||||
|
||||
# Lead times to train on (only used for room-based metrics)
|
||||
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
|
||||
|
||||
# Get final values (and pace data for room-based metrics)
|
||||
if is_room_based:
|
||||
history_result = await db.execute(text("""
|
||||
SELECT s.date as ds, s.booking_count as final,
|
||||
p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30
|
||||
FROM newbook_bookings_stats s
|
||||
LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date
|
||||
WHERE s.date >= :history_start
|
||||
AND s.date < :today
|
||||
AND s.booking_count IS NOT NULL
|
||||
ORDER BY s.date
|
||||
"""), {"history_start": history_start, "today": today})
|
||||
else:
|
||||
# Non-room metrics: get values without pace join
|
||||
history_query = f"""
|
||||
SELECT s.date as ds, {col_expr} as final
|
||||
{from_clause}
|
||||
WHERE s.date >= :history_start
|
||||
AND s.date < :today
|
||||
AND {col_expr} IS NOT NULL
|
||||
ORDER BY s.date
|
||||
"""
|
||||
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
|
||||
|
||||
history_rows = history_result.fetchall()
|
||||
|
||||
if len(history_rows) < 30:
|
||||
logger.warning(f"Insufficient historical data for CatBoost model: {len(history_rows)} rows")
|
||||
return []
|
||||
|
||||
# Load special dates for feature
|
||||
special_date_set = set()
|
||||
try:
|
||||
special_dates_result = await db.execute(text(
|
||||
"SELECT * FROM special_dates WHERE is_active = TRUE"
|
||||
))
|
||||
special_dates_rows = special_dates_result.fetchall()
|
||||
years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1}
|
||||
for row in special_dates_rows:
|
||||
sd = {
|
||||
'pattern_type': row.pattern_type,
|
||||
'fixed_month': row.fixed_month,
|
||||
'fixed_day': row.fixed_day,
|
||||
'nth_week': row.nth_week,
|
||||
'weekday': row.weekday,
|
||||
'month': row.month,
|
||||
'relative_to_month': row.relative_to_month,
|
||||
'relative_to_day': row.relative_to_day,
|
||||
'relative_weekday': row.relative_weekday,
|
||||
'relative_direction': row.relative_direction,
|
||||
'duration_days': row.duration_days,
|
||||
'is_recurring': row.is_recurring,
|
||||
'one_off_year': row.one_off_year
|
||||
}
|
||||
for year in years_needed:
|
||||
resolved_dates = resolve_special_date(sd, year)
|
||||
for d in resolved_dates:
|
||||
special_date_set.add(d)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load special dates: {e}")
|
||||
|
||||
# Build lookup dicts
|
||||
final_by_date = {}
|
||||
pace_by_date = {}
|
||||
for row in history_rows:
|
||||
final_by_date[row.ds] = row.final
|
||||
if is_room_based and hasattr(row, 'd0'):
|
||||
pace_by_date[row.ds] = {
|
||||
0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7,
|
||||
14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30
|
||||
}
|
||||
|
||||
# Build training examples
|
||||
training_rows = []
|
||||
|
||||
if is_room_based:
|
||||
# Room-based metrics: use pace features (one per date,lead_time combo)
|
||||
for row in history_rows:
|
||||
ds = row.ds
|
||||
final = float(row.final) if row.final else 0
|
||||
prior_ds = ds - timedelta(days=364)
|
||||
|
||||
prior_final = final_by_date.get(prior_ds)
|
||||
if prior_final is None:
|
||||
continue
|
||||
|
||||
for lead_time in train_lead_times:
|
||||
current_otb = pace_by_date.get(ds, {}).get(lead_time)
|
||||
if current_otb is None:
|
||||
continue
|
||||
|
||||
prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time)
|
||||
if prior_otb is None:
|
||||
prior_otb = 0
|
||||
|
||||
otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0
|
||||
|
||||
training_rows.append({
|
||||
'ds': ds,
|
||||
'y': final,
|
||||
'days_out': lead_time,
|
||||
'current_otb': float(current_otb),
|
||||
'prior_otb_same_lead': float(prior_otb),
|
||||
'lag_364': float(prior_final),
|
||||
'otb_pct_of_prior_final': otb_pct_of_prior_final
|
||||
})
|
||||
else:
|
||||
# Non-room metrics: use time features only (one per date)
|
||||
for row in history_rows:
|
||||
ds = row.ds
|
||||
final = float(row.final) if row.final else 0
|
||||
prior_ds = ds - timedelta(days=364)
|
||||
|
||||
prior_final = final_by_date.get(prior_ds)
|
||||
if prior_final is None:
|
||||
prior_final = 0 # Allow training even without prior year for revenue metrics
|
||||
|
||||
training_rows.append({
|
||||
'ds': ds,
|
||||
'y': final,
|
||||
'lag_364': float(prior_final) if prior_final else 0
|
||||
})
|
||||
|
||||
if len(training_rows) < 30:
|
||||
logger.warning(f"Insufficient data for CatBoost training: {len(training_rows)} rows")
|
||||
return []
|
||||
|
||||
df = pd.DataFrame(training_rows)
|
||||
df['ds'] = pd.to_datetime(df['ds'])
|
||||
|
||||
# Convert to occupancy if needed
|
||||
if metric == "occupancy" and default_bookable_cap > 0:
|
||||
df["y"] = (df["y"] / default_bookable_cap) * 100
|
||||
if "current_otb" in df.columns:
|
||||
df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100
|
||||
if "prior_otb_same_lead" in df.columns:
|
||||
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100
|
||||
df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100
|
||||
|
||||
# Create features - CatBoost handles categoricals natively
|
||||
df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical
|
||||
df['month'] = df['ds'].dt.month.astype(str) # Categorical
|
||||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||||
df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int)
|
||||
df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0)
|
||||
|
||||
df_train = df.dropna()
|
||||
|
||||
if len(df_train) < 30:
|
||||
logger.warning(f"Insufficient data after creating features: {len(df_train)} rows")
|
||||
return []
|
||||
|
||||
# Define features based on metric type - categoricals handled natively by CatBoost
|
||||
categorical_features = ['day_of_week', 'month']
|
||||
if is_room_based:
|
||||
numerical_features = ['week_of_year', 'is_weekend', 'is_special_date',
|
||||
'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final']
|
||||
else:
|
||||
numerical_features = ['week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
|
||||
feature_cols = categorical_features + numerical_features
|
||||
|
||||
X_train = df_train[feature_cols]
|
||||
y_train = df_train['y']
|
||||
|
||||
# Train CatBoost model
|
||||
model = CatBoostRegressor(
|
||||
iterations=150,
|
||||
depth=6,
|
||||
learning_rate=0.1,
|
||||
loss_function='RMSE',
|
||||
cat_features=categorical_features,
|
||||
verbose=False,
|
||||
random_seed=42
|
||||
)
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
# Create future dataframe for forecast period
|
||||
future_dates = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
if (current_date - today).days >= 0:
|
||||
future_dates.append(current_date)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
if not future_dates:
|
||||
logger.warning("No future dates to forecast")
|
||||
return []
|
||||
|
||||
# Generate forecasts for each date
|
||||
forecasts = []
|
||||
|
||||
for forecast_date in future_dates:
|
||||
lead_days = (forecast_date - today).days
|
||||
lead_col = get_lead_time_column(lead_days)
|
||||
prior_year_date = forecast_date - timedelta(days=364)
|
||||
|
||||
# Get OTB data only for room-based metrics
|
||||
current_otb = None
|
||||
|
||||
if is_room_based:
|
||||
current_query = text("""
|
||||
SELECT booking_count as current_otb
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :arrival_date
|
||||
""")
|
||||
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
|
||||
current_row = current_result.fetchone()
|
||||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||||
|
||||
# Get prior year final using metric mapping
|
||||
prior_query = f"""
|
||||
SELECT {col_expr} as prior_final
|
||||
{from_clause}
|
||||
WHERE s.date = :prior_date
|
||||
"""
|
||||
prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date})
|
||||
prior_row = prior_result.fetchone()
|
||||
prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0
|
||||
|
||||
# Get per-date bookable cap
|
||||
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
|
||||
|
||||
forecast_dt = pd.Timestamp(forecast_date)
|
||||
lag_364_val = prior_final if prior_final else 0
|
||||
|
||||
# Convert to occupancy if needed
|
||||
if metric == "occupancy" and date_bookable_cap > 0:
|
||||
if current_otb is not None:
|
||||
current_otb = (current_otb / date_bookable_cap) * 100
|
||||
lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0
|
||||
|
||||
# Build features based on metric type
|
||||
if is_room_based:
|
||||
# Get prior OTB at same lead time
|
||||
prior_year_for_otb = forecast_date - timedelta(days=364)
|
||||
prior_otb_query = text(f"""
|
||||
SELECT {lead_col} as prior_otb
|
||||
FROM newbook_booking_pace
|
||||
WHERE arrival_date = :prior_date
|
||||
""")
|
||||
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
|
||||
prior_otb_row = prior_otb_result.fetchone()
|
||||
prior_otb_same_lead = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else 0
|
||||
|
||||
if metric == "occupancy" and date_bookable_cap > 0:
|
||||
prior_otb_same_lead = (prior_otb_same_lead / date_bookable_cap) * 100 if prior_otb_same_lead else 0
|
||||
|
||||
current_otb_val = current_otb if current_otb is not None else 0
|
||||
otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0
|
||||
|
||||
features = pd.DataFrame([{
|
||||
'day_of_week': str(forecast_dt.dayofweek), # Categorical
|
||||
'month': str(forecast_dt.month), # Categorical
|
||||
'week_of_year': forecast_dt.isocalendar().week,
|
||||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||||
'days_out': lead_days,
|
||||
'current_otb': current_otb_val,
|
||||
'prior_otb_same_lead': prior_otb_same_lead,
|
||||
'lag_364': lag_364_val,
|
||||
'otb_pct_of_prior_final': otb_pct_of_prior_final,
|
||||
}])
|
||||
else:
|
||||
features = pd.DataFrame([{
|
||||
'day_of_week': str(forecast_dt.dayofweek), # Categorical
|
||||
'month': str(forecast_dt.month), # Categorical
|
||||
'week_of_year': forecast_dt.isocalendar().week,
|
||||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||||
'lag_364': lag_364_val,
|
||||
}])
|
||||
|
||||
# Predict
|
||||
yhat = float(model.predict(features)[0])
|
||||
|
||||
# Cap at max capacity based on metric type (uses per-date bookable cap)
|
||||
if is_pct_metric:
|
||||
yhat = min(max(yhat, 0), 100.0)
|
||||
elif metric == 'rooms':
|
||||
yhat = round(min(max(yhat, 0), float(date_bookable_cap)))
|
||||
elif metric == 'guests':
|
||||
yhat = round(max(yhat, 0))
|
||||
else:
|
||||
# Revenue/rate metrics: just ensure non-negative
|
||||
yhat = max(yhat, 0)
|
||||
|
||||
# Floor forecast to current OTB (room-based only)
|
||||
if is_room_based and current_otb is not None and yhat < current_otb:
|
||||
yhat = current_otb
|
||||
|
||||
# Round based on metric type
|
||||
if metric == "occupancy":
|
||||
yhat = round(yhat, 1)
|
||||
else:
|
||||
yhat = round_towards_reference(yhat, prior_final)
|
||||
|
||||
forecasts.append({
|
||||
'forecast_date': forecast_date,
|
||||
'predicted_value': yhat
|
||||
})
|
||||
|
||||
logger.info(f"CatBoost tuned generated {len(forecasts)} forecasts for {metric_code}")
|
||||
return forecasts
|
||||
866
backend/services/forecasting/covers_model.py
Normal file
866
backend/services/forecasting/covers_model.py
Normal file
|
|
@ -0,0 +1,866 @@
|
|||
"""
|
||||
Restaurant Covers Forecast Model
|
||||
|
||||
Forecasts restaurant covers based on:
|
||||
- Breakfast: Previous night's hotel occupancy (guests expected at breakfast)
|
||||
- Lunch: OTB bookings + non-resident pickup based on lead time
|
||||
- Dinner: OTB bookings split by hotel guest/non-resident + pickup for each segment
|
||||
|
||||
Key segments:
|
||||
- Resident (hotel guest): Based on hotel occupancy, booking patterns, DBB packages
|
||||
- Non-resident: Based on historical pickup patterns at lead time
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Optional, Any
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from services.forecasting.pickup_v2_model import forecast_rooms_for_date, get_prior_year_date as get_py_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid booking statuses for counting
|
||||
VALID_STATUSES = ('approved', 'arrived', 'seated', 'left')
|
||||
|
||||
|
||||
async def get_hotel_bookings_with_dinner_reservation(
|
||||
db: AsyncSession,
|
||||
target_date: date
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Get actual count of hotel bookings that have dinner reservations for a date.
|
||||
|
||||
Queries resos_bookings_data to find distinct hotel_booking_numbers
|
||||
that have dinner reservations, then compares to total hotel bookings.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"rooms_with_dinner": int, # Hotel bookings with dinner reservation
|
||||
"total_hotel_rooms": int, # Total hotel bookings for this date
|
||||
"rooms_without_dinner": int # Difference
|
||||
}
|
||||
"""
|
||||
# Count distinct hotel bookings with dinner reservations
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(DISTINCT hotel_booking_number) as rooms_with_dinner
|
||||
FROM resos_bookings_data
|
||||
WHERE booking_date = :target_date
|
||||
AND is_hotel_guest = true
|
||||
AND period_type = 'dinner'
|
||||
AND hotel_booking_number IS NOT NULL
|
||||
AND hotel_booking_number != ''
|
||||
AND status IN ('approved', 'arrived', 'seated', 'left')
|
||||
"""),
|
||||
{"target_date": target_date}
|
||||
)
|
||||
row = result.fetchone()
|
||||
rooms_with_dinner = row.rooms_with_dinner if row else 0
|
||||
|
||||
# Get total hotel bookings from stats
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(booking_count, 0) as total_rooms
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :target_date
|
||||
"""),
|
||||
{"target_date": target_date}
|
||||
)
|
||||
row = result.fetchone()
|
||||
total_hotel_rooms = row.total_rooms if row else 0
|
||||
|
||||
rooms_without_dinner = max(0, total_hotel_rooms - rooms_with_dinner)
|
||||
|
||||
return {
|
||||
"rooms_with_dinner": rooms_with_dinner,
|
||||
"total_hotel_rooms": total_hotel_rooms,
|
||||
"rooms_without_dinner": rooms_without_dinner,
|
||||
}
|
||||
|
||||
|
||||
def get_prior_year_date(target_date: date) -> date:
|
||||
"""
|
||||
Get prior year date with 364-day offset for day-of-week alignment.
|
||||
52 weeks = 364 days, so Monday aligns with Monday.
|
||||
"""
|
||||
return target_date - timedelta(days=364)
|
||||
|
||||
|
||||
async def get_hotel_occupancy_for_date(db: AsyncSession, stay_date: date) -> Dict[str, Any]:
|
||||
"""
|
||||
Get hotel room occupancy for a specific date from aggregated stats.
|
||||
Returns occupied rooms, total capacity, and occupancy percentage.
|
||||
Uses newbook_bookings_stats which is pre-aggregated with is_included filtering.
|
||||
"""
|
||||
# Query from aggregated stats table - more reliable and already filtered
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
COALESCE(booking_count, 0) as room_count,
|
||||
COALESCE(guests_count, 0) as guest_count,
|
||||
COALESCE(bookable_count, 0) as total_rooms,
|
||||
COALESCE(bookable_occupancy_pct, 0) as occupancy_pct
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :stay_date
|
||||
"""),
|
||||
{"stay_date": stay_date}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row:
|
||||
return {
|
||||
"occupied_rooms": row.room_count,
|
||||
"total_rooms": row.total_rooms,
|
||||
"occupancy_pct": round(float(row.occupancy_pct), 1) if row.occupancy_pct else 0,
|
||||
"guests": row.guest_count
|
||||
}
|
||||
|
||||
# No stats for this date - return empty
|
||||
return {"occupied_rooms": 0, "total_rooms": 0, "occupancy_pct": 0, "guests": 0}
|
||||
|
||||
|
||||
async def get_resos_covers_for_date(
|
||||
db: AsyncSession,
|
||||
target_date: date,
|
||||
period_type: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get restaurant booking covers for a specific date from aggregated stats table.
|
||||
Returns covers by period (breakfast, lunch, dinner, etc.)
|
||||
"""
|
||||
# Query from aggregated stats table - more efficient and reliable
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
COALESCE(breakfast_covers, 0) as breakfast_covers,
|
||||
COALESCE(lunch_covers, 0) as lunch_covers,
|
||||
COALESCE(afternoon_covers, 0) as afternoon_covers,
|
||||
COALESCE(dinner_covers, 0) as dinner_covers,
|
||||
COALESCE(other_covers, 0) as other_covers,
|
||||
COALESCE(total_covers, 0) as total_covers,
|
||||
COALESCE(hotel_guest_covers, 0) as hotel_guest_covers,
|
||||
COALESCE(non_hotel_guest_covers, 0) as non_hotel_guest_covers,
|
||||
COALESCE(dbb_covers, 0) as dbb_covers,
|
||||
COALESCE(total_bookings, 0) as total_bookings
|
||||
FROM resos_bookings_stats
|
||||
WHERE date = :target_date
|
||||
"""),
|
||||
{"target_date": target_date}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
# No data for this date - return empty structure
|
||||
return {
|
||||
"breakfast": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0},
|
||||
"lunch": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0},
|
||||
"dinner": {"total_covers": 0, "booking_count": 0, "resident_covers": 0, "non_resident_covers": 0, "dbb_covers": 0},
|
||||
}
|
||||
|
||||
# Calculate resident/non-resident split proportionally for each period
|
||||
# (stats table has overall split but not per-period, so we estimate based on ratio)
|
||||
total = row.total_covers or 1 # Avoid division by zero
|
||||
hotel_ratio = row.hotel_guest_covers / total if total > 0 else 0
|
||||
non_hotel_ratio = row.non_hotel_guest_covers / total if total > 0 else 0
|
||||
|
||||
covers_by_period = {
|
||||
"breakfast": {
|
||||
"total_covers": row.breakfast_covers,
|
||||
"booking_count": 0, # Not tracked per period in stats
|
||||
"resident_covers": int(row.breakfast_covers * hotel_ratio),
|
||||
"non_resident_covers": int(row.breakfast_covers * non_hotel_ratio),
|
||||
"dbb_covers": 0
|
||||
},
|
||||
"lunch": {
|
||||
"total_covers": row.lunch_covers,
|
||||
"booking_count": 0,
|
||||
"resident_covers": int(row.lunch_covers * hotel_ratio),
|
||||
"non_resident_covers": int(row.lunch_covers * non_hotel_ratio),
|
||||
"dbb_covers": 0
|
||||
},
|
||||
"dinner": {
|
||||
"total_covers": row.dinner_covers,
|
||||
"booking_count": 0,
|
||||
"resident_covers": int(row.dinner_covers * hotel_ratio),
|
||||
"non_resident_covers": int(row.dinner_covers * non_hotel_ratio),
|
||||
"dbb_covers": row.dbb_covers
|
||||
},
|
||||
}
|
||||
|
||||
return covers_by_period
|
||||
|
||||
|
||||
async def get_historical_breakfast_rate(db: AsyncSession, lookback_days: int = 90) -> float:
|
||||
"""
|
||||
Calculate historical breakfast attendance rate as covers per occupied room.
|
||||
Uses past data to determine typical breakfast covers per hotel room.
|
||||
Uses aggregated stats tables for reliability.
|
||||
"""
|
||||
# Join resos stats with newbook stats to get breakfast covers and occupancy
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
SUM(rbs.breakfast_covers) as total_breakfast,
|
||||
SUM(nbs.booking_count) as total_room_nights
|
||||
FROM resos_bookings_stats rbs
|
||||
JOIN newbook_bookings_stats nbs ON rbs.date = nbs.date
|
||||
WHERE rbs.date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
|
||||
AND rbs.date < CURRENT_DATE
|
||||
AND rbs.breakfast_covers > 0
|
||||
AND nbs.booking_count > 0
|
||||
"""),
|
||||
{"lookback_days": lookback_days}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row and row.total_room_nights and row.total_room_nights > 0:
|
||||
# Calculate covers per room night
|
||||
rate = float(row.total_breakfast) / float(row.total_room_nights)
|
||||
return rate
|
||||
|
||||
# Default: assume 1.8 covers per room (average party size for breakfast)
|
||||
return 1.8
|
||||
|
||||
|
||||
async def get_lunch_pickup_by_lead_time(
|
||||
db: AsyncSession,
|
||||
target_date: date,
|
||||
lead_days: int,
|
||||
lookback_weeks: int = 8
|
||||
) -> int:
|
||||
"""
|
||||
Get the median pickup COUNT for lunch at a given lead time for the same DOW.
|
||||
|
||||
Pickup = final_covers - otb_at_lead
|
||||
This tells us how many covers typically come in AFTER this lead time.
|
||||
|
||||
More stable than ratio-based approach because it doesn't inflate
|
||||
when current OTB is higher than historical OTB.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
target_date: Date we're forecasting (to get DOW)
|
||||
lead_days: Days until the target date
|
||||
lookback_weeks: Weeks of history to use
|
||||
|
||||
Returns:
|
||||
Median pickup count (integer), or 0 if no data
|
||||
"""
|
||||
# Get day of week - convert Python (0=Mon) to PostgreSQL (0=Sun, 1=Mon...6=Sat)
|
||||
python_dow = target_date.weekday()
|
||||
pg_dow = (python_dow + 1) % 7
|
||||
|
||||
# Determine which pace column to use based on lead days
|
||||
if lead_days <= 0:
|
||||
return 0 # No pickup for past dates
|
||||
elif lead_days <= 30:
|
||||
pace_col = f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
# Weekly intervals - find closest
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
|
||||
else:
|
||||
pace_col = "d177" # Cap at max tracked
|
||||
|
||||
# Query pace data for same DOW to calculate pickup counts
|
||||
# pace_type 'total' gives us overall covers
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
COALESCE({pace_col}, 0) as otb_at_lead,
|
||||
COALESCE(d0, 0) as final_covers
|
||||
FROM resos_booking_pace
|
||||
WHERE EXTRACT(DOW FROM booking_date) = :dow
|
||||
AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
|
||||
AND booking_date < CURRENT_DATE
|
||||
AND d0 > 0
|
||||
AND pace_type = 'total'
|
||||
ORDER BY booking_date DESC
|
||||
LIMIT :max_weeks
|
||||
"""),
|
||||
{
|
||||
"dow": pg_dow,
|
||||
"lookback_days": lookback_weeks * 7,
|
||||
"max_weeks": lookback_weeks
|
||||
}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
# No pace data - return 0 (no pickup estimate available)
|
||||
return 0
|
||||
|
||||
# Calculate pickup counts for each historical day
|
||||
pickups = []
|
||||
for row in rows:
|
||||
otb_at_lead = row.otb_at_lead or 0
|
||||
final = row.final_covers or 0
|
||||
# Pickup = how many came in after this lead time
|
||||
pickup = max(0, final - otb_at_lead) # Floor at 0 (cancellations shouldn't give negative)
|
||||
pickups.append(pickup)
|
||||
|
||||
if not pickups:
|
||||
return 0
|
||||
|
||||
# Calculate median pickup count
|
||||
pickups_sorted = sorted(pickups)
|
||||
n = len(pickups_sorted)
|
||||
if n % 2 == 0:
|
||||
median = (pickups_sorted[n // 2 - 1] + pickups_sorted[n // 2]) / 2
|
||||
else:
|
||||
median = pickups_sorted[n // 2]
|
||||
|
||||
return math.ceil(median) # Round up
|
||||
|
||||
|
||||
async def get_dinner_non_resident_pickup_by_lead_time(
|
||||
db: AsyncSession,
|
||||
target_date: date,
|
||||
lead_days: int,
|
||||
lookback_weeks: int = 8
|
||||
) -> int:
|
||||
"""
|
||||
Get median pickup count for non-resident dinner at a given lead time.
|
||||
Same logic as lunch - straight pickup count based on historical pace data.
|
||||
"""
|
||||
python_dow = target_date.weekday()
|
||||
pg_dow = (python_dow + 1) % 7
|
||||
|
||||
if lead_days <= 0:
|
||||
return 0
|
||||
elif lead_days <= 30:
|
||||
pace_col = f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
|
||||
else:
|
||||
pace_col = "d177"
|
||||
|
||||
# Query pace data for non_resident type
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
COALESCE({pace_col}, 0) as otb_at_lead,
|
||||
COALESCE(d0, 0) as final_covers
|
||||
FROM resos_booking_pace
|
||||
WHERE EXTRACT(DOW FROM booking_date) = :dow
|
||||
AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
|
||||
AND booking_date < CURRENT_DATE
|
||||
AND d0 > 0
|
||||
AND pace_type = 'non_resident'
|
||||
ORDER BY booking_date DESC
|
||||
LIMIT :max_weeks
|
||||
"""),
|
||||
{
|
||||
"dow": pg_dow,
|
||||
"lookback_days": lookback_weeks * 7,
|
||||
"max_weeks": lookback_weeks
|
||||
}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
pickups = []
|
||||
for row in rows:
|
||||
otb_at_lead = row.otb_at_lead or 0
|
||||
final = row.final_covers or 0
|
||||
pickup = max(0, final - otb_at_lead)
|
||||
pickups.append(pickup)
|
||||
|
||||
if not pickups:
|
||||
return 0
|
||||
|
||||
pickups_sorted = sorted(pickups)
|
||||
n = len(pickups_sorted)
|
||||
if n % 2 == 0:
|
||||
median = (pickups_sorted[n // 2 - 1] + pickups_sorted[n // 2]) / 2
|
||||
else:
|
||||
median = pickups_sorted[n // 2]
|
||||
|
||||
return math.ceil(median)
|
||||
|
||||
|
||||
async def get_resident_dining_rate(
|
||||
db: AsyncSession,
|
||||
target_date: date,
|
||||
lookback_weeks: int = 4
|
||||
) -> float:
|
||||
"""
|
||||
Calculate what % of hotel guests typically dine at the restaurant (resident covers).
|
||||
|
||||
Simple approach: resident_covers / hotel_guests for same DOW over last N weeks.
|
||||
Returns median rate to apply to forecasted hotel guests.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
target_date: Date we're forecasting (to get DOW)
|
||||
lookback_weeks: Weeks of history to analyze
|
||||
|
||||
Returns:
|
||||
Median dining rate (0.0 to 1.0)
|
||||
"""
|
||||
python_dow = target_date.weekday()
|
||||
pg_dow = (python_dow + 1) % 7
|
||||
|
||||
# Query resident covers and hotel guests for same DOW
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
nbs.date,
|
||||
COALESCE(nbs.guests_count, 0) as hotel_guests,
|
||||
COALESCE(rbs.hotel_guest_covers, 0) as resident_covers
|
||||
FROM newbook_bookings_stats nbs
|
||||
JOIN resos_bookings_stats rbs ON nbs.date = rbs.date
|
||||
WHERE EXTRACT(DOW FROM nbs.date) = :dow
|
||||
AND nbs.date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
|
||||
AND nbs.date < CURRENT_DATE
|
||||
AND nbs.guests_count > 0
|
||||
ORDER BY nbs.date DESC
|
||||
LIMIT :max_weeks
|
||||
"""),
|
||||
{
|
||||
"dow": pg_dow,
|
||||
"lookback_days": lookback_weeks * 7,
|
||||
"max_weeks": lookback_weeks
|
||||
}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
return 0.4 # Default 40% if no data
|
||||
|
||||
# Calculate dining rate for each week
|
||||
dining_rates = []
|
||||
for row in rows:
|
||||
if row.hotel_guests > 0:
|
||||
rate = min(1.0, row.resident_covers / row.hotel_guests)
|
||||
dining_rates.append(rate)
|
||||
|
||||
if not dining_rates:
|
||||
return 0.4
|
||||
|
||||
# Return median
|
||||
sorted_rates = sorted(dining_rates)
|
||||
n = len(sorted_rates)
|
||||
if n % 2 == 0:
|
||||
return (sorted_rates[n // 2 - 1] + sorted_rates[n // 2]) / 2
|
||||
return sorted_rates[n // 2]
|
||||
|
||||
|
||||
async def get_historical_pickup_by_lead_time(
|
||||
db: AsyncSession,
|
||||
period_type: str,
|
||||
is_resident: bool,
|
||||
lead_days: int,
|
||||
lookback_weeks: int = 12
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Calculate historical pickup patterns for a period/segment at a given lead time.
|
||||
Returns average pickup and pickup rate compared to final.
|
||||
"""
|
||||
# Get column name for this lead time
|
||||
column = f"d{lead_days}" if lead_days <= 30 else f"d{lead_days}" # Use same format for all
|
||||
|
||||
# For lead times with pace data, use pace table
|
||||
pace_type = 'resident' if is_resident else 'non_resident'
|
||||
|
||||
if lead_days <= 365: # We have pace columns up to d365
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
SELECT
|
||||
AVG(COALESCE({column}, 0)) as avg_at_lead,
|
||||
AVG(COALESCE(d0, 0)) as avg_final
|
||||
FROM resos_booking_pace
|
||||
WHERE pace_type = :pace_type
|
||||
AND booking_date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
|
||||
AND booking_date < CURRENT_DATE
|
||||
"""),
|
||||
{"pace_type": pace_type, "lookback_days": lookback_weeks * 7}
|
||||
)
|
||||
else:
|
||||
# Use aggregated stats table for period-specific analysis
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
AVG(CASE WHEN :is_resident THEN hotel_guest_covers ELSE non_hotel_guest_covers END) as avg_covers
|
||||
FROM resos_bookings_stats
|
||||
WHERE date >= CURRENT_DATE - CAST(:lookback_days AS INTEGER)
|
||||
AND date < CURRENT_DATE
|
||||
"""),
|
||||
{"is_resident": is_resident, "lookback_days": lookback_weeks * 7}
|
||||
)
|
||||
|
||||
row = result.fetchone()
|
||||
|
||||
return {
|
||||
"avg_at_lead": row.avg_at_lead if row and row.avg_at_lead else 0,
|
||||
"avg_final": row.avg_final if row and row.avg_final else 0
|
||||
}
|
||||
|
||||
|
||||
async def forecast_covers_for_date(
|
||||
db: AsyncSession,
|
||||
target_date: date,
|
||||
include_details: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate covers forecast for a specific date.
|
||||
|
||||
Returns breakdown by period and segment:
|
||||
- Breakfast: Based on previous night's occupancy
|
||||
- Lunch: OTB + non-resident pickup
|
||||
- Dinner: OTB (resident + non-resident) + pickup for each
|
||||
"""
|
||||
today = date.today()
|
||||
lead_days = (target_date - today).days
|
||||
prior_year_date = get_prior_year_date(target_date)
|
||||
|
||||
# Get current OTB covers
|
||||
current_covers = await get_resos_covers_for_date(db, target_date)
|
||||
|
||||
# Get prior year covers
|
||||
prior_covers = await get_resos_covers_for_date(db, prior_year_date)
|
||||
|
||||
# Get hotel occupancy for the night before (for breakfast)
|
||||
night_before = target_date - timedelta(days=1)
|
||||
prior_year_night_before = get_prior_year_date(night_before)
|
||||
|
||||
# Get current hotel OTB for night before
|
||||
hotel_otb = await get_hotel_occupancy_for_date(db, night_before)
|
||||
# Get prior year hotel occupancy for night before (tells us expected final)
|
||||
hotel_prior = await get_hotel_occupancy_for_date(db, prior_year_night_before)
|
||||
|
||||
# Get breakfast rate (covers per room)
|
||||
breakfast_rate = await get_historical_breakfast_rate(db)
|
||||
|
||||
# Calculate forecasts by period
|
||||
result = {
|
||||
"date": target_date.isoformat(),
|
||||
"day_of_week": target_date.strftime("%a"),
|
||||
"lead_days": lead_days,
|
||||
"prior_year_date": prior_year_date.isoformat(),
|
||||
}
|
||||
|
||||
# ============ BREAKFAST ============
|
||||
# Breakfast = hotel guests from night before (guests eat breakfast, not rooms)
|
||||
# Past: use actual hotel guest count
|
||||
# Future: OTB guests + pickup from pickupv2 hotel forecast
|
||||
|
||||
hotel_guests_otb = hotel_otb["guests"]
|
||||
hotel_rooms_otb = hotel_otb["occupied_rooms"]
|
||||
hotel_guests_prior = hotel_prior["guests"]
|
||||
hotel_rooms_prior = hotel_prior["occupied_rooms"]
|
||||
|
||||
# Calculate guests per room ratio for converting room forecast to guests
|
||||
# Use prior year ratio (more stable/representative of final state) with fallbacks
|
||||
if hotel_rooms_prior > 0:
|
||||
guests_per_room = hotel_guests_prior / hotel_rooms_prior
|
||||
elif hotel_rooms_otb > 0:
|
||||
guests_per_room = hotel_guests_otb / hotel_rooms_otb
|
||||
else:
|
||||
guests_per_room = 1.8 # Default fallback
|
||||
|
||||
breakfast_calc = None
|
||||
if lead_days <= 0:
|
||||
# PAST: Use actual hotel guest count
|
||||
breakfast_otb = hotel_guests_otb
|
||||
breakfast_pickup = 0
|
||||
breakfast_forecast = breakfast_otb
|
||||
else:
|
||||
# FUTURE: Use pickupv2 model for room forecast
|
||||
breakfast_otb = hotel_guests_otb
|
||||
|
||||
# Get pickupv2 room forecast for the night before
|
||||
# (night_before lead_days = lead_days for target_date since breakfast is next morning)
|
||||
night_before_lead_days = lead_days - 1 # Night before has 1 less lead day
|
||||
pickup_rooms = 0
|
||||
try:
|
||||
pickupv2_forecast = await forecast_rooms_for_date(
|
||||
db,
|
||||
night_before,
|
||||
night_before_lead_days,
|
||||
prior_year_night_before,
|
||||
'hotel_room_nights'
|
||||
)
|
||||
if pickupv2_forecast:
|
||||
# Get forecasted rooms and pickup from pickupv2
|
||||
forecasted_rooms = pickupv2_forecast.get('predicted_value', hotel_rooms_otb)
|
||||
pickup_rooms = pickupv2_forecast.get('pickup_rooms_total', 0)
|
||||
|
||||
# Convert pickup rooms to guests using the ratio (round up)
|
||||
breakfast_pickup = math.ceil(pickup_rooms * guests_per_room)
|
||||
# Forecast = OTB + pickup (floor is always OTB guests)
|
||||
breakfast_forecast = breakfast_otb + breakfast_pickup
|
||||
|
||||
# Store calculation details
|
||||
breakfast_calc = {
|
||||
"night_before": night_before.isoformat(),
|
||||
"hotel_rooms_otb": hotel_rooms_otb,
|
||||
"hotel_guests_otb": hotel_guests_otb,
|
||||
"pickup_rooms": round(pickup_rooms, 1),
|
||||
"guests_per_room": round(guests_per_room, 2),
|
||||
"source": "pickupv2",
|
||||
}
|
||||
else:
|
||||
# Fallback to prior year pattern
|
||||
breakfast_pickup = max(0, hotel_guests_prior - hotel_guests_otb)
|
||||
breakfast_forecast = breakfast_otb + breakfast_pickup
|
||||
breakfast_calc = {
|
||||
"night_before": night_before.isoformat(),
|
||||
"hotel_guests_prior": hotel_guests_prior,
|
||||
"source": "prior_year_fallback",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Pickupv2 forecast failed for {night_before}: {e}")
|
||||
# Fallback to prior year pattern
|
||||
breakfast_pickup = max(0, hotel_guests_prior - hotel_guests_otb)
|
||||
breakfast_forecast = breakfast_otb + breakfast_pickup
|
||||
breakfast_calc = {
|
||||
"night_before": night_before.isoformat(),
|
||||
"hotel_guests_prior": hotel_guests_prior,
|
||||
"source": "prior_year_fallback",
|
||||
}
|
||||
|
||||
# Prior year breakfast (for comparison)
|
||||
prior_breakfast = hotel_guests_prior
|
||||
|
||||
result["breakfast"] = {
|
||||
"otb": breakfast_otb,
|
||||
"pickup": breakfast_pickup,
|
||||
"forecast": breakfast_forecast,
|
||||
"prior_year": prior_breakfast,
|
||||
"hotel_guests_otb": hotel_guests_otb,
|
||||
"hotel_guests_prior": hotel_guests_prior,
|
||||
"calc": breakfast_calc,
|
||||
}
|
||||
|
||||
# ============ LUNCH ============
|
||||
# Lunch: OTB + pickup based on median historical pickup at lead time
|
||||
# Uses straight pickup count (not ratio) for stability
|
||||
lunch_data = current_covers.get("lunch", {})
|
||||
lunch_otb = lunch_data.get("total_covers", 0)
|
||||
prior_lunch = prior_covers.get("lunch", {}).get("total_covers", 0)
|
||||
|
||||
# Get median pickup count for this lead time and DOW
|
||||
lunch_pickup = await get_lunch_pickup_by_lead_time(db, target_date, lead_days)
|
||||
|
||||
# Determine pace column for tooltip
|
||||
if lead_days <= 30:
|
||||
lunch_pace_col = f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
lunch_pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
|
||||
else:
|
||||
lunch_pace_col = "d177"
|
||||
|
||||
lunch_calc = None
|
||||
# For future dates, add pickup to OTB
|
||||
if lead_days > 0:
|
||||
lunch_forecast = lunch_otb + lunch_pickup
|
||||
lunch_calc = {
|
||||
"day_of_week": target_date.strftime("%A"),
|
||||
"lead_days": lead_days,
|
||||
"pace_column": lunch_pace_col,
|
||||
"lookback_weeks": 8,
|
||||
"median_pickup": lunch_pickup,
|
||||
"source": "resos_booking_pace (total)",
|
||||
}
|
||||
else:
|
||||
# Past date - no pickup
|
||||
lunch_pickup = 0
|
||||
lunch_forecast = lunch_otb
|
||||
|
||||
result["lunch"] = {
|
||||
"otb": lunch_otb,
|
||||
"pickup": lunch_pickup,
|
||||
"forecast": lunch_forecast,
|
||||
"prior_year": prior_lunch,
|
||||
"calc": lunch_calc,
|
||||
}
|
||||
|
||||
# ============ DINNER ============
|
||||
# Dinner: More sophisticated calculation
|
||||
# - Non-resident: Lead-time based median pickup (like lunch)
|
||||
# - Resident: Based on hotel guests without dinner reservations + conversion rate
|
||||
dinner_data = current_covers.get("dinner", {})
|
||||
dinner_otb = dinner_data.get("total_covers", 0)
|
||||
dinner_resident_otb = dinner_data.get("resident_covers", 0)
|
||||
dinner_non_resident_otb = dinner_data.get("non_resident_covers", 0)
|
||||
dinner_dbb_otb = dinner_data.get("dbb_covers", 0)
|
||||
|
||||
prior_dinner = prior_covers.get("dinner", {}).get("total_covers", 0)
|
||||
prior_dinner_resident = prior_covers.get("dinner", {}).get("resident_covers", 0)
|
||||
prior_dinner_non_resident = prior_covers.get("dinner", {}).get("non_resident_covers", 0)
|
||||
|
||||
# Determine pace column for non-resident tooltip
|
||||
if lead_days <= 30:
|
||||
dinner_pace_col = f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
dinner_pace_col = f"d{min(weekly_cols, key=lambda x: abs(x - lead_days))}"
|
||||
else:
|
||||
dinner_pace_col = "d177"
|
||||
|
||||
non_resident_calc = None
|
||||
if lead_days > 0:
|
||||
# ---- NON-RESIDENT PICKUP ----
|
||||
# Use lead-time based median pickup (same logic as lunch)
|
||||
non_resident_pickup = await get_dinner_non_resident_pickup_by_lead_time(db, target_date, lead_days)
|
||||
non_resident_calc = {
|
||||
"day_of_week": target_date.strftime("%A"),
|
||||
"lead_days": lead_days,
|
||||
"pace_column": dinner_pace_col,
|
||||
"lookback_weeks": 8,
|
||||
"median_pickup": non_resident_pickup,
|
||||
"source": "resos_booking_pace (non_resident)",
|
||||
}
|
||||
|
||||
# ---- RESIDENT PICKUP ----
|
||||
# Simple approach: % of hotel guests who dine, applied to forecasted guests
|
||||
# Get hotel occupancy for target_date (dinner is same night as stay)
|
||||
hotel_tonight = await get_hotel_occupancy_for_date(db, target_date)
|
||||
hotel_guests_otb = hotel_tonight["guests"]
|
||||
hotel_rooms_otb = hotel_tonight["occupied_rooms"]
|
||||
|
||||
# Calculate guests per room (use prior year ratio if current is 0)
|
||||
prior_year_hotel = await get_hotel_occupancy_for_date(db, prior_year_date)
|
||||
if hotel_rooms_otb > 0:
|
||||
guests_per_room = hotel_guests_otb / hotel_rooms_otb
|
||||
elif prior_year_hotel["occupied_rooms"] > 0:
|
||||
guests_per_room = prior_year_hotel["guests"] / prior_year_hotel["occupied_rooms"]
|
||||
else:
|
||||
guests_per_room = 1.8 # Default
|
||||
|
||||
# Get pickupv2 room forecast for tonight
|
||||
pickup_rooms = 0
|
||||
try:
|
||||
pickupv2_dinner = await forecast_rooms_for_date(
|
||||
db, target_date, lead_days, prior_year_date, 'hotel_room_nights'
|
||||
)
|
||||
if pickupv2_dinner:
|
||||
pickup_rooms = pickupv2_dinner.get('pickup_rooms_total', 0)
|
||||
except Exception as e:
|
||||
logger.warning(f"Pickupv2 forecast failed for dinner {target_date}: {e}")
|
||||
|
||||
# Calculate forecasted hotel guests (OTB + pickup)
|
||||
pickup_guests = pickup_rooms * guests_per_room
|
||||
forecasted_guests = hotel_guests_otb + pickup_guests
|
||||
|
||||
# Get historical resident dining rate (% of hotel guests who dine)
|
||||
dining_rate = await get_resident_dining_rate(db, target_date)
|
||||
|
||||
# Calculate expected resident covers
|
||||
# forecasted_resident_covers = forecasted_guests × dining_rate
|
||||
forecasted_resident_covers = forecasted_guests * dining_rate
|
||||
|
||||
# Resident pickup = expected total - current OTB resident covers
|
||||
resident_pickup = max(0, math.ceil(forecasted_resident_covers) - dinner_resident_otb)
|
||||
|
||||
dinner_forecast = dinner_otb + resident_pickup + non_resident_pickup
|
||||
|
||||
# Store calculation details for tooltip
|
||||
resident_calc = {
|
||||
"hotel_guests_otb": hotel_guests_otb,
|
||||
"pickup_rooms": round(pickup_rooms, 1),
|
||||
"guests_per_room": round(guests_per_room, 2),
|
||||
"pickup_guests": round(pickup_guests, 1),
|
||||
"forecasted_guests": round(forecasted_guests, 1),
|
||||
"dining_rate": round(dining_rate * 100, 1), # As percentage
|
||||
"forecasted_resident_covers": round(forecasted_resident_covers, 1),
|
||||
"resident_otb": dinner_resident_otb,
|
||||
"source": "last 4 weeks same DOW",
|
||||
}
|
||||
else:
|
||||
# Past date - no pickup
|
||||
dinner_forecast = dinner_otb
|
||||
resident_pickup = 0
|
||||
non_resident_pickup = 0
|
||||
resident_calc = None
|
||||
non_resident_calc = None
|
||||
|
||||
result["dinner"] = {
|
||||
"otb": dinner_otb,
|
||||
"resident_otb": dinner_resident_otb,
|
||||
"non_resident_otb": dinner_non_resident_otb,
|
||||
"dbb_otb": dinner_dbb_otb,
|
||||
"resident_pickup": resident_pickup,
|
||||
"non_resident_pickup": non_resident_pickup,
|
||||
"forecast": dinner_forecast,
|
||||
"prior_year": prior_dinner,
|
||||
"prior_resident": prior_dinner_resident,
|
||||
"prior_non_resident": prior_dinner_non_resident,
|
||||
"resident_calc": resident_calc,
|
||||
"non_resident_calc": non_resident_calc,
|
||||
}
|
||||
|
||||
# Totals
|
||||
total_otb = breakfast_otb + lunch_otb + dinner_otb
|
||||
total_forecast = breakfast_forecast + lunch_forecast + dinner_forecast
|
||||
prior_total = prior_breakfast + prior_lunch + prior_dinner
|
||||
|
||||
result["totals"] = {
|
||||
"otb": total_otb,
|
||||
"forecast": total_forecast,
|
||||
"prior_year": prior_total,
|
||||
"pace_vs_prior_pct": round((total_otb / prior_total * 100), 1) if prior_total > 0 else None
|
||||
}
|
||||
|
||||
# Add hotel occupancy context
|
||||
result["hotel_context"] = {
|
||||
"night_before_occupancy": hotel_otb["occupancy_pct"],
|
||||
"night_before_rooms": hotel_otb["occupied_rooms"],
|
||||
"night_before_guests": hotel_otb["guests"]
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def forecast_covers_range(
|
||||
db: AsyncSession,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
include_details: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate covers forecast for a date range.
|
||||
"""
|
||||
forecasts = []
|
||||
current = start_date
|
||||
|
||||
while current <= end_date:
|
||||
try:
|
||||
day_forecast = await forecast_covers_for_date(db, current, include_details)
|
||||
forecasts.append(day_forecast)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to forecast covers for {current}: {e}")
|
||||
|
||||
current += timedelta(days=1)
|
||||
|
||||
# Calculate summary
|
||||
summary = {
|
||||
"breakfast_otb": sum(f["breakfast"]["otb"] for f in forecasts),
|
||||
"breakfast_forecast": sum(f["breakfast"]["forecast"] for f in forecasts),
|
||||
"breakfast_prior": sum(f["breakfast"]["prior_year"] for f in forecasts),
|
||||
"lunch_otb": sum(f["lunch"]["otb"] for f in forecasts),
|
||||
"lunch_forecast": sum(f["lunch"]["forecast"] for f in forecasts),
|
||||
"lunch_prior": sum(f["lunch"]["prior_year"] for f in forecasts),
|
||||
"dinner_otb": sum(f["dinner"]["otb"] for f in forecasts),
|
||||
"dinner_forecast": sum(f["dinner"]["forecast"] for f in forecasts),
|
||||
"dinner_prior": sum(f["dinner"]["prior_year"] for f in forecasts),
|
||||
"total_otb": sum(f["totals"]["otb"] for f in forecasts),
|
||||
"total_forecast": sum(f["totals"]["forecast"] for f in forecasts),
|
||||
"total_prior": sum(f["totals"]["prior_year"] for f in forecasts),
|
||||
"days_count": len(forecasts)
|
||||
}
|
||||
|
||||
return {
|
||||
"data": forecasts,
|
||||
"summary": summary
|
||||
}
|
||||
659
backend/services/forecasting/historical_forecast.py
Normal file
659
backend/services/forecasting/historical_forecast.py
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
"""
|
||||
Historical Forecast Runner
|
||||
Runs all models as if it were a specific historical date.
|
||||
|
||||
This allows backtesting of Prophet, XGBoost, and Pickup models
|
||||
by only using data that would have been available at that time.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import json
|
||||
from sqlalchemy import text
|
||||
|
||||
from utils.time_alignment import get_prior_year_daily
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_historical_forecast(
|
||||
db,
|
||||
simulated_today: date,
|
||||
metric_codes: List[str] = None,
|
||||
models: List[str] = None,
|
||||
forecast_days: int = 60
|
||||
) -> dict:
|
||||
"""
|
||||
Run forecasts as if today were a specific historical date.
|
||||
|
||||
Only uses data that would have been available on simulated_today.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
simulated_today: The date to pretend "today" is
|
||||
metric_codes: List of metrics to forecast (default: all main metrics)
|
||||
models: List of models to run (default: all)
|
||||
forecast_days: Number of days to forecast (default: 60)
|
||||
|
||||
Returns:
|
||||
Dict with results summary
|
||||
"""
|
||||
if metric_codes is None:
|
||||
metric_codes = ['hotel_room_nights', 'hotel_occupancy_pct', 'resos_dinner_covers', 'resos_lunch_covers']
|
||||
|
||||
if models is None:
|
||||
models = ['prophet', 'xgboost', 'pickup', 'catboost']
|
||||
|
||||
forecast_from = simulated_today + timedelta(days=1)
|
||||
forecast_to = simulated_today + timedelta(days=forecast_days)
|
||||
|
||||
results = {
|
||||
"simulated_today": str(simulated_today),
|
||||
"forecast_from": str(forecast_from),
|
||||
"forecast_to": str(forecast_to),
|
||||
"metrics": {},
|
||||
"total_forecasts": 0
|
||||
}
|
||||
|
||||
for metric_code in metric_codes:
|
||||
results["metrics"][metric_code] = {}
|
||||
|
||||
for model in models:
|
||||
try:
|
||||
if model == 'prophet':
|
||||
forecasts = await _run_prophet_historical(
|
||||
db, metric_code, simulated_today, forecast_from, forecast_to
|
||||
)
|
||||
elif model == 'xgboost':
|
||||
forecasts = await _run_xgboost_historical(
|
||||
db, metric_code, simulated_today, forecast_from, forecast_to
|
||||
)
|
||||
elif model == 'pickup':
|
||||
forecasts = await _run_pickup_historical(
|
||||
db, metric_code, simulated_today, forecast_from, forecast_to
|
||||
)
|
||||
elif model == 'catboost':
|
||||
forecasts = await _run_catboost_historical(
|
||||
db, metric_code, simulated_today, forecast_from, forecast_to
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
results["metrics"][metric_code][model] = len(forecasts)
|
||||
results["total_forecasts"] += len(forecasts)
|
||||
await db.commit() # Commit after each successful model run
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Historical {model} forecast failed for {metric_code}: {e}")
|
||||
await db.rollback() # Rollback on error to clear failed transaction
|
||||
results["metrics"][metric_code][model] = f"error: {str(e)[:100]}"
|
||||
logger.info(f"Historical forecasts complete for {simulated_today}: {results['total_forecasts']} total forecasts")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _run_prophet_historical(
|
||||
db,
|
||||
metric_code: str,
|
||||
simulated_today: date,
|
||||
forecast_from: date,
|
||||
forecast_to: date,
|
||||
training_days: int = 2555
|
||||
) -> List[dict]:
|
||||
"""Run Prophet using only data available before simulated_today."""
|
||||
try:
|
||||
from prophet import Prophet
|
||||
|
||||
# Get room capacity for capping (sum across all room categories for a single date)
|
||||
total_rooms = 25
|
||||
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
|
||||
rooms_result = await db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(available), 25) as total_rooms
|
||||
FROM newbook_occupancy_report
|
||||
WHERE date = (
|
||||
SELECT MAX(date) FROM newbook_occupancy_report
|
||||
WHERE date <= :simulated_today
|
||||
)
|
||||
"""),
|
||||
{"simulated_today": simulated_today}
|
||||
)
|
||||
rooms_row = rooms_result.fetchone()
|
||||
if rooms_row and rooms_row.total_rooms:
|
||||
total_rooms = int(rooms_row.total_rooms)
|
||||
|
||||
# Training data ends at simulated_today - 1 (yesterday from simulated perspective)
|
||||
training_to = simulated_today - timedelta(days=1)
|
||||
training_from = training_to - timedelta(days=training_days)
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT date, actual_value
|
||||
FROM daily_metrics
|
||||
WHERE metric_code = :metric_code
|
||||
AND date BETWEEN :from_date AND :to_date
|
||||
AND actual_value IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"metric_code": metric_code, "from_date": training_from, "to_date": training_to}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if len(rows) < 30:
|
||||
logger.warning(f"Insufficient data for historical Prophet: {metric_code} has {len(rows)} records as of {simulated_today}")
|
||||
return []
|
||||
|
||||
df = pd.DataFrame([{"ds": row.date, "y": float(row.actual_value)} for row in rows])
|
||||
|
||||
model = Prophet(
|
||||
yearly_seasonality=True,
|
||||
weekly_seasonality=True,
|
||||
daily_seasonality=False,
|
||||
interval_width=0.80
|
||||
)
|
||||
model.add_country_holidays(country_name='GB')
|
||||
model.fit(df)
|
||||
|
||||
future_dates = pd.date_range(start=forecast_from, end=forecast_to, freq='D')
|
||||
future_df = pd.DataFrame({"ds": future_dates})
|
||||
forecast = model.predict(future_df)
|
||||
|
||||
forecasts = []
|
||||
for _, row in forecast.iterrows():
|
||||
predicted_value = float(row["yhat"])
|
||||
lower_bound = float(row["yhat_lower"])
|
||||
upper_bound = float(row["yhat_upper"])
|
||||
|
||||
# Apply physical caps
|
||||
if metric_code == 'hotel_occupancy_pct':
|
||||
predicted_value = min(predicted_value, 100)
|
||||
lower_bound = min(lower_bound, 100)
|
||||
upper_bound = min(upper_bound, 100)
|
||||
if metric_code == 'hotel_room_nights':
|
||||
predicted_value = min(predicted_value, total_rooms)
|
||||
lower_bound = min(lower_bound, total_rooms)
|
||||
upper_bound = min(upper_bound, total_rooms)
|
||||
|
||||
forecast_record = {
|
||||
"forecast_date": row["ds"].date(),
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "prophet",
|
||||
"predicted_value": round(predicted_value, 2),
|
||||
"lower_bound": round(lower_bound, 2),
|
||||
"upper_bound": round(upper_bound, 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
# Store with generated_at = simulated_today to track when this "would have been" generated
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type,
|
||||
predicted_value, lower_bound, upper_bound, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type,
|
||||
:predicted_value, :lower_bound, :upper_bound, :generated_at
|
||||
)
|
||||
"""),
|
||||
{**forecast_record, "generated_at": simulated_today}
|
||||
)
|
||||
|
||||
logger.info(f"Historical Prophet forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
except ImportError:
|
||||
logger.error("Prophet not installed")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Historical Prophet failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def _run_xgboost_historical(
|
||||
db,
|
||||
metric_code: str,
|
||||
simulated_today: date,
|
||||
forecast_from: date,
|
||||
forecast_to: date,
|
||||
training_days: int = 2555
|
||||
) -> List[dict]:
|
||||
"""Run XGBoost using only data available before simulated_today."""
|
||||
try:
|
||||
import xgboost as xgb
|
||||
|
||||
training_to = simulated_today - timedelta(days=1)
|
||||
training_from = training_to - timedelta(days=training_days + 60)
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT date, actual_value
|
||||
FROM daily_metrics
|
||||
WHERE metric_code = :metric_code
|
||||
AND date BETWEEN :from_date AND :to_date
|
||||
AND actual_value IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"metric_code": metric_code, "from_date": training_from, "to_date": training_to}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if len(rows) < 60:
|
||||
logger.warning(f"Insufficient data for historical XGBoost: {metric_code} has {len(rows)} records")
|
||||
return []
|
||||
|
||||
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
|
||||
df = df.sort_values('ds').reset_index(drop=True)
|
||||
df = _create_features(df)
|
||||
df = df.dropna()
|
||||
|
||||
feature_cols = [
|
||||
'day_of_week', 'month', 'day_of_month', 'week_of_year', 'is_weekend',
|
||||
'dow_sin', 'dow_cos', 'month_sin', 'month_cos',
|
||||
'lag_7', 'lag_14', 'lag_21', 'lag_28',
|
||||
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
|
||||
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
|
||||
]
|
||||
|
||||
if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30:
|
||||
feature_cols.append('lag_365')
|
||||
|
||||
X = df[feature_cols]
|
||||
y = df['y']
|
||||
|
||||
model = xgb.XGBRegressor(
|
||||
n_estimators=100,
|
||||
max_depth=5,
|
||||
learning_rate=0.1,
|
||||
objective='reg:squarederror',
|
||||
random_state=42
|
||||
)
|
||||
model.fit(X, y)
|
||||
|
||||
forecasts = []
|
||||
current_df = df.copy()
|
||||
|
||||
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
|
||||
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
|
||||
current_df = pd.concat([current_df, new_row], ignore_index=True)
|
||||
current_df = _create_features(current_df)
|
||||
|
||||
X_pred = current_df[feature_cols].iloc[-1:].ffill()
|
||||
prediction = float(model.predict(X_pred)[0])
|
||||
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
|
||||
|
||||
forecast_record = {
|
||||
"forecast_date": forecast_date.date(),
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "xgboost",
|
||||
"predicted_value": round(float(prediction), 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type, predicted_value, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at
|
||||
)
|
||||
"""),
|
||||
{**forecast_record, "generated_at": simulated_today}
|
||||
)
|
||||
|
||||
logger.info(f"Historical XGBoost forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Required package not installed: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Historical XGBoost failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def _run_pickup_historical(
|
||||
db,
|
||||
metric_code: str,
|
||||
simulated_today: date,
|
||||
forecast_from: date,
|
||||
forecast_to: date
|
||||
) -> List[dict]:
|
||||
"""Run Pickup model using only data available before simulated_today."""
|
||||
|
||||
forecasts = []
|
||||
|
||||
# Get room capacity (sum of available rooms across all categories for a single date)
|
||||
total_rooms = 25
|
||||
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
|
||||
rooms_result = await db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(SUM(available), 25) as total_rooms
|
||||
FROM newbook_occupancy_report
|
||||
WHERE date = (
|
||||
SELECT MAX(date) FROM newbook_occupancy_report
|
||||
WHERE date <= :simulated_today
|
||||
)
|
||||
"""),
|
||||
{"simulated_today": simulated_today}
|
||||
)
|
||||
rooms_row = rooms_result.fetchone()
|
||||
if rooms_row and rooms_row.total_rooms:
|
||||
total_rooms = int(rooms_row.total_rooms)
|
||||
|
||||
for days_out in range((forecast_to - forecast_from).days + 1):
|
||||
forecast_date = forecast_from + timedelta(days=days_out)
|
||||
lead_time = (forecast_date - simulated_today).days
|
||||
|
||||
if lead_time < 1:
|
||||
continue
|
||||
|
||||
# Calculate prior year comparison date
|
||||
prior_year_date = get_prior_year_daily(forecast_date)
|
||||
|
||||
# Get/reconstruct current OTB as of simulated_today
|
||||
current_otb = await _get_reconstructed_otb(
|
||||
db, metric_code, forecast_date, simulated_today, total_rooms
|
||||
)
|
||||
|
||||
if current_otb is None:
|
||||
continue
|
||||
|
||||
# Get prior year OTB at same lead time
|
||||
prior_simulated_today = get_prior_year_daily(simulated_today)
|
||||
prior_otb = await _get_reconstructed_otb(
|
||||
db, metric_code, prior_year_date, prior_simulated_today, total_rooms
|
||||
)
|
||||
|
||||
# Get prior year final actual
|
||||
prior_final_result = await db.execute(
|
||||
text("""
|
||||
SELECT actual_value
|
||||
FROM daily_metrics
|
||||
WHERE date = :prior_date AND metric_code = :metric
|
||||
"""),
|
||||
{"prior_date": prior_year_date, "metric": metric_code}
|
||||
)
|
||||
prior_final_row = prior_final_result.fetchone()
|
||||
prior_final = float(prior_final_row.actual_value) if prior_final_row and prior_final_row.actual_value else None
|
||||
|
||||
# Calculate projection using additive method
|
||||
projected_value = current_otb
|
||||
|
||||
if prior_otb is not None and prior_final is not None:
|
||||
prior_pickup = prior_final - prior_otb
|
||||
projected_value = current_otb + prior_pickup
|
||||
|
||||
if projected_value < current_otb:
|
||||
projected_value = current_otb
|
||||
|
||||
# Apply caps
|
||||
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
|
||||
projected_value = 100
|
||||
if metric_code == 'hotel_room_nights' and projected_value > total_rooms:
|
||||
projected_value = total_rooms
|
||||
|
||||
elif prior_final is not None and prior_final > 0:
|
||||
# Implied additive
|
||||
if lead_time >= 28:
|
||||
estimated_pct = 0.35
|
||||
elif lead_time >= 14:
|
||||
estimated_pct = 0.55
|
||||
elif lead_time >= 7:
|
||||
estimated_pct = 0.75
|
||||
else:
|
||||
estimated_pct = 0.90
|
||||
|
||||
implied_pickup = prior_final * (1 - estimated_pct)
|
||||
projected_value = current_otb + implied_pickup
|
||||
projected_value = max(projected_value, current_otb)
|
||||
|
||||
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
|
||||
projected_value = 100
|
||||
if metric_code == 'hotel_room_nights' and projected_value > total_rooms:
|
||||
projected_value = total_rooms
|
||||
|
||||
forecast_record = {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "pickup",
|
||||
"predicted_value": round(projected_value, 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type, predicted_value, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at
|
||||
)
|
||||
"""),
|
||||
{**forecast_record, "generated_at": simulated_today}
|
||||
)
|
||||
|
||||
logger.info(f"Historical Pickup forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
|
||||
async def _run_catboost_historical(
|
||||
db,
|
||||
metric_code: str,
|
||||
simulated_today: date,
|
||||
forecast_from: date,
|
||||
forecast_to: date,
|
||||
training_days: int = 2555
|
||||
) -> List[dict]:
|
||||
"""Run CatBoost using only data available before simulated_today."""
|
||||
try:
|
||||
from catboost import CatBoostRegressor
|
||||
|
||||
training_to = simulated_today - timedelta(days=1)
|
||||
training_from = training_to - timedelta(days=training_days + 60)
|
||||
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT date, actual_value
|
||||
FROM daily_metrics
|
||||
WHERE metric_code = :metric_code
|
||||
AND date BETWEEN :from_date AND :to_date
|
||||
AND actual_value IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"metric_code": metric_code, "from_date": training_from, "to_date": training_to}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
if len(rows) < 60:
|
||||
logger.warning(f"Insufficient data for historical CatBoost: {metric_code} has {len(rows)} records")
|
||||
return []
|
||||
|
||||
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
|
||||
df = df.sort_values('ds').reset_index(drop=True)
|
||||
df = _create_catboost_features(df)
|
||||
df = df.dropna()
|
||||
|
||||
categorical_features = ['day_of_week', 'month']
|
||||
numerical_features = [
|
||||
'day_of_month', 'week_of_year', 'is_weekend',
|
||||
'lag_7', 'lag_14', 'lag_21', 'lag_28',
|
||||
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
|
||||
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
|
||||
]
|
||||
|
||||
if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30:
|
||||
numerical_features.append('lag_365')
|
||||
|
||||
feature_cols = categorical_features + numerical_features
|
||||
|
||||
X = df[feature_cols]
|
||||
y = df['y']
|
||||
|
||||
model = CatBoostRegressor(
|
||||
iterations=200,
|
||||
depth=6,
|
||||
learning_rate=0.1,
|
||||
loss_function='RMSE',
|
||||
cat_features=categorical_features,
|
||||
verbose=False,
|
||||
random_seed=42
|
||||
)
|
||||
model.fit(X, y)
|
||||
|
||||
forecasts = []
|
||||
current_df = df.copy()
|
||||
|
||||
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
|
||||
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
|
||||
current_df = pd.concat([current_df, new_row], ignore_index=True)
|
||||
current_df = _create_catboost_features(current_df)
|
||||
|
||||
X_pred = current_df[feature_cols].iloc[-1:].copy()
|
||||
for col in numerical_features:
|
||||
if col in X_pred.columns:
|
||||
X_pred[col] = X_pred[col].ffill()
|
||||
if X_pred[col].isna().any():
|
||||
X_pred[col] = X_pred[col].fillna(0)
|
||||
|
||||
prediction = float(model.predict(X_pred)[0])
|
||||
prediction = max(0, prediction)
|
||||
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
|
||||
|
||||
forecast_record = {
|
||||
"forecast_date": forecast_date.date(),
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "catboost",
|
||||
"predicted_value": round(float(prediction), 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type, predicted_value, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type, :predicted_value, :generated_at
|
||||
)
|
||||
"""),
|
||||
{**forecast_record, "generated_at": simulated_today}
|
||||
)
|
||||
|
||||
logger.info(f"Historical CatBoost forecast for {metric_code} as of {simulated_today}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"CatBoost not installed: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Historical CatBoost failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _create_catboost_features(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Create features for CatBoost model with native categorical support."""
|
||||
df = df.copy()
|
||||
|
||||
df['day_of_week'] = df['ds'].dt.dayofweek.astype(str) # Categorical for CatBoost
|
||||
df['month'] = df['ds'].dt.month.astype(str) # Categorical for CatBoost
|
||||
df['day_of_month'] = df['ds'].dt.day
|
||||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||||
df['is_weekend'] = (df['ds'].dt.dayofweek >= 5).astype(int)
|
||||
|
||||
for lag in [7, 14, 21, 28]:
|
||||
df[f'lag_{lag}'] = df['y'].shift(lag)
|
||||
|
||||
for window in [7, 14, 28]:
|
||||
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
|
||||
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std().fillna(0)
|
||||
|
||||
if len(df) > 365:
|
||||
df['lag_365'] = df['y'].shift(365)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
async def _get_reconstructed_otb(
|
||||
db,
|
||||
metric_code: str,
|
||||
target_date: date,
|
||||
as_of_date: date,
|
||||
total_rooms: int = 25
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Get or reconstruct OTB value for a target date as of a specific date.
|
||||
|
||||
First tries pickup_snapshots, then reconstructs from booking data.
|
||||
"""
|
||||
# Try snapshots first
|
||||
snap_result = await db.execute(
|
||||
text("""
|
||||
SELECT otb_value
|
||||
FROM pickup_snapshots
|
||||
WHERE stay_date = :target_date
|
||||
AND metric_type = :metric
|
||||
AND snapshot_date <= :as_of_date
|
||||
ORDER BY snapshot_date DESC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"target_date": target_date, "metric": metric_code, "as_of_date": as_of_date}
|
||||
)
|
||||
snap_row = snap_result.fetchone()
|
||||
|
||||
if snap_row and snap_row.otb_value is not None:
|
||||
return float(snap_row.otb_value)
|
||||
|
||||
# Reconstruct from booking data
|
||||
# EXCLUDES overflow category (category_id=5) used for chargeable no-shows
|
||||
if metric_code in ('hotel_room_nights', 'hotel_occupancy_pct'):
|
||||
# Use CAST instead of :: to avoid asyncpg parameter parsing issues
|
||||
recon_result = await db.execute(
|
||||
text("""
|
||||
SELECT COUNT(DISTINCT newbook_id) as otb_count
|
||||
FROM newbook_bookings
|
||||
WHERE arrival_date <= :target_date
|
||||
AND departure_date > :target_date
|
||||
AND LOWER(status) NOT IN ('cancelled', 'no show', 'no_show', 'quote', 'waitlist')
|
||||
AND CAST(raw_json->>'booking_placed' AS timestamp) <= CAST(:as_of_date AS date) + INTERVAL '1 day'
|
||||
AND (category_id IS NULL OR category_id != '5')
|
||||
"""),
|
||||
{"target_date": target_date, "as_of_date": as_of_date}
|
||||
)
|
||||
recon_row = recon_result.fetchone()
|
||||
|
||||
if recon_row:
|
||||
otb_count = recon_row.otb_count or 0
|
||||
if metric_code == 'hotel_occupancy_pct':
|
||||
return (otb_count / total_rooms) * 100 if total_rooms > 0 else 0
|
||||
else:
|
||||
return otb_count
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _create_features(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Create features for XGBoost model."""
|
||||
df = df.copy()
|
||||
|
||||
df['day_of_week'] = df['ds'].dt.dayofweek
|
||||
df['month'] = df['ds'].dt.month
|
||||
df['day_of_month'] = df['ds'].dt.day
|
||||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||||
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
|
||||
|
||||
df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7)
|
||||
df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7)
|
||||
df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
|
||||
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
|
||||
|
||||
for lag in [7, 14, 21, 28]:
|
||||
df[f'lag_{lag}'] = df['y'].shift(lag)
|
||||
|
||||
for window in [7, 14, 28]:
|
||||
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
|
||||
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std()
|
||||
|
||||
if len(df) > 365:
|
||||
df['lag_365'] = df['y'].shift(365)
|
||||
|
||||
return df
|
||||
332
backend/services/forecasting/pickup_model.py
Normal file
332
backend/services/forecasting/pickup_model.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"""
|
||||
Pickup forecasting model
|
||||
Hotel industry standard pace/pickup tracking
|
||||
Compares current on-the-books vs historical patterns
|
||||
|
||||
Uses ADDITIVE method for small properties:
|
||||
- Projected = current_otb + expected_pickup_count
|
||||
- Where expected_pickup_count = prior_year_final - prior_year_otb
|
||||
|
||||
This avoids ratio distortion with small numbers (e.g., 2→6 = 3x ratio
|
||||
applied to 5 = 15 rooms, which is unrealistic)
|
||||
|
||||
Prior year comparison uses 364 days (52 weeks) for day-of-week alignment:
|
||||
- Monday compares to Monday
|
||||
- Saturday compares to Saturday
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import text
|
||||
|
||||
from utils.time_alignment import get_prior_year_daily, get_comparison_info
|
||||
from utils.capacity import get_bookable_cap_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_pickup_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
forecast_from: date,
|
||||
forecast_to: date
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Run Pickup model forecast for a metric
|
||||
|
||||
The pickup model works by:
|
||||
1. Getting current on-the-books (OTB) for each future date
|
||||
2. Comparing to prior year SAME DAY OF WEEK at same lead time
|
||||
3. Projecting final using ADDITIVE method (not ratio) for reliability
|
||||
|
||||
Additive method: projected = current_otb + (prior_final - prior_otb)
|
||||
This represents: "what I have now + what typically picks up from here"
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast
|
||||
forecast_from: Start date for forecasts
|
||||
forecast_to: End date for forecasts
|
||||
|
||||
Returns:
|
||||
List of forecast records
|
||||
"""
|
||||
forecasts = []
|
||||
today = date.today()
|
||||
|
||||
for days_out in range((forecast_to - forecast_from).days + 1):
|
||||
forecast_date = forecast_from + timedelta(days=days_out)
|
||||
lead_time = (forecast_date - today).days
|
||||
|
||||
if lead_time < 1:
|
||||
continue # Can't do pickup for past dates
|
||||
|
||||
# Calculate prior year comparison date (same day of week alignment)
|
||||
prior_year_date = get_prior_year_daily(forecast_date)
|
||||
|
||||
# Get bookable rooms for this date (rooms - maintenance, for capping)
|
||||
bookable_cap = get_bookable_cap_sync(db, forecast_date, fallback_value=25)
|
||||
|
||||
# Get current OTB from snapshots
|
||||
otb_result = db.execute(
|
||||
text("""
|
||||
SELECT otb_value, prior_year_otb, prior_year_final
|
||||
FROM pickup_snapshots
|
||||
WHERE stay_date = :forecast_date
|
||||
AND metric_type = :metric_code
|
||||
AND snapshot_date = :today
|
||||
"""),
|
||||
{"forecast_date": forecast_date, "metric_code": metric_code, "today": today}
|
||||
)
|
||||
otb_row = otb_result.fetchone()
|
||||
|
||||
if not otb_row:
|
||||
# No OTB data available, skip
|
||||
continue
|
||||
|
||||
current_otb = float(otb_row.otb_value) if otb_row.otb_value is not None else 0
|
||||
# Use 'is not None' - 0 is valid data meaning no bookings at that lead time
|
||||
prior_otb = float(otb_row.prior_year_otb) if otb_row.prior_year_otb is not None else None
|
||||
prior_final = float(otb_row.prior_year_final) if otb_row.prior_year_final is not None else None
|
||||
|
||||
# Get pickup curve for this day of week and season (fallback)
|
||||
day_of_week = forecast_date.weekday()
|
||||
day_name = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'][day_of_week]
|
||||
month = forecast_date.month
|
||||
|
||||
# Determine season
|
||||
if month in [6, 7, 8]:
|
||||
season = 'peak'
|
||||
elif month in [12, 1, 2]:
|
||||
season = 'low'
|
||||
else:
|
||||
season = 'shoulder'
|
||||
|
||||
curve_result = db.execute(
|
||||
text("""
|
||||
SELECT avg_pct_of_final, std_dev
|
||||
FROM pickup_curves
|
||||
WHERE day_of_week = :dow
|
||||
AND season = :season
|
||||
AND metric_type = :metric_code
|
||||
AND days_out = :lead_time
|
||||
"""),
|
||||
{"dow": day_of_week, "season": season, "metric_code": metric_code, "lead_time": lead_time}
|
||||
)
|
||||
curve_row = curve_result.fetchone()
|
||||
|
||||
# Calculate projection
|
||||
projected_value = current_otb
|
||||
projection_method = 'current_otb'
|
||||
pace_vs_prior = None
|
||||
confidence_note = "Using current on-the-books"
|
||||
|
||||
if prior_otb is not None and prior_final is not None:
|
||||
# Calculate expected pickup count from prior year
|
||||
prior_pickup_count = prior_final - prior_otb # How many picked up from this lead time
|
||||
|
||||
# Calculate pace vs prior year
|
||||
if prior_otb > 0:
|
||||
pace_vs_prior = ((current_otb - prior_otb) / prior_otb) * 100
|
||||
|
||||
# ADDITIVE METHOD: current + expected pickup
|
||||
# This is more reliable for small properties than ratio method
|
||||
# Example: prior had 2 OTB → 6 final = 4 pickup
|
||||
# current has 5 OTB → project 5 + 4 = 9
|
||||
projected_value = current_otb + prior_pickup_count
|
||||
|
||||
# Ensure projection is at least current OTB (pickup can't be negative in projection)
|
||||
if projected_value < current_otb:
|
||||
projected_value = current_otb
|
||||
projection_method = 'additive_floor'
|
||||
confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: {prior_otb:.0f}→{prior_final:.0f} (negative pickup, using OTB)"
|
||||
else:
|
||||
projection_method = 'additive'
|
||||
confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: {prior_otb:.0f}→{prior_final:.0f} (+{prior_pickup_count:.0f} pickup)"
|
||||
|
||||
# Apply physical caps
|
||||
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
|
||||
projected_value = 100
|
||||
confidence_note += " (capped at 100%)"
|
||||
if metric_code == 'hotel_room_nights' and projected_value > bookable_cap:
|
||||
projected_value = bookable_cap
|
||||
confidence_note += f" (capped at {bookable_cap} bookable rooms)"
|
||||
|
||||
elif prior_final is not None and prior_final > 0:
|
||||
# No prior OTB, but have prior final - use as guidance
|
||||
# Estimate typical OTB percentage at this lead time
|
||||
if lead_time >= 28:
|
||||
estimated_pct = 0.35
|
||||
elif lead_time >= 14:
|
||||
estimated_pct = 0.55
|
||||
elif lead_time >= 7:
|
||||
estimated_pct = 0.75
|
||||
else:
|
||||
estimated_pct = 0.90
|
||||
|
||||
# Calculate implied pickup from typical percentages
|
||||
implied_prior_otb = prior_final * estimated_pct
|
||||
implied_pickup = prior_final - implied_prior_otb
|
||||
|
||||
# Apply additive method with implied pickup
|
||||
projected_value = current_otb + implied_pickup
|
||||
projected_value = max(projected_value, current_otb)
|
||||
|
||||
projection_method = 'implied_additive'
|
||||
confidence_note = f"vs {day_name} {prior_year_date.strftime('%d %b %Y')}: final was {prior_final:.0f}, est +{implied_pickup:.0f} pickup at {lead_time}d"
|
||||
|
||||
# Apply physical caps
|
||||
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
|
||||
projected_value = 100
|
||||
confidence_note += " (capped at 100%)"
|
||||
if metric_code == 'hotel_room_nights' and projected_value > bookable_cap:
|
||||
projected_value = bookable_cap
|
||||
confidence_note += f" (capped at {bookable_cap} bookable rooms)"
|
||||
|
||||
elif curve_row and curve_row.avg_pct_of_final > 0:
|
||||
# Curve method: project based on historical pickup curve
|
||||
projected_value = current_otb / (curve_row.avg_pct_of_final / 100)
|
||||
projection_method = 'curve'
|
||||
confidence_note = f"Based on pickup curve ({curve_row.avg_pct_of_final:.1f}% typical at {lead_time} days out)"
|
||||
|
||||
# Apply physical caps
|
||||
if metric_code == 'hotel_occupancy_pct' and projected_value > 100:
|
||||
projected_value = 100
|
||||
confidence_note += " (capped at 100%)"
|
||||
if metric_code == 'hotel_room_nights' and projected_value > bookable_cap:
|
||||
projected_value = bookable_cap
|
||||
confidence_note += f" (capped at {bookable_cap} bookable rooms)"
|
||||
|
||||
forecast_record = {
|
||||
"forecast_date": forecast_date,
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "pickup",
|
||||
"predicted_value": round(projected_value, 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
# Store in database
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type, predicted_value, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type, :predicted_value, NOW()
|
||||
)
|
||||
"""),
|
||||
forecast_record
|
||||
)
|
||||
|
||||
# Store explanation
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO pickup_explanations (
|
||||
forecast_date, forecast_type, current_otb, days_out,
|
||||
comparison_otb, comparison_final,
|
||||
pickup_curve_pct, pace_vs_prior_pct, projection_method,
|
||||
projected_value, confidence_note, generated_at
|
||||
) VALUES (
|
||||
:date, :metric, :otb, :days_out,
|
||||
:prior_otb, :prior_final,
|
||||
:curve_pct, :pace, :method,
|
||||
:projected, :confidence, NOW()
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"date": forecast_date,
|
||||
"metric": metric_code,
|
||||
"otb": current_otb,
|
||||
"days_out": lead_time,
|
||||
"prior_otb": prior_otb,
|
||||
"prior_final": prior_final,
|
||||
"curve_pct": curve_row.avg_pct_of_final if curve_row else None,
|
||||
"pace": pace_vs_prior,
|
||||
"method": projection_method,
|
||||
"projected": projected_value,
|
||||
"confidence": confidence_note
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass # Skip if conflict, explanations are supplementary
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Pickup forecast generated for {metric_code}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
|
||||
async def update_pickup_curves(db, metric_code: str, lookback_days: int = 2555):
|
||||
"""
|
||||
Update historical pickup curves from actuals
|
||||
|
||||
Calculates average percentage of final value at each lead time
|
||||
"""
|
||||
logger.info(f"Updating pickup curves for {metric_code}")
|
||||
|
||||
# For each day of week and season
|
||||
for dow in range(7):
|
||||
for season in ['peak', 'shoulder', 'low']:
|
||||
# Get historical final values and snapshots
|
||||
result = db.execute(
|
||||
text("""
|
||||
WITH final_values AS (
|
||||
SELECT date, actual_value
|
||||
FROM daily_metrics
|
||||
WHERE metric_code = :metric_code
|
||||
AND date > CURRENT_DATE - :lookback
|
||||
AND actual_value IS NOT NULL
|
||||
AND EXTRACT(DOW FROM date) = :dow
|
||||
),
|
||||
snapshot_data AS (
|
||||
SELECT
|
||||
ps.stay_date,
|
||||
ps.days_out,
|
||||
ps.otb_value,
|
||||
fv.actual_value as final_value
|
||||
FROM pickup_snapshots ps
|
||||
JOIN final_values fv ON ps.stay_date = fv.date
|
||||
WHERE ps.metric_type = :metric_code
|
||||
)
|
||||
SELECT
|
||||
days_out,
|
||||
AVG(otb_value / NULLIF(final_value, 0) * 100) as avg_pct,
|
||||
STDDEV(otb_value / NULLIF(final_value, 0) * 100) as std_pct,
|
||||
COUNT(*) as sample_count
|
||||
FROM snapshot_data
|
||||
WHERE final_value > 0
|
||||
GROUP BY days_out
|
||||
HAVING COUNT(*) >= 5
|
||||
"""),
|
||||
{"metric_code": metric_code, "lookback": lookback_days, "dow": dow}
|
||||
)
|
||||
|
||||
for row in result.fetchall():
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO pickup_curves (
|
||||
day_of_week, season, metric_type, days_out,
|
||||
avg_pct_of_final, std_dev, sample_count, updated_at
|
||||
) VALUES (
|
||||
:dow, :season, :metric, :days_out,
|
||||
:avg_pct, :std, :count, NOW()
|
||||
)
|
||||
ON CONFLICT (day_of_week, season, metric_type, days_out)
|
||||
DO UPDATE SET
|
||||
avg_pct_of_final = :avg_pct,
|
||||
std_dev = :std,
|
||||
sample_count = :count,
|
||||
updated_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"dow": dow,
|
||||
"season": season,
|
||||
"metric": metric_code,
|
||||
"days_out": row.days_out,
|
||||
"avg_pct": row.avg_pct,
|
||||
"std": row.std_pct,
|
||||
"count": row.sample_count
|
||||
}
|
||||
)
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Pickup curves updated for {metric_code}")
|
||||
177
backend/services/forecasting/pickup_tuned.py
Normal file
177
backend/services/forecasting/pickup_tuned.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""
|
||||
Pickup Tuned Model Service
|
||||
|
||||
This is the production-tuned Pickup model extracted from the preview endpoint.
|
||||
Uses the exact same logic as the frontend preview to ensure value consistency.
|
||||
|
||||
Pickup Formula: Forecast = Current OTB + (Prior Year Final - Prior Year OTB)
|
||||
|
||||
This transparent model calculates expected pickup based on prior year booking patterns.
|
||||
Only works for room-based metrics (occupancy, rooms). Not applicable to revenue metrics.
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy import text
|
||||
|
||||
from utils.capacity import get_bookable_cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_lead_time_column(lead_days: int) -> str:
|
||||
"""Map lead days to the appropriate column in newbook_booking_pace."""
|
||||
if lead_days <= 0:
|
||||
return "d0"
|
||||
elif lead_days <= 30:
|
||||
return f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
for col in weekly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d177"
|
||||
else:
|
||||
monthly_cols = [210, 240, 270, 300, 330, 365]
|
||||
for col in monthly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d365"
|
||||
|
||||
|
||||
async def run_pickup_tuned_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
perception_date: Optional[date] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Generate Pickup forecast using production-tuned model.
|
||||
|
||||
Uses transparent booking pace formula:
|
||||
Forecast = Current OTB + (Prior Year Final - Prior Year OTB)
|
||||
|
||||
This uses the exact same logic as the preview endpoint to ensure
|
||||
backend snapshots match frontend preview values.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast (only room-based metrics supported)
|
||||
start_date: Start date for forecast
|
||||
end_date: End date for forecast
|
||||
perception_date: Optional date to generate forecast as-of (for backtesting)
|
||||
|
||||
Returns:
|
||||
List of forecast dicts with forecast_date and predicted_value
|
||||
"""
|
||||
logger.info(f"Running Pickup tuned forecast for {metric_code}: {start_date} to {end_date}")
|
||||
|
||||
# Map metric codes to preview endpoint metric names
|
||||
metric_map = {
|
||||
'hotel_occupancy_pct': 'occupancy',
|
||||
'hotel_room_nights': 'rooms',
|
||||
'hotel_guests': 'guests',
|
||||
}
|
||||
|
||||
metric = metric_map.get(metric_code, 'rooms')
|
||||
|
||||
# Check if metric is room-based (pickup model only works for these)
|
||||
is_room_based = metric in ('occupancy', 'rooms')
|
||||
if not is_room_based:
|
||||
logger.warning(f"Pickup model doesn't apply to non-room metric: {metric_code}")
|
||||
return []
|
||||
|
||||
# Use perception_date if provided, otherwise use actual today
|
||||
today = perception_date if perception_date else date.today()
|
||||
|
||||
# Get default bookable cap
|
||||
default_bookable_cap = await get_bookable_cap(db)
|
||||
|
||||
# Generate forecasts for each date
|
||||
forecasts = []
|
||||
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
lead_days = (current_date - today).days
|
||||
if lead_days < 0:
|
||||
current_date += timedelta(days=1)
|
||||
continue
|
||||
|
||||
lead_col = get_lead_time_column(lead_days)
|
||||
prior_year_date = current_date - timedelta(days=364) # 52 weeks for DOW alignment
|
||||
|
||||
# Get current OTB
|
||||
current_query = text("""
|
||||
SELECT booking_count as current_otb
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :arrival_date
|
||||
""")
|
||||
current_result = await db.execute(current_query, {"arrival_date": current_date})
|
||||
current_row = current_result.fetchone()
|
||||
|
||||
# Get prior year OTB from booking_pace (for lead time comparison)
|
||||
prior_year_for_otb = current_date - timedelta(days=364)
|
||||
prior_otb_query = text(f"""
|
||||
SELECT {lead_col} as prior_otb
|
||||
FROM newbook_booking_pace
|
||||
WHERE arrival_date = :prior_date
|
||||
""")
|
||||
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
|
||||
prior_otb_row = prior_otb_result.fetchone()
|
||||
|
||||
# Get prior year FINAL from bookings_stats
|
||||
prior_final_query = text("""
|
||||
SELECT booking_count as prior_final
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :prior_date
|
||||
""")
|
||||
prior_final_result = await db.execute(prior_final_query, {"prior_date": prior_year_date})
|
||||
prior_final_row = prior_final_result.fetchone()
|
||||
|
||||
# Extract values
|
||||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||||
prior_otb = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else None
|
||||
prior_final = prior_final_row.prior_final if prior_final_row and prior_final_row.prior_final is not None else 0
|
||||
|
||||
# Get per-date bookable cap
|
||||
date_bookable_cap = await get_bookable_cap(db, current_date, default_bookable_cap)
|
||||
|
||||
# Convert to occupancy % if metric is occupancy
|
||||
if metric == "occupancy" and date_bookable_cap > 0:
|
||||
if current_otb is not None:
|
||||
current_otb = (current_otb / date_bookable_cap) * 100
|
||||
if prior_otb is not None:
|
||||
prior_otb = (prior_otb / date_bookable_cap) * 100
|
||||
if prior_final is not None:
|
||||
prior_final = (prior_final / date_bookable_cap) * 100
|
||||
|
||||
# Calculate forecast using pickup formula
|
||||
forecast = None
|
||||
|
||||
if current_otb is not None:
|
||||
if prior_final is not None and prior_otb is not None:
|
||||
expected_pickup = prior_final - prior_otb
|
||||
forecast = current_otb + expected_pickup
|
||||
# Floor to current OTB if pickup is negative
|
||||
if forecast < current_otb:
|
||||
forecast = current_otb
|
||||
# Cap at max capacity (uses per-date bookable cap)
|
||||
if metric == "occupancy" and forecast > 100:
|
||||
forecast = 100.0
|
||||
elif metric == "rooms" and forecast > date_bookable_cap:
|
||||
forecast = float(date_bookable_cap)
|
||||
else:
|
||||
# No prior year data - use current OTB as forecast
|
||||
forecast = current_otb
|
||||
|
||||
if forecast is not None:
|
||||
forecasts.append({
|
||||
'forecast_date': current_date,
|
||||
'predicted_value': round(forecast, 1)
|
||||
})
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
logger.info(f"Pickup tuned generated {len(forecasts)} forecasts for {metric_code}")
|
||||
return forecasts
|
||||
1367
backend/services/forecasting/pickup_v2_model.py
Normal file
1367
backend/services/forecasting/pickup_v2_model.py
Normal file
File diff suppressed because it is too large
Load diff
195
backend/services/forecasting/prophet_model.py
Normal file
195
backend/services/forecasting/prophet_model.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
"""
|
||||
Prophet forecasting model
|
||||
Time series forecasting with trend, seasonality, and holiday effects
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_prophet_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
forecast_from: date,
|
||||
forecast_to: date,
|
||||
training_days: int = 2555 # ~7 years - use all available history
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Run Prophet forecast for a metric
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct')
|
||||
forecast_from: Start date for forecasts
|
||||
forecast_to: End date for forecasts
|
||||
training_days: Days of historical data to use for training
|
||||
|
||||
Returns:
|
||||
List of forecast records
|
||||
"""
|
||||
try:
|
||||
from prophet import Prophet
|
||||
|
||||
# Get historical data
|
||||
training_from = forecast_from - timedelta(days=training_days)
|
||||
|
||||
# Revenue metrics use earned_revenue_data joined with gl_accounts
|
||||
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev']
|
||||
if metric_code in revenue_metrics:
|
||||
revenue_departments = {
|
||||
'net_accom': 'accommodation',
|
||||
'net_dry': 'dry',
|
||||
'net_wet': 'wet',
|
||||
'total_rev': None, # All departments
|
||||
}
|
||||
department = revenue_departments.get(metric_code)
|
||||
if department is None and metric_code != 'total_rev':
|
||||
logger.warning(f"Unknown revenue metric for Prophet: {metric_code}")
|
||||
return []
|
||||
|
||||
if metric_code == 'total_rev':
|
||||
# Total revenue across all departments
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT date, SUM(amount_net) as actual_value
|
||||
FROM newbook_earned_revenue_data
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
GROUP BY date
|
||||
HAVING SUM(amount_net) IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
|
||||
)
|
||||
else:
|
||||
# Revenue by department
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT r.date, SUM(r.amount_net) as actual_value
|
||||
FROM newbook_earned_revenue_data r
|
||||
JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id
|
||||
WHERE r.date BETWEEN :from_date AND :to_date
|
||||
AND g.department = :department
|
||||
GROUP BY r.date
|
||||
HAVING SUM(r.amount_net) IS NOT NULL
|
||||
ORDER BY r.date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department}
|
||||
)
|
||||
else:
|
||||
# Hotel metrics use newbook_bookings_stats table
|
||||
metric_column_map = {
|
||||
'hotel_occupancy_pct': 'total_occupancy_pct',
|
||||
'hotel_room_nights': 'booking_count',
|
||||
'hotel_guests': 'guests_count',
|
||||
}
|
||||
|
||||
column_name = metric_column_map.get(metric_code)
|
||||
if not column_name:
|
||||
logger.warning(f"Unknown metric_code for Prophet: {metric_code}")
|
||||
return []
|
||||
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
SELECT date, {column_name} as actual_value
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
AND {column_name} IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
if len(rows) < 30:
|
||||
logger.warning(f"Insufficient data for Prophet forecast: {metric_code} has {len(rows)} records")
|
||||
return []
|
||||
|
||||
# Prepare data for Prophet
|
||||
df = pd.DataFrame([{"ds": row.date, "y": float(row.actual_value)} for row in rows])
|
||||
|
||||
# Initialize and fit Prophet model
|
||||
model = Prophet(
|
||||
yearly_seasonality=True,
|
||||
weekly_seasonality=True,
|
||||
daily_seasonality=False,
|
||||
interval_width=0.80 # 80% confidence interval
|
||||
)
|
||||
|
||||
# Add UK holidays
|
||||
model.add_country_holidays(country_name='GB')
|
||||
|
||||
model.fit(df)
|
||||
|
||||
# Generate future dates
|
||||
future_dates = pd.date_range(start=forecast_from, end=forecast_to, freq='D')
|
||||
future_df = pd.DataFrame({"ds": future_dates})
|
||||
|
||||
# Make predictions
|
||||
forecast = model.predict(future_df)
|
||||
|
||||
# Store forecasts
|
||||
forecasts = []
|
||||
for _, row in forecast.iterrows():
|
||||
forecast_record = {
|
||||
"forecast_date": row["ds"].date(),
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "prophet",
|
||||
"predicted_value": round(float(row["yhat"]), 2),
|
||||
"lower_bound": round(float(row["yhat_lower"]), 2),
|
||||
"upper_bound": round(float(row["yhat_upper"]), 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
# Store in database - simple insert (latest value wins)
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type,
|
||||
predicted_value, lower_bound, upper_bound, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type,
|
||||
:predicted_value, :lower_bound, :upper_bound, NOW()
|
||||
)
|
||||
"""),
|
||||
forecast_record
|
||||
)
|
||||
|
||||
# Store decomposition for explainability
|
||||
# Simple insert - decomposition stored per generation
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO prophet_decomposition (
|
||||
forecast_date, forecast_type, trend,
|
||||
yearly_seasonality, weekly_seasonality, generated_at
|
||||
) VALUES (
|
||||
:date, :metric, :trend, :yearly, :weekly, NOW()
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"date": row["ds"].date(),
|
||||
"metric": metric_code,
|
||||
"trend": round(float(row.get("trend", 0)), 4),
|
||||
"yearly": round(float(row.get("yearly", 0)), 4),
|
||||
"weekly": round(float(row.get("weekly", 0)), 4)
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass # Skip if conflict, decomposition is supplementary
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Prophet forecast generated for {metric_code}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
except ImportError:
|
||||
logger.error("Prophet not installed. Install with: pip install prophet")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Prophet forecast failed for {metric_code}: {e}")
|
||||
return []
|
||||
281
backend/services/forecasting/prophet_tuned.py
Normal file
281
backend/services/forecasting/prophet_tuned.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"""
|
||||
Prophet Tuned Model Service
|
||||
|
||||
This is the production-tuned Prophet model extracted from the prophet-preview endpoint.
|
||||
Uses the exact same logic as the frontend preview to ensure value consistency.
|
||||
|
||||
Features:
|
||||
- 2 years of training data
|
||||
- Logistic growth with floor/cap
|
||||
- UK holidays + custom special dates
|
||||
- OTB floor capping
|
||||
- Per-date bookable cap adjustments
|
||||
- Metric-specific handling
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
import pandas as pd
|
||||
import warnings
|
||||
from prophet import Prophet
|
||||
from sqlalchemy import text
|
||||
|
||||
from utils.capacity import get_bookable_cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
|
||||
# Metric configuration mapping
|
||||
METRIC_COLUMN_MAP = {
|
||||
'occupancy': ('s.occupancy_pct', False, True),
|
||||
'rooms': ('s.booking_count', False, False),
|
||||
'guests': ('s.guest_count', False, False),
|
||||
'ave_guest_rate': ('s.arr_net', False, False),
|
||||
'arr': ('s.arr_net', False, False),
|
||||
'net_accom': ('r.accommodation', True, False),
|
||||
'net_dry': ('r.dry', True, False),
|
||||
'net_wet': ('r.wet', True, False),
|
||||
'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False),
|
||||
}
|
||||
|
||||
|
||||
def get_metric_query_parts(metric: str) -> tuple:
|
||||
"""
|
||||
Get SQL query parts for a metric.
|
||||
Returns: (column_expr, from_clause, is_percentage)
|
||||
"""
|
||||
if metric not in METRIC_COLUMN_MAP:
|
||||
# Default to rooms if unknown metric
|
||||
metric = 'rooms'
|
||||
|
||||
col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric]
|
||||
|
||||
if needs_revenue:
|
||||
from_clause = """
|
||||
FROM newbook_bookings_stats s
|
||||
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
|
||||
"""
|
||||
else:
|
||||
from_clause = "FROM newbook_bookings_stats s"
|
||||
|
||||
return col_expr, from_clause, is_pct
|
||||
|
||||
|
||||
def get_lead_time_column(lead_days: int) -> str:
|
||||
"""
|
||||
Map lead days to the appropriate column in newbook_booking_pace.
|
||||
"""
|
||||
if lead_days <= 0:
|
||||
return "d0"
|
||||
elif lead_days <= 30:
|
||||
return f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
# Weekly intervals - find nearest column
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
for col in weekly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d177"
|
||||
else:
|
||||
# Monthly intervals
|
||||
monthly_cols = [210, 240, 270, 300, 330, 365]
|
||||
for col in monthly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d365"
|
||||
|
||||
|
||||
async def run_prophet_tuned_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
perception_date: Optional[date] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Generate Prophet forecast using production-tuned model.
|
||||
|
||||
This uses the exact same logic as the prophet-preview endpoint to ensure
|
||||
backend snapshots match frontend preview values.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast (e.g., 'hotel_occupancy_pct', 'hotel_room_nights')
|
||||
start_date: Start date for forecast
|
||||
end_date: End date for forecast
|
||||
perception_date: Optional date to generate forecast as-of (for backtesting)
|
||||
|
||||
Returns:
|
||||
List of forecast dicts with forecast_date and predicted_value
|
||||
"""
|
||||
logger.info(f"Running Prophet tuned forecast for {metric_code}: {start_date} to {end_date}")
|
||||
|
||||
# Map metric codes to preview endpoint metric names
|
||||
metric_map = {
|
||||
'hotel_occupancy_pct': 'occupancy',
|
||||
'hotel_room_nights': 'rooms',
|
||||
'hotel_guests': 'guests',
|
||||
'hotel_arr': 'arr',
|
||||
'ave_guest_rate': 'ave_guest_rate',
|
||||
'net_accom': 'net_accom',
|
||||
'net_dry': 'net_dry',
|
||||
'net_wet': 'net_wet',
|
||||
'total_rev': 'total_rev',
|
||||
}
|
||||
|
||||
metric = metric_map.get(metric_code, 'rooms')
|
||||
|
||||
# Use perception_date if provided, otherwise use actual today
|
||||
today = perception_date if perception_date else date.today()
|
||||
|
||||
# Get default bookable cap
|
||||
default_bookable_cap = await get_bookable_cap(db)
|
||||
|
||||
# Get metric column and query parts
|
||||
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
|
||||
|
||||
# Get historical data for Prophet training (past 2 years)
|
||||
history_start = today - timedelta(days=730)
|
||||
history_query = f"""
|
||||
SELECT s.date as ds, {col_expr} as y
|
||||
{from_clause}
|
||||
WHERE s.date >= :history_start
|
||||
AND s.date < :today
|
||||
AND {col_expr} IS NOT NULL
|
||||
ORDER BY s.date
|
||||
"""
|
||||
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
|
||||
history_rows = history_result.fetchall()
|
||||
|
||||
if len(history_rows) < 30:
|
||||
logger.warning(f"Insufficient historical data for Prophet model: {len(history_rows)} rows")
|
||||
return []
|
||||
|
||||
# Build training dataframe
|
||||
df = pd.DataFrame([{"ds": row.ds, "y": float(row.y) if row.y is not None else 0} for row in history_rows])
|
||||
|
||||
# Set floor/cap based on metric type
|
||||
if is_pct_metric:
|
||||
# Percentage metrics (occupancy)
|
||||
training_cap = 100
|
||||
elif metric == 'rooms':
|
||||
# Room counts - cap at bookable rooms
|
||||
training_cap = default_bookable_cap
|
||||
elif metric == 'guests':
|
||||
# Guests can exceed rooms (multiple per room) - use historical max * 1.5
|
||||
training_cap = df["y"].max() * 1.5 if len(df) > 0 and df["y"].max() > 0 else default_bookable_cap * 3
|
||||
else:
|
||||
# Revenue/rate metrics - use percentile-based cap
|
||||
training_cap = df["y"].quantile(0.99) * 1.5 if len(df) > 0 and df["y"].quantile(0.99) > 0 else 10000
|
||||
|
||||
df["floor"] = 0
|
||||
df["cap"] = training_cap
|
||||
|
||||
# Train Prophet model with logistic growth (respects floor/cap)
|
||||
model = Prophet(
|
||||
growth='logistic',
|
||||
yearly_seasonality=True,
|
||||
weekly_seasonality=True,
|
||||
daily_seasonality=False,
|
||||
interval_width=0.8,
|
||||
changepoint_prior_scale=0.05
|
||||
)
|
||||
|
||||
# Add UK holidays
|
||||
model.add_country_holidays(country_name='UK')
|
||||
|
||||
# Add custom special dates from settings
|
||||
try:
|
||||
from api.special_dates import get_special_dates_for_prophet
|
||||
# Get special dates for training period + forecast period
|
||||
min_year = history_start.year
|
||||
max_year = end_date.year + 1
|
||||
custom_holidays = await get_special_dates_for_prophet(db, min_year, max_year)
|
||||
|
||||
if custom_holidays:
|
||||
# Create holidays dataframe for Prophet
|
||||
holidays_df = pd.DataFrame(custom_holidays)
|
||||
# Group by holiday name and add lower/upper windows
|
||||
for holiday_name in holidays_df['holiday'].unique():
|
||||
holiday_dates = holidays_df[holidays_df['holiday'] == holiday_name][['ds', 'holiday']]
|
||||
holiday_dates = holiday_dates.copy()
|
||||
holiday_dates['lower_window'] = 0
|
||||
holiday_dates['upper_window'] = 0
|
||||
model.holidays = pd.concat([model.holidays, holiday_dates]) if model.holidays is not None else holiday_dates
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load special dates for Prophet: {e}")
|
||||
|
||||
model.fit(df)
|
||||
|
||||
# Create future dataframe for forecast period
|
||||
future_dates = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
if (current_date - today).days >= 0:
|
||||
future_dates.append({"ds": current_date})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
if not future_dates:
|
||||
logger.warning("No future dates to forecast")
|
||||
return []
|
||||
|
||||
future_df = pd.DataFrame(future_dates)
|
||||
|
||||
# Add floor/cap for logistic growth predictions (must match training cap)
|
||||
future_df["floor"] = 0
|
||||
future_df["cap"] = training_cap
|
||||
|
||||
forecast = model.predict(future_df)
|
||||
|
||||
# Process forecast results
|
||||
forecasts = []
|
||||
is_room_based = metric in ('occupancy', 'rooms')
|
||||
|
||||
for _, row in forecast.iterrows():
|
||||
forecast_date = row["ds"].date()
|
||||
lead_days = (forecast_date - today).days
|
||||
lead_col = get_lead_time_column(lead_days)
|
||||
|
||||
# Get current OTB (only for room-based metrics)
|
||||
current_otb = None
|
||||
if is_room_based:
|
||||
current_query = text("""
|
||||
SELECT booking_count as current_otb
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :arrival_date
|
||||
""")
|
||||
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
|
||||
current_row = current_result.fetchone()
|
||||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||||
|
||||
# Get per-date bookable cap for room-based metrics
|
||||
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
|
||||
|
||||
# Convert to occupancy if needed
|
||||
if metric == "occupancy" and date_bookable_cap > 0:
|
||||
if current_otb is not None:
|
||||
current_otb = (current_otb / date_bookable_cap) * 100
|
||||
|
||||
# Get Prophet forecast values
|
||||
yhat = row["yhat"]
|
||||
|
||||
# Cap at max capacity based on metric type (uses per-date bookable cap)
|
||||
if is_pct_metric:
|
||||
yhat = min(yhat, 100.0)
|
||||
elif metric == 'rooms':
|
||||
yhat = min(yhat, float(date_bookable_cap))
|
||||
# Guests and revenue/rate metrics don't have a hard cap
|
||||
|
||||
# Floor forecast to current OTB if we have it (room-based metrics only)
|
||||
# But never exceed the bookable capacity (e.g., closed/maintenance periods)
|
||||
if is_room_based and current_otb is not None and yhat < current_otb:
|
||||
yhat = min(current_otb, float(date_bookable_cap))
|
||||
|
||||
forecasts.append({
|
||||
'forecast_date': forecast_date,
|
||||
'predicted_value': round(yhat, 2)
|
||||
})
|
||||
|
||||
logger.info(f"Prophet tuned generated {len(forecasts)} forecasts for {metric_code}")
|
||||
return forecasts
|
||||
300
backend/services/forecasting/xgboost_model.py
Normal file
300
backend/services/forecasting/xgboost_model.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""
|
||||
XGBoost forecasting model
|
||||
Gradient boosting with feature engineering and SHAP explainability
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import json
|
||||
from sqlalchemy import text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_features(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Create features for XGBoost model
|
||||
|
||||
Features include:
|
||||
- Day of week (0-6)
|
||||
- Month (1-12)
|
||||
- Day of month
|
||||
- Week of year
|
||||
- Is weekend
|
||||
- Is holiday (would need holiday calendar)
|
||||
- Lag features (7, 14, 28 days)
|
||||
- Rolling averages (7, 14, 28 days)
|
||||
"""
|
||||
df = df.copy()
|
||||
|
||||
# Date features
|
||||
df['day_of_week'] = df['ds'].dt.dayofweek
|
||||
df['month'] = df['ds'].dt.month
|
||||
df['day_of_month'] = df['ds'].dt.day
|
||||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||||
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
|
||||
|
||||
# Cyclical encoding for day of week
|
||||
df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7)
|
||||
df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7)
|
||||
|
||||
# Cyclical encoding for month
|
||||
df['month_sin'] = np.sin(2 * np.pi * df['month'] / 12)
|
||||
df['month_cos'] = np.cos(2 * np.pi * df['month'] / 12)
|
||||
|
||||
# Lag features
|
||||
for lag in [7, 14, 21, 28]:
|
||||
df[f'lag_{lag}'] = df['y'].shift(lag)
|
||||
|
||||
# Rolling averages
|
||||
for window in [7, 14, 28]:
|
||||
df[f'rolling_mean_{window}'] = df['y'].rolling(window=window, min_periods=1).mean()
|
||||
df[f'rolling_std_{window}'] = df['y'].rolling(window=window, min_periods=1).std()
|
||||
|
||||
# Year-over-year feature: uses 365 days (same calendar date, not DOW-aligned)
|
||||
# This is intentional for ML: captures date-specific patterns like holidays
|
||||
# Combined with day_of_week features, the model learns both patterns
|
||||
# Note: For direct comparisons (pickup model), use 364 days for DOW alignment
|
||||
if len(df) > 365:
|
||||
df['lag_365'] = df['y'].shift(365)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
async def run_xgboost_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
forecast_from: date,
|
||||
forecast_to: date,
|
||||
training_days: int = 2555 # ~7 years - use all available history
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Run XGBoost forecast for a metric
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast
|
||||
forecast_from: Start date for forecasts
|
||||
forecast_to: End date for forecasts
|
||||
training_days: Days of historical data to use
|
||||
|
||||
Returns:
|
||||
List of forecast records
|
||||
"""
|
||||
try:
|
||||
import xgboost as xgb
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
# Get historical data
|
||||
training_from = forecast_from - timedelta(days=training_days + 60) # Extra for lag features
|
||||
|
||||
# Revenue metrics use earned_revenue_data joined with gl_accounts
|
||||
revenue_metrics = ['net_accom', 'net_dry', 'net_wet', 'total_rev']
|
||||
if metric_code in revenue_metrics:
|
||||
revenue_departments = {
|
||||
'net_accom': 'accommodation',
|
||||
'net_dry': 'dry',
|
||||
'net_wet': 'wet',
|
||||
'total_rev': None, # All departments
|
||||
}
|
||||
department = revenue_departments.get(metric_code)
|
||||
if department is None and metric_code != 'total_rev':
|
||||
logger.warning(f"Unknown revenue metric for XGBoost: {metric_code}")
|
||||
return []
|
||||
|
||||
if metric_code == 'total_rev':
|
||||
# Total revenue across all departments
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT date, SUM(amount_net) as actual_value
|
||||
FROM newbook_earned_revenue_data
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
GROUP BY date
|
||||
HAVING SUM(amount_net) IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
|
||||
)
|
||||
else:
|
||||
# Revenue by department
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT r.date, SUM(r.amount_net) as actual_value
|
||||
FROM newbook_earned_revenue_data r
|
||||
JOIN newbook_gl_accounts g ON r.gl_account_id = g.gl_account_id
|
||||
WHERE r.date BETWEEN :from_date AND :to_date
|
||||
AND g.department = :department
|
||||
GROUP BY r.date
|
||||
HAVING SUM(r.amount_net) IS NOT NULL
|
||||
ORDER BY r.date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1), "department": department}
|
||||
)
|
||||
else:
|
||||
# Hotel metrics use newbook_bookings_stats table
|
||||
metric_column_map = {
|
||||
'hotel_occupancy_pct': 'total_occupancy_pct',
|
||||
'hotel_room_nights': 'booking_count',
|
||||
'hotel_guests': 'guests_count',
|
||||
}
|
||||
|
||||
column_name = metric_column_map.get(metric_code)
|
||||
if not column_name:
|
||||
logger.warning(f"Unknown metric_code for XGBoost: {metric_code}")
|
||||
return []
|
||||
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
SELECT date, {column_name} as actual_value
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
AND {column_name} IS NOT NULL
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"from_date": training_from, "to_date": forecast_from - timedelta(days=1)}
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
if len(rows) < 60:
|
||||
logger.warning(f"Insufficient data for XGBoost forecast: {metric_code} has {len(rows)} records")
|
||||
return []
|
||||
|
||||
# Prepare data
|
||||
df = pd.DataFrame([{"ds": pd.Timestamp(row.date), "y": float(row.actual_value)} for row in rows])
|
||||
df = df.sort_values('ds').reset_index(drop=True)
|
||||
|
||||
# Create features
|
||||
df = create_features(df)
|
||||
|
||||
# Remove rows with NaN from lag features
|
||||
df = df.dropna()
|
||||
|
||||
# Define feature columns
|
||||
feature_cols = [
|
||||
'day_of_week', 'month', 'day_of_month', 'week_of_year', 'is_weekend',
|
||||
'dow_sin', 'dow_cos', 'month_sin', 'month_cos',
|
||||
'lag_7', 'lag_14', 'lag_21', 'lag_28',
|
||||
'rolling_mean_7', 'rolling_mean_14', 'rolling_mean_28',
|
||||
'rolling_std_7', 'rolling_std_14', 'rolling_std_28'
|
||||
]
|
||||
|
||||
# Add lag_365 if available
|
||||
if 'lag_365' in df.columns and df['lag_365'].notna().sum() > 30:
|
||||
feature_cols.append('lag_365')
|
||||
|
||||
X = df[feature_cols]
|
||||
y = df['y']
|
||||
|
||||
# Train model
|
||||
model = xgb.XGBRegressor(
|
||||
n_estimators=100,
|
||||
max_depth=5,
|
||||
learning_rate=0.1,
|
||||
objective='reg:squarederror',
|
||||
random_state=42
|
||||
)
|
||||
model.fit(X, y)
|
||||
|
||||
# Generate forecasts
|
||||
forecasts = []
|
||||
current_df = df.copy()
|
||||
|
||||
for forecast_date in pd.date_range(start=forecast_from, end=forecast_to, freq='D'):
|
||||
# Create row for forecast date
|
||||
new_row = pd.DataFrame([{"ds": forecast_date, "y": np.nan}])
|
||||
current_df = pd.concat([current_df, new_row], ignore_index=True)
|
||||
current_df = create_features(current_df)
|
||||
|
||||
# Get features for prediction
|
||||
X_pred = current_df[feature_cols].iloc[-1:].ffill()
|
||||
|
||||
# Make prediction
|
||||
prediction = float(model.predict(X_pred)[0])
|
||||
|
||||
# Update y value for lag features
|
||||
current_df.iloc[-1, current_df.columns.get_loc('y')] = prediction
|
||||
|
||||
forecast_record = {
|
||||
"forecast_date": forecast_date.date(),
|
||||
"forecast_type": metric_code,
|
||||
"model_type": "xgboost",
|
||||
"predicted_value": round(float(prediction), 2)
|
||||
}
|
||||
forecasts.append(forecast_record)
|
||||
|
||||
# Store in database
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO forecasts (
|
||||
forecast_date, forecast_type, model_type, predicted_value, generated_at
|
||||
) VALUES (
|
||||
:forecast_date, :forecast_type, :model_type, :predicted_value, NOW()
|
||||
)
|
||||
"""),
|
||||
forecast_record
|
||||
)
|
||||
|
||||
# Commit forecasts before SHAP calculations
|
||||
db.commit()
|
||||
|
||||
# Calculate SHAP values for explainability
|
||||
try:
|
||||
import shap
|
||||
explainer = shap.TreeExplainer(model)
|
||||
|
||||
# Get SHAP values for last few predictions
|
||||
for i, forecast_date in enumerate(pd.date_range(start=forecast_from, end=min(forecast_from + timedelta(days=7), forecast_to), freq='D')):
|
||||
idx = len(df) + i
|
||||
X_explain = current_df[feature_cols].iloc[idx:idx+1].ffill()
|
||||
shap_values = explainer.shap_values(X_explain)
|
||||
|
||||
# Store SHAP explanation
|
||||
feature_contributions = dict(zip(feature_cols, shap_values[0].tolist()))
|
||||
top_positive = sorted(
|
||||
[{"feature": k, "contribution": v} for k, v in feature_contributions.items() if v > 0],
|
||||
key=lambda x: x["contribution"], reverse=True
|
||||
)[:5]
|
||||
top_negative = sorted(
|
||||
[{"feature": k, "contribution": v} for k, v in feature_contributions.items() if v < 0],
|
||||
key=lambda x: x["contribution"]
|
||||
)[:3]
|
||||
|
||||
try:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO xgboost_explanations (
|
||||
forecast_date, forecast_type, base_value,
|
||||
feature_values, shap_values, top_positive, top_negative, generated_at
|
||||
) VALUES (
|
||||
:date, :metric, :base_value, :feature_values,
|
||||
:shap_values, :top_positive, :top_negative, NOW()
|
||||
)
|
||||
"""),
|
||||
{
|
||||
"date": forecast_date.date(),
|
||||
"metric": metric_code,
|
||||
"base_value": float(explainer.expected_value),
|
||||
"feature_values": json.dumps(X_explain.iloc[0].to_dict()),
|
||||
"shap_values": json.dumps(feature_contributions),
|
||||
"top_positive": json.dumps(top_positive),
|
||||
"top_negative": json.dumps(top_negative)
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass # Skip if conflict, explanations are supplementary
|
||||
except Exception as e:
|
||||
logger.warning(f"SHAP calculation failed: {e}")
|
||||
|
||||
db.commit()
|
||||
logger.info(f"XGBoost forecast generated for {metric_code}: {len(forecasts)} records")
|
||||
return forecasts
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"Required package not installed: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"XGBoost forecast failed for {metric_code}: {e}")
|
||||
return []
|
||||
446
backend/services/forecasting/xgboost_tuned.py
Normal file
446
backend/services/forecasting/xgboost_tuned.py
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
"""
|
||||
XGBoost Tuned Model Service
|
||||
|
||||
This is the production-tuned XGBoost model extracted from the xgboost-preview endpoint.
|
||||
Uses the exact same logic as the frontend preview to ensure value consistency.
|
||||
|
||||
Features:
|
||||
- 2 years of training data
|
||||
- Pace features (OTB at different lead times) for room-based metrics
|
||||
- Time-based features (day of week, month, week, weekend, special dates)
|
||||
- Lag features from prior year same DOW
|
||||
- OTB floor capping
|
||||
- Per-date bookable cap adjustments
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from xgboost import XGBRegressor
|
||||
import warnings
|
||||
from sqlalchemy import text
|
||||
|
||||
from utils.capacity import get_bookable_cap
|
||||
from api.special_dates import resolve_special_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
|
||||
# Metric configuration mapping
|
||||
METRIC_COLUMN_MAP = {
|
||||
'occupancy': ('s.occupancy_pct', False, True),
|
||||
'rooms': ('s.booking_count', False, False),
|
||||
'guests': ('s.guest_count', False, False),
|
||||
'ave_guest_rate': ('s.arr_net', False, False),
|
||||
'arr': ('s.arr_net', False, False),
|
||||
'net_accom': ('r.accommodation', True, False),
|
||||
'net_dry': ('r.dry', True, False),
|
||||
'net_wet': ('r.wet', True, False),
|
||||
'total_rev': ('(COALESCE(r.accommodation, 0) + COALESCE(r.dry, 0) + COALESCE(r.wet, 0))', True, False),
|
||||
}
|
||||
|
||||
|
||||
def get_metric_query_parts(metric: str) -> tuple:
|
||||
"""Get SQL query parts for a metric. Returns: (column_expr, from_clause, is_percentage)"""
|
||||
if metric not in METRIC_COLUMN_MAP:
|
||||
metric = 'rooms'
|
||||
col_expr, needs_revenue, is_pct = METRIC_COLUMN_MAP[metric]
|
||||
if needs_revenue:
|
||||
from_clause = """
|
||||
FROM newbook_bookings_stats s
|
||||
LEFT JOIN newbook_net_revenue_data r ON s.date = r.date
|
||||
"""
|
||||
else:
|
||||
from_clause = "FROM newbook_bookings_stats s"
|
||||
return col_expr, from_clause, is_pct
|
||||
|
||||
|
||||
def get_lead_time_column(lead_days: int) -> str:
|
||||
"""Map lead days to the appropriate column in newbook_booking_pace."""
|
||||
if lead_days <= 0:
|
||||
return "d0"
|
||||
elif lead_days <= 30:
|
||||
return f"d{lead_days}"
|
||||
elif lead_days <= 177:
|
||||
weekly_cols = [37, 44, 51, 58, 65, 72, 79, 86, 93, 100, 107, 114, 121, 128, 135, 142, 149, 156, 163, 170, 177]
|
||||
for col in weekly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d177"
|
||||
else:
|
||||
monthly_cols = [210, 240, 270, 300, 330, 365]
|
||||
for col in monthly_cols:
|
||||
if lead_days <= col:
|
||||
return f"d{col}"
|
||||
return "d365"
|
||||
|
||||
|
||||
def round_towards_reference(value: float, reference: Optional[float]) -> int:
|
||||
"""Round a forecast value towards a reference value (prior year actual)."""
|
||||
if reference is None:
|
||||
return round(value)
|
||||
if value < reference:
|
||||
return int(np.ceil(value))
|
||||
else:
|
||||
return int(np.floor(value))
|
||||
|
||||
|
||||
async def run_xgboost_tuned_forecast(
|
||||
db,
|
||||
metric_code: str,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
perception_date: Optional[date] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Generate XGBoost forecast using production-tuned model.
|
||||
|
||||
This uses the exact same logic as the xgboost-preview endpoint to ensure
|
||||
backend snapshots match frontend preview values.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
metric_code: Metric to forecast
|
||||
start_date: Start date for forecast
|
||||
end_date: End date for forecast
|
||||
perception_date: Optional date to generate forecast as-of (for backtesting)
|
||||
|
||||
Returns:
|
||||
List of forecast dicts with forecast_date and predicted_value
|
||||
"""
|
||||
logger.info(f"Running XGBoost tuned forecast for {metric_code}: {start_date} to {end_date}")
|
||||
|
||||
# Map metric codes to preview endpoint metric names
|
||||
metric_map = {
|
||||
'hotel_occupancy_pct': 'occupancy',
|
||||
'hotel_room_nights': 'rooms',
|
||||
'hotel_guests': 'guests',
|
||||
'hotel_arr': 'arr',
|
||||
'ave_guest_rate': 'ave_guest_rate',
|
||||
'net_accom': 'net_accom',
|
||||
'net_dry': 'net_dry',
|
||||
'net_wet': 'net_wet',
|
||||
'total_rev': 'total_rev',
|
||||
}
|
||||
|
||||
metric = metric_map.get(metric_code, 'rooms')
|
||||
|
||||
# Use perception_date if provided, otherwise use actual today
|
||||
today = perception_date if perception_date else date.today()
|
||||
|
||||
# Get default bookable cap
|
||||
default_bookable_cap = await get_bookable_cap(db)
|
||||
|
||||
# Get metric column and query parts
|
||||
col_expr, from_clause, is_pct_metric = get_metric_query_parts(metric)
|
||||
is_room_based = metric in ('occupancy', 'rooms')
|
||||
|
||||
# Get historical data for XGBoost training (past 2 years)
|
||||
history_start = today - timedelta(days=730)
|
||||
|
||||
# Lead times to train on (key intervals) - only used for room-based metrics
|
||||
train_lead_times = [0, 1, 3, 7, 14, 21, 28, 30]
|
||||
|
||||
# Get final values (and pace data for room-based metrics)
|
||||
if is_room_based:
|
||||
history_result = await db.execute(text("""
|
||||
SELECT s.date as ds, s.booking_count as final,
|
||||
p.d0, p.d1, p.d3, p.d7, p.d14, p.d21, p.d28, p.d30
|
||||
FROM newbook_bookings_stats s
|
||||
LEFT JOIN newbook_booking_pace p ON s.date = p.arrival_date
|
||||
WHERE s.date >= :history_start
|
||||
AND s.date < :today
|
||||
AND s.booking_count IS NOT NULL
|
||||
ORDER BY s.date
|
||||
"""), {"history_start": history_start, "today": today})
|
||||
else:
|
||||
# Non-room metrics: get values without pace join
|
||||
history_query = f"""
|
||||
SELECT s.date as ds, {col_expr} as final
|
||||
{from_clause}
|
||||
WHERE s.date >= :history_start
|
||||
AND s.date < :today
|
||||
AND {col_expr} IS NOT NULL
|
||||
ORDER BY s.date
|
||||
"""
|
||||
history_result = await db.execute(text(history_query), {"history_start": history_start, "today": today})
|
||||
|
||||
history_rows = history_result.fetchall()
|
||||
|
||||
if len(history_rows) < 30:
|
||||
logger.warning(f"Insufficient historical data for XGBoost model: {len(history_rows)} rows")
|
||||
return []
|
||||
|
||||
# Load special dates for feature
|
||||
special_date_set = set()
|
||||
try:
|
||||
special_dates_result = await db.execute(text(
|
||||
"SELECT * FROM special_dates WHERE is_active = TRUE"
|
||||
))
|
||||
special_dates_rows = special_dates_result.fetchall()
|
||||
years_needed = set(r.ds.year for r in history_rows) | {today.year, today.year + 1}
|
||||
for row in special_dates_rows:
|
||||
sd = {
|
||||
'pattern_type': row.pattern_type,
|
||||
'fixed_month': row.fixed_month,
|
||||
'fixed_day': row.fixed_day,
|
||||
'nth_week': row.nth_week,
|
||||
'weekday': row.weekday,
|
||||
'month': row.month,
|
||||
'relative_to_month': row.relative_to_month,
|
||||
'relative_to_day': row.relative_to_day,
|
||||
'relative_weekday': row.relative_weekday,
|
||||
'relative_direction': row.relative_direction,
|
||||
'duration_days': row.duration_days,
|
||||
'is_recurring': row.is_recurring,
|
||||
'one_off_year': row.one_off_year
|
||||
}
|
||||
for year in years_needed:
|
||||
resolved_dates = resolve_special_date(sd, year)
|
||||
for d in resolved_dates:
|
||||
special_date_set.add(d)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load special dates: {e}")
|
||||
|
||||
# Build lookup dicts
|
||||
final_by_date = {}
|
||||
pace_by_date = {}
|
||||
for row in history_rows:
|
||||
final_by_date[row.ds] = row.final
|
||||
if is_room_based and hasattr(row, 'd0'):
|
||||
pace_by_date[row.ds] = {
|
||||
0: row.d0, 1: row.d1, 3: row.d3, 7: row.d7,
|
||||
14: row.d14, 21: row.d21, 28: row.d28, 30: row.d30
|
||||
}
|
||||
|
||||
# Build training examples
|
||||
training_rows = []
|
||||
|
||||
if is_room_based:
|
||||
# Room-based metrics: use pace features (one per date,lead_time combo)
|
||||
for row in history_rows:
|
||||
ds = row.ds
|
||||
final = float(row.final) if row.final else 0
|
||||
prior_ds = ds - timedelta(days=364)
|
||||
|
||||
prior_final = final_by_date.get(prior_ds)
|
||||
if prior_final is None:
|
||||
continue
|
||||
|
||||
for lead_time in train_lead_times:
|
||||
current_otb = pace_by_date.get(ds, {}).get(lead_time)
|
||||
if current_otb is None:
|
||||
continue
|
||||
|
||||
prior_otb = pace_by_date.get(prior_ds, {}).get(lead_time)
|
||||
if prior_otb is None:
|
||||
prior_otb = 0
|
||||
|
||||
otb_pct_of_prior_final = (float(current_otb) / float(prior_final) * 100) if prior_final > 0 else 0
|
||||
|
||||
training_rows.append({
|
||||
'ds': ds,
|
||||
'y': final,
|
||||
'days_out': lead_time,
|
||||
'current_otb': float(current_otb),
|
||||
'prior_otb_same_lead': float(prior_otb),
|
||||
'lag_364': float(prior_final),
|
||||
'otb_pct_of_prior_final': otb_pct_of_prior_final
|
||||
})
|
||||
else:
|
||||
# Non-room metrics: use time features only (one per date)
|
||||
for row in history_rows:
|
||||
ds = row.ds
|
||||
final = float(row.final) if row.final else 0
|
||||
prior_ds = ds - timedelta(days=364)
|
||||
|
||||
prior_final = final_by_date.get(prior_ds)
|
||||
if prior_final is None:
|
||||
prior_final = 0 # Allow training even without prior year for revenue metrics
|
||||
|
||||
training_rows.append({
|
||||
'ds': ds,
|
||||
'y': final,
|
||||
'lag_364': float(prior_final) if prior_final else 0
|
||||
})
|
||||
|
||||
if len(training_rows) < 30:
|
||||
logger.warning(f"Insufficient data for XGBoost training: {len(training_rows)} rows")
|
||||
return []
|
||||
|
||||
df = pd.DataFrame(training_rows)
|
||||
df['ds'] = pd.to_datetime(df['ds'])
|
||||
|
||||
# Convert to occupancy if needed
|
||||
if metric == "occupancy" and default_bookable_cap > 0:
|
||||
df["y"] = (df["y"] / default_bookable_cap) * 100
|
||||
if "current_otb" in df.columns:
|
||||
df["current_otb"] = (df["current_otb"] / default_bookable_cap) * 100
|
||||
if "prior_otb_same_lead" in df.columns:
|
||||
df["prior_otb_same_lead"] = (df["prior_otb_same_lead"] / default_bookable_cap) * 100
|
||||
df["lag_364"] = (df["lag_364"] / default_bookable_cap) * 100
|
||||
|
||||
# Create time-based features
|
||||
df['day_of_week'] = df['ds'].dt.dayofweek
|
||||
df['month'] = df['ds'].dt.month
|
||||
df['week_of_year'] = df['ds'].dt.isocalendar().week.astype(int)
|
||||
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
|
||||
df['is_special_date'] = df['ds'].dt.date.apply(lambda x: 1 if x in special_date_set else 0)
|
||||
|
||||
df_train = df.dropna()
|
||||
|
||||
if len(df_train) < 30:
|
||||
logger.warning(f"Insufficient data after creating features: {len(df_train)} rows")
|
||||
return []
|
||||
|
||||
# Define features based on metric type
|
||||
if is_room_based:
|
||||
feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date',
|
||||
'days_out', 'current_otb', 'prior_otb_same_lead', 'lag_364', 'otb_pct_of_prior_final']
|
||||
else:
|
||||
feature_cols = ['day_of_week', 'month', 'week_of_year', 'is_weekend', 'is_special_date', 'lag_364']
|
||||
|
||||
X_train = df_train[feature_cols]
|
||||
y_train = df_train['y']
|
||||
|
||||
# Train XGBoost model
|
||||
model = XGBRegressor(
|
||||
n_estimators=100,
|
||||
max_depth=6,
|
||||
learning_rate=0.1,
|
||||
objective='reg:squarederror',
|
||||
random_state=42,
|
||||
n_jobs=-1
|
||||
)
|
||||
model.fit(X_train, y_train)
|
||||
|
||||
# Create future dataframe for forecast period
|
||||
future_dates = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
if (current_date - today).days >= 0:
|
||||
future_dates.append(current_date)
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
if not future_dates:
|
||||
logger.warning("No future dates to forecast")
|
||||
return []
|
||||
|
||||
# Generate forecasts for each date
|
||||
forecasts = []
|
||||
|
||||
for forecast_date in future_dates:
|
||||
lead_days = (forecast_date - today).days
|
||||
lead_col = get_lead_time_column(lead_days)
|
||||
prior_year_date = forecast_date - timedelta(days=364)
|
||||
|
||||
# Get OTB data only for room-based metrics
|
||||
current_otb = None
|
||||
|
||||
if is_room_based:
|
||||
current_query = text("""
|
||||
SELECT booking_count as current_otb
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date = :arrival_date
|
||||
""")
|
||||
current_result = await db.execute(current_query, {"arrival_date": forecast_date})
|
||||
current_row = current_result.fetchone()
|
||||
current_otb = current_row.current_otb if current_row and current_row.current_otb is not None else 0
|
||||
|
||||
# Get prior year final using metric mapping
|
||||
prior_query = f"""
|
||||
SELECT {col_expr} as prior_final
|
||||
{from_clause}
|
||||
WHERE s.date = :prior_date
|
||||
"""
|
||||
prior_result = await db.execute(text(prior_query), {"prior_date": prior_year_date})
|
||||
prior_row = prior_result.fetchone()
|
||||
prior_final = float(prior_row.prior_final) if prior_row and prior_row.prior_final is not None else 0
|
||||
|
||||
# Get per-date bookable cap for this forecast date
|
||||
date_bookable_cap = await get_bookable_cap(db, forecast_date, default_bookable_cap)
|
||||
|
||||
# Build features for this date
|
||||
forecast_dt = pd.Timestamp(forecast_date)
|
||||
lag_364_val = prior_final if prior_final else 0
|
||||
|
||||
# Convert to occupancy if needed
|
||||
if metric == "occupancy" and date_bookable_cap > 0:
|
||||
if current_otb is not None:
|
||||
current_otb = (current_otb / date_bookable_cap) * 100
|
||||
lag_364_val = (prior_final / date_bookable_cap) * 100 if prior_final else 0
|
||||
|
||||
# Build features based on metric type
|
||||
if is_room_based:
|
||||
# Get prior OTB at same lead time
|
||||
prior_year_for_otb = forecast_date - timedelta(days=364)
|
||||
prior_otb_query = text(f"""
|
||||
SELECT {lead_col} as prior_otb
|
||||
FROM newbook_booking_pace
|
||||
WHERE arrival_date = :prior_date
|
||||
""")
|
||||
prior_otb_result = await db.execute(prior_otb_query, {"prior_date": prior_year_for_otb})
|
||||
prior_otb_row = prior_otb_result.fetchone()
|
||||
prior_otb_same_lead = prior_otb_row.prior_otb if prior_otb_row and prior_otb_row.prior_otb is not None else 0
|
||||
|
||||
if metric == "occupancy" and date_bookable_cap > 0:
|
||||
prior_otb_same_lead = (prior_otb_same_lead / date_bookable_cap) * 100 if prior_otb_same_lead else 0
|
||||
|
||||
current_otb_val = current_otb if current_otb is not None else 0
|
||||
otb_pct_of_prior_final = (current_otb_val / lag_364_val * 100) if lag_364_val > 0 else 0
|
||||
|
||||
features = pd.DataFrame([{
|
||||
'day_of_week': forecast_dt.dayofweek,
|
||||
'month': forecast_dt.month,
|
||||
'week_of_year': forecast_dt.isocalendar().week,
|
||||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||||
'days_out': lead_days,
|
||||
'current_otb': current_otb_val,
|
||||
'prior_otb_same_lead': prior_otb_same_lead,
|
||||
'lag_364': lag_364_val,
|
||||
'otb_pct_of_prior_final': otb_pct_of_prior_final,
|
||||
}])
|
||||
else:
|
||||
features = pd.DataFrame([{
|
||||
'day_of_week': forecast_dt.dayofweek,
|
||||
'month': forecast_dt.month,
|
||||
'week_of_year': forecast_dt.isocalendar().week,
|
||||
'is_weekend': 1 if forecast_dt.dayofweek >= 5 else 0,
|
||||
'is_special_date': 1 if forecast_date in special_date_set else 0,
|
||||
'lag_364': lag_364_val,
|
||||
}])
|
||||
|
||||
# Predict
|
||||
yhat = float(model.predict(features)[0])
|
||||
|
||||
# Cap at max capacity based on metric type (uses per-date bookable cap)
|
||||
if is_pct_metric:
|
||||
yhat = min(max(yhat, 0), 100.0)
|
||||
elif metric == 'rooms':
|
||||
yhat = round(min(max(yhat, 0), float(date_bookable_cap)))
|
||||
elif metric == 'guests':
|
||||
yhat = round(max(yhat, 0))
|
||||
else:
|
||||
# Revenue/rate metrics: just ensure non-negative
|
||||
yhat = max(yhat, 0)
|
||||
|
||||
# Floor forecast to current OTB (room-based only)
|
||||
if is_room_based and current_otb is not None and yhat < current_otb:
|
||||
yhat = current_otb
|
||||
|
||||
# Round based on metric type
|
||||
if metric == "occupancy":
|
||||
yhat = round(yhat, 1)
|
||||
else:
|
||||
yhat = round_towards_reference(yhat, prior_final)
|
||||
|
||||
forecasts.append({
|
||||
'forecast_date': forecast_date,
|
||||
'predicted_value': yhat
|
||||
})
|
||||
|
||||
logger.info(f"XGBoost tuned generated {len(forecasts)} forecasts for {metric_code}")
|
||||
return forecasts
|
||||
443
backend/services/newbook_client.py
Normal file
443
backend/services/newbook_client.py
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
"""
|
||||
Newbook API Client
|
||||
|
||||
CRITICAL: This client is READ-ONLY.
|
||||
Newbook API uses POST for all requests - the "action" parameter determines the operation.
|
||||
This client ONLY uses read actions (bookings_list, site_list, report_*).
|
||||
NO write actions (booking_create, booking_update, booking_cancel, etc.) are used.
|
||||
Data flows ONE WAY: Newbook → Local Database
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NewbookAPIError(Exception):
|
||||
"""Custom exception for Newbook API errors"""
|
||||
pass
|
||||
|
||||
|
||||
class NewbookClient:
|
||||
"""
|
||||
Async client for Newbook REST API
|
||||
|
||||
Rate limiting: ~100 requests/min, using 0.75s delay between requests
|
||||
Pagination: Uses data_offset/data_limit, max 1000 per request
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.newbook.cloud/rest"
|
||||
|
||||
def __init__(self, api_key: str = None, username: str = None, password: str = None, region: str = None):
|
||||
# Use provided credentials or fall back to environment variables
|
||||
self.api_key = api_key or os.getenv("NEWBOOK_API_KEY")
|
||||
self.username = username or os.getenv("NEWBOOK_USERNAME")
|
||||
self.password = password or os.getenv("NEWBOOK_PASSWORD")
|
||||
self.region = region or os.getenv("NEWBOOK_REGION")
|
||||
|
||||
if not all([self.api_key, self.username, self.password, self.region]):
|
||||
logger.warning("Newbook credentials not fully configured")
|
||||
|
||||
def _get_url(self, endpoint: str) -> str:
|
||||
"""Get full URL for an endpoint"""
|
||||
return f"{self.BASE_URL}/{endpoint}"
|
||||
|
||||
@classmethod
|
||||
async def from_db(cls, db):
|
||||
"""Create client with credentials from database"""
|
||||
from api.config import _get_config_value
|
||||
|
||||
api_key = await _get_config_value(db, "newbook_api_key")
|
||||
username = await _get_config_value(db, "newbook_username")
|
||||
password = await _get_config_value(db, "newbook_password")
|
||||
region = await _get_config_value(db, "newbook_region")
|
||||
|
||||
return cls(api_key=api_key, username=username, password=password, region=region)
|
||||
|
||||
async def __aenter__(self):
|
||||
self.client = httpx.AsyncClient(timeout=300.0)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.client.aclose()
|
||||
|
||||
def _get_auth_payload(self) -> dict:
|
||||
"""Get base authentication payload (api_key and region only - username/password go in Basic Auth)"""
|
||||
return {
|
||||
"api_key": self.api_key,
|
||||
"region": self.region
|
||||
}
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Test API connection"""
|
||||
try:
|
||||
payload = self._get_auth_payload()
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("site_list"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Newbook connection test failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_bookings(
|
||||
self,
|
||||
modified_since: Optional[str] = None,
|
||||
modified_until: Optional[str] = None,
|
||||
batch_size: int = 1000
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Fetch all bookings with pagination and rate limiting.
|
||||
|
||||
Uses list_type="all" which returns all bookings (including cancelled).
|
||||
period_from/period_to filter by created/modified timestamp, not stay dates.
|
||||
|
||||
Args:
|
||||
modified_since: ISO timestamp - only bookings created/modified after this
|
||||
modified_until: ISO timestamp - only bookings created/modified before this
|
||||
batch_size: Records per request (max 1000)
|
||||
|
||||
Returns:
|
||||
List of booking objects (all statuses including cancelled)
|
||||
"""
|
||||
all_bookings = []
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
logger.info(f"Fetching Newbook bookings (all): modified_since={modified_since} (offset: {offset})")
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"list_type": "all",
|
||||
"data_offset": offset,
|
||||
"data_limit": batch_size
|
||||
})
|
||||
|
||||
# Add optional timestamp filters
|
||||
if modified_since:
|
||||
payload["period_from"] = modified_since
|
||||
if modified_until:
|
||||
payload["period_to"] = modified_until
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("bookings_list"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Newbook API error {response.status_code}: {response.text}")
|
||||
raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
||||
|
||||
bookings = data.get("data", [])
|
||||
|
||||
if not bookings:
|
||||
break
|
||||
|
||||
all_bookings.extend(bookings)
|
||||
logger.info(f"Fetched {len(bookings)} bookings (offset {offset})")
|
||||
|
||||
# Check if we've got all records
|
||||
total = data.get("data_total", 0)
|
||||
if offset + len(bookings) >= total:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
|
||||
# Rate limiting: 0.75s delay
|
||||
await asyncio.sleep(0.75)
|
||||
|
||||
logger.info(f"Total bookings fetched: {len(all_bookings)}")
|
||||
return all_bookings
|
||||
|
||||
async def get_bookings_by_stay_dates(
|
||||
self,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
list_type: str = "staying",
|
||||
batch_size: int = 1000
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Fetch bookings by stay dates (arrival/departure/staying period).
|
||||
|
||||
Args:
|
||||
from_date: Start date for stay period
|
||||
to_date: End date for stay period
|
||||
list_type: Type of booking list:
|
||||
"staying" - bookings staying during dates (excludes cancelled)
|
||||
"arrived" - arrived during dates (add mode="projected" for expected)
|
||||
"arriving" - expected to arrive before period_to
|
||||
"departed" - departed during dates
|
||||
"departing" - expected to depart during dates
|
||||
"cancelled" - cancelled during dates
|
||||
"placed" - created during dates
|
||||
"no_show" - no shows for dates
|
||||
batch_size: Records per request (max 1000)
|
||||
|
||||
Returns:
|
||||
List of booking objects
|
||||
"""
|
||||
all_bookings = []
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
logger.info(f"Fetching Newbook bookings ({list_type}): {from_date} to {to_date} (offset: {offset})")
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"list_type": list_type,
|
||||
"period_from": from_date.isoformat(),
|
||||
"period_to": to_date.isoformat(),
|
||||
"data_offset": offset,
|
||||
"data_limit": batch_size
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("bookings_list"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Newbook API error {response.status_code}: {response.text}")
|
||||
raise NewbookAPIError(f"Failed to fetch bookings: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
||||
|
||||
bookings = data.get("data", [])
|
||||
|
||||
if not bookings:
|
||||
break
|
||||
|
||||
all_bookings.extend(bookings)
|
||||
logger.info(f"Fetched {len(bookings)} bookings (offset {offset})")
|
||||
|
||||
# Check if we've got all records
|
||||
total = data.get("data_total", 0)
|
||||
if offset + len(bookings) >= total:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
|
||||
# Rate limiting: 0.75s delay
|
||||
await asyncio.sleep(0.75)
|
||||
|
||||
logger.info(f"Total bookings fetched: {len(all_bookings)}")
|
||||
return all_bookings
|
||||
|
||||
async def get_occupancy_report(
|
||||
self,
|
||||
from_date: date,
|
||||
to_date: date
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Fetch occupancy report for date range.
|
||||
|
||||
Uses reports_occupancy endpoint which returns data by room category.
|
||||
Returns all categories with nested occupancy data for each date in range.
|
||||
No pagination needed - API returns full dataset in single response.
|
||||
|
||||
Response format:
|
||||
[
|
||||
{
|
||||
"category_id": "1",
|
||||
"category_name": "Single Room",
|
||||
"occupancy": {
|
||||
"2024-08-01": {
|
||||
"date": "2024-08-01",
|
||||
"available": 5,
|
||||
"occupied": 3,
|
||||
"maintenance": 1,
|
||||
"allotted": 0,
|
||||
"revenue_gross": 450.00,
|
||||
"revenue_net": 375.00
|
||||
},
|
||||
...
|
||||
}
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Returns list of category objects with nested occupancy by date
|
||||
"""
|
||||
logger.info(f"Fetching occupancy report: {from_date} to {to_date}")
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"period_from": f"{from_date.isoformat()} 00:00:00",
|
||||
"period_to": f"{to_date.isoformat()} 23:59:59"
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("reports_occupancy"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookAPIError(f"Failed to fetch occupancy: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
||||
|
||||
records = data.get("data", [])
|
||||
logger.info(f"Fetched occupancy report: {len(records)} categories")
|
||||
return records
|
||||
|
||||
async def get_site_list(self) -> List[dict]:
|
||||
"""Fetch list of rooms/sites with categories"""
|
||||
payload = self._get_auth_payload()
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("site_list"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookAPIError(f"Failed to fetch site list: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
return data.get("data", [])
|
||||
|
||||
async def get_earned_revenue(
|
||||
self,
|
||||
from_date: date,
|
||||
to_date: date
|
||||
) -> dict:
|
||||
"""
|
||||
Fetch earned revenue report day by day
|
||||
|
||||
Returns dict keyed by date with revenue breakdown by GL code
|
||||
"""
|
||||
all_revenue = {}
|
||||
current_date = from_date
|
||||
|
||||
while current_date <= to_date:
|
||||
logger.info(f"Fetching earned revenue for {current_date}")
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"period_from": current_date.isoformat(),
|
||||
"period_to": current_date.isoformat()
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("reports_earned_revenue"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("success"):
|
||||
day_data = data.get("data", {})
|
||||
# Debug: log first day's response structure
|
||||
if current_date == from_date:
|
||||
import json
|
||||
logger.info(f"Sample earned revenue response: {json.dumps(day_data)[:500]}")
|
||||
all_revenue[current_date.isoformat()] = day_data
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Rate limiting
|
||||
await asyncio.sleep(0.75)
|
||||
|
||||
return all_revenue
|
||||
|
||||
async def get_transaction_flow(
|
||||
self,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
batch_size: int = 5000
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Fetch transaction flow report for date range.
|
||||
Used by reconciliation module for payment categorization.
|
||||
|
||||
Returns raw transaction records (payments, refunds, voided items).
|
||||
Excludes balance_transfer items.
|
||||
Handles pagination via data_offset/data_limit.
|
||||
"""
|
||||
logger.info(f"Fetching transaction flow: {from_date} to {to_date}")
|
||||
|
||||
all_transactions = []
|
||||
offset = 0
|
||||
|
||||
while True:
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"period_from": f"{from_date.isoformat()} 00:00:00",
|
||||
"period_to": f"{to_date.isoformat()} 23:59:59",
|
||||
"data_offset": offset,
|
||||
"data_limit": batch_size
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("reports_transaction_flow"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookAPIError(f"Failed to fetch transaction flow: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
if not data.get("success"):
|
||||
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
||||
|
||||
records = data.get("data", [])
|
||||
all_transactions.extend(records)
|
||||
|
||||
# If we got fewer records than the limit, we're done
|
||||
if len(records) < batch_size:
|
||||
break
|
||||
|
||||
offset += batch_size
|
||||
await asyncio.sleep(0.75)
|
||||
|
||||
logger.info(f"Fetched transaction flow: {len(all_transactions)} transactions")
|
||||
return all_transactions
|
||||
|
||||
async def get_gl_account_list(self) -> List[dict]:
|
||||
"""
|
||||
Fetch GL account list from Newbook.
|
||||
Used for reconciliation sales breakdown column configuration.
|
||||
"""
|
||||
logger.info("Fetching GL account list from Newbook")
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("gl_account_list"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookAPIError(f"Failed to fetch GL accounts: {response.status_code}")
|
||||
|
||||
data = response.json()
|
||||
if not data.get("success"):
|
||||
raise NewbookAPIError(f"Newbook API returned failure: {data.get('message')}")
|
||||
|
||||
records = data.get("data", [])
|
||||
logger.info(f"Fetched {len(records)} GL accounts")
|
||||
return records
|
||||
855
backend/services/newbook_rates_client.py
Normal file
855
backend/services/newbook_rates_client.py
Normal file
|
|
@ -0,0 +1,855 @@
|
|||
"""
|
||||
Newbook Rates Client
|
||||
|
||||
Fetches current rack rates from Newbook API for revenue forecasting.
|
||||
Uses the bookings_availability_pricing endpoint to simulate booking requests.
|
||||
|
||||
This client is READ-ONLY - it only queries available rates, never creates bookings.
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NewbookRatesError(Exception):
|
||||
"""Custom exception for Newbook rates API errors"""
|
||||
pass
|
||||
|
||||
|
||||
class NewbookRatesClient:
|
||||
"""
|
||||
Async client for fetching current rates from Newbook API.
|
||||
|
||||
Uses bookings_availability_pricing endpoint which simulates a booking request.
|
||||
Handles minimum stay restrictions by extending the stay period when needed.
|
||||
|
||||
Rate limiting: ~100 requests/min, using 0.75s delay between requests
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.newbook.cloud/rest"
|
||||
|
||||
def __init__(self, api_key: str = None, username: str = None, password: str = None,
|
||||
region: str = None, vat_rate: Decimal = Decimal('0.20')):
|
||||
self.api_key = api_key or os.getenv("NEWBOOK_API_KEY")
|
||||
self.username = username or os.getenv("NEWBOOK_USERNAME")
|
||||
self.password = password or os.getenv("NEWBOOK_PASSWORD")
|
||||
self.region = region or os.getenv("NEWBOOK_REGION")
|
||||
self.vat_rate = vat_rate
|
||||
|
||||
if not all([self.api_key, self.username, self.password, self.region]):
|
||||
logger.warning("Newbook credentials not fully configured")
|
||||
|
||||
def _get_url(self, endpoint: str) -> str:
|
||||
"""Get full URL for an endpoint"""
|
||||
return f"{self.BASE_URL}/{endpoint}"
|
||||
|
||||
@classmethod
|
||||
async def from_db(cls, db):
|
||||
"""Create client with credentials and VAT rate from database"""
|
||||
from sqlalchemy import text
|
||||
|
||||
# Get credentials from config
|
||||
result = await db.execute(
|
||||
text("SELECT config_key, config_value FROM system_config WHERE config_key IN ('newbook_api_key', 'newbook_username', 'newbook_password', 'newbook_region', 'accommodation_vat_rate')")
|
||||
)
|
||||
rows = result.fetchall()
|
||||
config = {row.config_key: row.config_value for row in rows}
|
||||
|
||||
vat_rate = Decimal(config.get('accommodation_vat_rate', '0.20'))
|
||||
|
||||
return cls(
|
||||
api_key=config.get('newbook_api_key'),
|
||||
username=config.get('newbook_username'),
|
||||
password=config.get('newbook_password'),
|
||||
region=config.get('newbook_region'),
|
||||
vat_rate=vat_rate
|
||||
)
|
||||
|
||||
async def __aenter__(self):
|
||||
self.client = httpx.AsyncClient(timeout=60.0)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.client.aclose()
|
||||
|
||||
def _get_auth_payload(self) -> dict:
|
||||
"""Get base authentication payload"""
|
||||
return {
|
||||
"api_key": self.api_key,
|
||||
"region": self.region
|
||||
}
|
||||
|
||||
async def get_category_rates(
|
||||
self,
|
||||
category_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
guests_adults: int = 2,
|
||||
guests_children: int = 0
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Fetch current rates for a category over a date range.
|
||||
|
||||
Uses daily=true to get per-night rates. Handles minimum stay
|
||||
restrictions by extending the period when needed.
|
||||
|
||||
Args:
|
||||
category_id: Newbook category ID
|
||||
from_date: Start date for rates
|
||||
to_date: End date for rates (inclusive)
|
||||
guests_adults: Number of adult guests (default 2)
|
||||
guests_children: Number of child guests (default 0)
|
||||
|
||||
Returns:
|
||||
List of dicts with {date, gross_rate, net_rate}
|
||||
"""
|
||||
rates = []
|
||||
current_date = from_date
|
||||
|
||||
while current_date <= to_date:
|
||||
try:
|
||||
# Fetch rates for up to 7 days at a time to optimize API calls
|
||||
batch_end = min(current_date + timedelta(days=6), to_date)
|
||||
batch_rates = await self._fetch_rates_batch(
|
||||
category_id, current_date, batch_end, guests_adults, guests_children
|
||||
)
|
||||
rates.extend(batch_rates)
|
||||
|
||||
# Move to next batch
|
||||
current_date = batch_end + timedelta(days=1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch rates for category {category_id} starting {current_date}: {e}")
|
||||
# Skip this batch and continue
|
||||
current_date = current_date + timedelta(days=7)
|
||||
|
||||
# Rate limiting - ALWAYS wait 1.5s between requests, even after errors
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
return rates
|
||||
|
||||
async def get_single_night_rates(
|
||||
self,
|
||||
category_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
guests_adults: int = 2,
|
||||
guests_children: int = 0
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Fetch rates with single-night queries for accurate per-day tariff availability.
|
||||
|
||||
Unlike get_category_rates which batches, this queries each date individually
|
||||
as a 1-night stay. This gives accurate tariff_success per night, catching
|
||||
issues like Valentine's Day blocking only that night, not a whole week.
|
||||
|
||||
Much slower but necessary for accurate bookability data.
|
||||
|
||||
Args:
|
||||
category_id: Newbook category ID
|
||||
from_date: Start date for rates
|
||||
to_date: End date for rates (inclusive)
|
||||
guests_adults: Number of adult guests (default 2)
|
||||
guests_children: Number of child guests (default 0)
|
||||
|
||||
Returns:
|
||||
List of dicts with {date, gross_rate, net_rate, tariffs_data}
|
||||
"""
|
||||
rates = []
|
||||
current_date = from_date
|
||||
|
||||
while current_date <= to_date:
|
||||
try:
|
||||
# Single-night query for accurate tariff availability
|
||||
batch_rates = await self._fetch_rates_batch(
|
||||
category_id, current_date, current_date, guests_adults, guests_children
|
||||
)
|
||||
rates.extend(batch_rates)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch single-night rate for {category_id} on {current_date}: {e}")
|
||||
# Continue with next date
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Rate limiting - wait between each single-night query
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return rates
|
||||
|
||||
async def fetch_single_date_all_categories(
|
||||
self,
|
||||
for_date: date,
|
||||
guests_adults: int = 2,
|
||||
guests_children: int = 0
|
||||
) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
Fetch single-night rates for ALL categories for one date.
|
||||
|
||||
Returns:
|
||||
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
|
||||
"""
|
||||
return await self._fetch_all_categories_batch(
|
||||
for_date, guests_adults, guests_children
|
||||
)
|
||||
|
||||
async def fetch_multi_night_for_date(
|
||||
self,
|
||||
for_date: date,
|
||||
nights: int,
|
||||
guests_adults: int = 2,
|
||||
guests_children: int = 0
|
||||
) -> Dict[str, Dict[str, bool]]:
|
||||
"""
|
||||
Fetch multi-night availability for ALL categories for one date.
|
||||
|
||||
Returns:
|
||||
Dict of {category_id: {tariff_name: available}}
|
||||
"""
|
||||
return await self._fetch_all_categories_multi_night(
|
||||
for_date, nights, guests_adults, guests_children
|
||||
)
|
||||
|
||||
async def get_all_categories_single_night_rates(
|
||||
self,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
guests_adults: int = 2,
|
||||
guests_children: int = 0
|
||||
) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
Fetch rates for ALL categories with single-night queries.
|
||||
|
||||
More efficient than get_single_night_rates - omits category_id to get
|
||||
all categories in a single API call per date. This reduces API calls
|
||||
from (categories × days) to just (days).
|
||||
|
||||
Args:
|
||||
from_date: Start date for rates
|
||||
to_date: End date for rates (inclusive)
|
||||
guests_adults: Number of adult guests (default 2)
|
||||
guests_children: Number of child guests (default 0)
|
||||
|
||||
Returns:
|
||||
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}, ...]}
|
||||
"""
|
||||
all_rates: Dict[str, List[Dict]] = {}
|
||||
current_date = from_date
|
||||
total_days = (to_date - from_date).days + 1
|
||||
day_count = 0
|
||||
|
||||
while current_date <= to_date:
|
||||
day_count += 1
|
||||
try:
|
||||
# Single-night query WITHOUT category_id - returns ALL categories
|
||||
category_rates = await self._fetch_all_categories_batch(
|
||||
current_date, guests_adults, guests_children
|
||||
)
|
||||
|
||||
# Merge into all_rates dict
|
||||
for cat_id, rates in category_rates.items():
|
||||
if cat_id not in all_rates:
|
||||
all_rates[cat_id] = []
|
||||
all_rates[cat_id].extend(rates)
|
||||
|
||||
logger.info(f"Fetched {current_date} ({day_count}/{total_days}) - {len(category_rates)} categories")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch rates for {current_date}: {e}")
|
||||
# Continue with next date
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Rate limiting - wait between each query
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return all_rates
|
||||
|
||||
async def _fetch_all_categories_batch(
|
||||
self,
|
||||
for_date: date,
|
||||
guests_adults: int,
|
||||
guests_children: int,
|
||||
retry_count: int = 0
|
||||
) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
Fetch rates for ALL categories for a single date.
|
||||
|
||||
Omits category_id from request - Newbook returns all available categories.
|
||||
|
||||
Returns:
|
||||
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
|
||||
"""
|
||||
# Single-night query
|
||||
period_from = f"{for_date.isoformat()} 14:00:00"
|
||||
period_to = f"{(for_date + timedelta(days=1)).isoformat()} 10:00:00"
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"period_from": period_from,
|
||||
"period_to": period_to,
|
||||
"adults": guests_adults,
|
||||
"children": guests_children,
|
||||
"infants": 0,
|
||||
"daily_mode": "true"
|
||||
# NO category_id - returns all categories
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("bookings_availability_pricing"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
# Handle rate limiting with exponential backoff
|
||||
if response.status_code == 429:
|
||||
if retry_count < 3:
|
||||
wait_time = 60 * (retry_count + 1)
|
||||
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3")
|
||||
await asyncio.sleep(wait_time)
|
||||
return await self._fetch_all_categories_batch(
|
||||
for_date, guests_adults, guests_children, retry_count + 1
|
||||
)
|
||||
else:
|
||||
raise NewbookRatesError(f"Rate limited after 3 retries")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
|
||||
|
||||
# Parse all categories from response
|
||||
return self._parse_all_categories_tariffs(data, for_date)
|
||||
|
||||
async def _fetch_all_categories_multi_night(
|
||||
self,
|
||||
for_date: date,
|
||||
nights: int,
|
||||
guests_adults: int = 2,
|
||||
guests_children: int = 0,
|
||||
retry_count: int = 0
|
||||
) -> Dict[str, Dict[str, bool]]:
|
||||
"""
|
||||
Fetch multi-night availability for ALL categories for a specific date.
|
||||
|
||||
Used to verify that rates with min_stay requirements are actually bookable.
|
||||
|
||||
Args:
|
||||
for_date: Check-in date
|
||||
nights: Number of nights to query (e.g., 2 for min_stay=2)
|
||||
guests_adults: Number of adult guests
|
||||
guests_children: Number of child guests
|
||||
|
||||
Returns:
|
||||
Dict of {category_id: {tariff_name: available}}
|
||||
"""
|
||||
# Multi-night query
|
||||
period_from = f"{for_date.isoformat()} 14:00:00"
|
||||
period_to = f"{(for_date + timedelta(days=nights)).isoformat()} 10:00:00"
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"period_from": period_from,
|
||||
"period_to": period_to,
|
||||
"adults": guests_adults,
|
||||
"children": guests_children,
|
||||
"infants": 0,
|
||||
"daily_mode": "true"
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("bookings_availability_pricing"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
# Handle rate limiting
|
||||
if response.status_code == 429:
|
||||
if retry_count < 3:
|
||||
wait_time = 60 * (retry_count + 1)
|
||||
logger.warning(f"Rate limited (multi-night), waiting {wait_time}s")
|
||||
await asyncio.sleep(wait_time)
|
||||
return await self._fetch_all_categories_multi_night(
|
||||
for_date, nights, guests_adults, guests_children, retry_count + 1
|
||||
)
|
||||
else:
|
||||
raise NewbookRatesError(f"Rate limited after 3 retries")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
|
||||
|
||||
# Parse availability by tariff name for each category
|
||||
results: Dict[str, Dict[str, bool]] = {}
|
||||
|
||||
if not isinstance(data.get("data"), dict):
|
||||
return results
|
||||
|
||||
for key, cat_data in data["data"].items():
|
||||
if not (key.isdigit() or str(key).isnumeric()):
|
||||
continue
|
||||
if not isinstance(cat_data, dict):
|
||||
continue
|
||||
|
||||
category_id = str(key)
|
||||
tariffs_available = cat_data.get("tariffs_available", [])
|
||||
|
||||
results[category_id] = {}
|
||||
for tariff in tariffs_available:
|
||||
tariff_name = tariff.get("tariff_name", "")
|
||||
tariff_label = tariff.get("tariff_label", "")
|
||||
# Check tariff_success (API returns string "true"/"false")
|
||||
tariff_success = str(tariff.get("tariff_success", False)).lower() in ("true", "1")
|
||||
# Available if API says success, OR if rates are quoted and no restriction message
|
||||
is_available = tariff_success or (
|
||||
bool(tariff.get("tariffs_quoted")) and not tariff.get("tariff_message")
|
||||
)
|
||||
# Store under both tariff_name and tariff_label for flexible matching
|
||||
results[category_id][tariff_name] = is_available
|
||||
if tariff_label and tariff_label != tariff_name:
|
||||
results[category_id][tariff_label] = is_available
|
||||
|
||||
return results
|
||||
|
||||
async def get_multi_night_availability(
|
||||
self,
|
||||
dates_by_nights: Dict[int, List[date]],
|
||||
guests_adults: int = 2,
|
||||
guests_children: int = 0
|
||||
) -> Dict[date, Dict[str, Dict[str, bool]]]:
|
||||
"""
|
||||
Fetch multi-night availability for specific dates grouped by stay length.
|
||||
|
||||
Checks if a tariff is available when booking N nights starting from each date.
|
||||
|
||||
Args:
|
||||
dates_by_nights: Dict of {nights: [dates]} e.g., {2: [date1, date2], 3: [date3]}
|
||||
guests_adults: Number of adult guests
|
||||
guests_children: Number of child guests
|
||||
|
||||
Returns:
|
||||
Dict of {date: {category_id: {tariff_name: available}}}
|
||||
"""
|
||||
results: Dict[date, Dict[str, Dict[str, bool]]] = {}
|
||||
|
||||
total_queries = sum(len(dates) for dates in dates_by_nights.values())
|
||||
query_count = 0
|
||||
|
||||
for nights, dates in dates_by_nights.items():
|
||||
for query_date in dates:
|
||||
query_count += 1
|
||||
|
||||
try:
|
||||
result = await self._fetch_all_categories_multi_night(
|
||||
query_date, nights, guests_adults, guests_children
|
||||
)
|
||||
results[query_date] = result
|
||||
logger.info(f"Multi-night check {query_count}/{total_queries}: {query_date} ({nights} nights)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed multi-night check for {query_date}: {e}")
|
||||
|
||||
# Rate limiting
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
return results
|
||||
|
||||
async def _fetch_rates_batch(
|
||||
self,
|
||||
category_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
guests_adults: int,
|
||||
guests_children: int,
|
||||
retry_count: int = 0
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Fetch rates for a batch of dates (up to 7 days).
|
||||
|
||||
Handles minimum stay restrictions by extending the period and
|
||||
extracting only the dates we need.
|
||||
|
||||
Returns:
|
||||
List of dicts with {date, gross_rate, net_rate}
|
||||
"""
|
||||
# Format dates with times (check-in 14:00, check-out 10:00)
|
||||
period_from = f"{from_date.isoformat()} 14:00:00"
|
||||
period_to = f"{(to_date + timedelta(days=1)).isoformat()} 10:00:00"
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"period_from": period_from,
|
||||
"period_to": period_to,
|
||||
"adults": guests_adults,
|
||||
"children": guests_children,
|
||||
"infants": 0,
|
||||
"category_id": category_id,
|
||||
"daily_mode": "true" # Get per-night breakdown
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("bookings_availability_pricing"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
# Handle rate limiting with exponential backoff
|
||||
if response.status_code == 429:
|
||||
if retry_count < 3:
|
||||
wait_time = 60 * (retry_count + 1) # 60s, 120s, 180s
|
||||
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry {retry_count + 1}/3")
|
||||
await asyncio.sleep(wait_time)
|
||||
return await self._fetch_rates_batch(
|
||||
category_id, from_date, to_date, guests_adults, guests_children, retry_count + 1
|
||||
)
|
||||
else:
|
||||
raise NewbookRatesError(f"Rate limited after 3 retries")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
# Check if minimum stay restriction
|
||||
categories = data.get("data", {}).get("categories", [])
|
||||
if categories:
|
||||
cat = categories[0] if isinstance(categories, list) else categories.get(category_id, {})
|
||||
min_periods = cat.get("minimum_periods", 1)
|
||||
|
||||
if min_periods > 1:
|
||||
# Extend the stay to meet minimum and retry
|
||||
extended_to = from_date + timedelta(days=min_periods)
|
||||
logger.info(f"Minimum stay {min_periods} nights for category {category_id}, extending to {extended_to}")
|
||||
return await self._fetch_rates_with_min_stay(
|
||||
category_id, from_date, to_date, extended_to,
|
||||
guests_adults, guests_children
|
||||
)
|
||||
|
||||
raise NewbookRatesError(f"API returned failure: {data.get('message')}")
|
||||
|
||||
# Parse tariffs_quoted from response
|
||||
return self._parse_tariffs(data, from_date, to_date)
|
||||
|
||||
async def _fetch_rates_with_min_stay(
|
||||
self,
|
||||
category_id: str,
|
||||
from_date: date,
|
||||
to_date: date,
|
||||
extended_to: date,
|
||||
guests_adults: int,
|
||||
guests_children: int,
|
||||
retry_count: int = 0
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Fetch rates with extended period for minimum stay requirement.
|
||||
|
||||
Args:
|
||||
category_id: Newbook category ID
|
||||
from_date: Original start date
|
||||
to_date: Original end date (dates we want)
|
||||
extended_to: Extended end date to meet minimum stay
|
||||
guests_adults: Number of adults
|
||||
guests_children: Number of children
|
||||
|
||||
Returns:
|
||||
List of rates for the original date range only
|
||||
"""
|
||||
period_from = f"{from_date.isoformat()} 14:00:00"
|
||||
period_to = f"{(extended_to + timedelta(days=1)).isoformat()} 10:00:00"
|
||||
|
||||
payload = self._get_auth_payload()
|
||||
payload.update({
|
||||
"period_from": period_from,
|
||||
"period_to": period_to,
|
||||
"adults": guests_adults,
|
||||
"children": guests_children,
|
||||
"infants": 0,
|
||||
"category_id": category_id,
|
||||
"daily_mode": "true"
|
||||
})
|
||||
|
||||
response = await self.client.post(
|
||||
self._get_url("bookings_availability_pricing"),
|
||||
json=payload,
|
||||
auth=(self.username, self.password)
|
||||
)
|
||||
|
||||
# Handle rate limiting with exponential backoff
|
||||
if response.status_code == 429:
|
||||
if retry_count < 3:
|
||||
wait_time = 60 * (retry_count + 1)
|
||||
logger.warning(f"Rate limited by Newbook API, waiting {wait_time}s before retry")
|
||||
await asyncio.sleep(wait_time)
|
||||
return await self._fetch_rates_with_min_stay(
|
||||
category_id, from_date, to_date, extended_to, guests_adults, guests_children, retry_count + 1
|
||||
)
|
||||
else:
|
||||
raise NewbookRatesError(f"Rate limited after 3 retries")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise NewbookRatesError(f"API error {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise NewbookRatesError(f"API returned failure even with extended stay: {data.get('message')}")
|
||||
|
||||
# Parse tariffs but only return dates in our original range
|
||||
return self._parse_tariffs(data, from_date, to_date)
|
||||
|
||||
def _parse_tariffs(self, data: dict, from_date: date, to_date: date) -> List[Dict]:
|
||||
"""
|
||||
Parse tariffs from API response.
|
||||
|
||||
With daily_mode=true, the API returns tariffs_quoted as a dict keyed by date.
|
||||
Falls back to tariffs_available average if tariffs_quoted not available.
|
||||
|
||||
Args:
|
||||
data: Full API response
|
||||
from_date: Start date to include
|
||||
to_date: End date to include
|
||||
|
||||
Returns:
|
||||
List of dicts with {date, gross_rate, net_rate, tariffs_data}
|
||||
tariffs_data contains all available tariff options for rate report
|
||||
"""
|
||||
rates = []
|
||||
tariffs_quoted = {}
|
||||
fallback_rate = None
|
||||
inventory_items = []
|
||||
all_tariffs_available = [] # Store all tariff options for reporting
|
||||
|
||||
# Find tariffs data in the response
|
||||
if isinstance(data.get("data"), dict):
|
||||
for key in data["data"].keys():
|
||||
# Category IDs are numeric strings
|
||||
if key.isdigit() or key.isnumeric():
|
||||
cat_data = data["data"][key]
|
||||
if isinstance(cat_data, dict):
|
||||
tariffs_available = cat_data.get("tariffs_available", [])
|
||||
all_tariffs_available = tariffs_available # Capture all options
|
||||
if tariffs_available:
|
||||
first_tariff = tariffs_available[0]
|
||||
# tariffs_quoted is a dict keyed by date string
|
||||
tariffs_quoted = first_tariff.get("tariffs_quoted", {})
|
||||
# inventory_items are at tariff level (total for whole stay)
|
||||
inventory_items = first_tariff.get("inventory_items", [])
|
||||
# Fallback average rate
|
||||
fallback_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0))
|
||||
break
|
||||
|
||||
# If we have per-night tariffs_quoted dict, parse it
|
||||
if isinstance(tariffs_quoted, dict) and tariffs_quoted:
|
||||
num_nights = len(tariffs_quoted)
|
||||
|
||||
# Calculate per-night inventory item amount for items already included in tariff
|
||||
included_inventory_per_night = Decimal('0')
|
||||
for item in inventory_items:
|
||||
already_included = item.get('amount_already_included_in_tariff_total', '')
|
||||
if str(already_included).lower() == 'true':
|
||||
total_amount = Decimal(str(item.get('amount', 0) or 0))
|
||||
included_inventory_per_night += total_amount / num_nights
|
||||
|
||||
for date_str, tariff in tariffs_quoted.items():
|
||||
try:
|
||||
stay_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Only include dates in our range
|
||||
if stay_date < from_date or stay_date > to_date:
|
||||
continue
|
||||
|
||||
gross_rate = Decimal(str(tariff.get('amount', 0) or 0))
|
||||
# Net = (gross - included_inventory_per_night) / (1 + VAT)
|
||||
gross_after_inventory = gross_rate - included_inventory_per_night
|
||||
net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01'))
|
||||
|
||||
# Build tariffs_data with day-specific rates
|
||||
tariffs_data = self._build_tariffs_summary(all_tariffs_available, stay_date)
|
||||
|
||||
rates.append({
|
||||
'date': stay_date,
|
||||
'gross_rate': float(gross_rate),
|
||||
'net_rate': float(net_rate),
|
||||
'tariffs_data': tariffs_data
|
||||
})
|
||||
|
||||
return rates
|
||||
|
||||
# Fallback: use average_nightly_tariff and apply to all dates
|
||||
if fallback_rate and fallback_rate > 0:
|
||||
net_rate = (fallback_rate / (1 + self.vat_rate)).quantize(Decimal('0.01'))
|
||||
current_date = from_date
|
||||
while current_date <= to_date:
|
||||
# Build tariffs_data (no day-specific rates in fallback)
|
||||
tariffs_data = self._build_tariffs_summary(all_tariffs_available, current_date)
|
||||
rates.append({
|
||||
'date': current_date,
|
||||
'gross_rate': float(fallback_rate),
|
||||
'net_rate': float(net_rate),
|
||||
'tariffs_data': tariffs_data
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
return rates
|
||||
|
||||
logger.warning(f"No rate found in response for {from_date} to {to_date}")
|
||||
return rates
|
||||
|
||||
def _build_tariffs_summary(self, tariffs_available: list, for_date: date = None) -> dict:
|
||||
"""
|
||||
Build a summary of all available tariff options for rate reporting.
|
||||
|
||||
Args:
|
||||
tariffs_available: List of tariff dicts from API response
|
||||
for_date: Optional specific date to extract day-specific rates
|
||||
|
||||
Returns:
|
||||
Dict with tariff summaries - tariff_count and list of tariff details
|
||||
"""
|
||||
if not tariffs_available:
|
||||
return {}
|
||||
|
||||
summary = {
|
||||
'tariff_count': len(tariffs_available),
|
||||
'tariffs': []
|
||||
}
|
||||
|
||||
date_key = for_date.isoformat() if for_date else None
|
||||
|
||||
for idx, tariff in enumerate(tariffs_available):
|
||||
# Get day-specific rate from tariffs_quoted if available
|
||||
day_rate = None
|
||||
if date_key:
|
||||
tariffs_quoted = tariff.get('tariffs_quoted', {})
|
||||
if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted:
|
||||
day_quote = tariffs_quoted[date_key]
|
||||
if isinstance(day_quote, dict):
|
||||
day_rate = float(day_quote.get('amount', 0) or 0)
|
||||
else:
|
||||
day_rate = float(day_quote or 0)
|
||||
|
||||
# API uses tariff_label for the name
|
||||
message = tariff.get('tariff_message', '')
|
||||
|
||||
# Extract minimum stay from message or dedicated field
|
||||
min_stay = tariff.get('minimum_nights', None)
|
||||
if min_stay is None and message:
|
||||
# Try to parse from message like "Minimum 2 nights" or "2 Night Minimum"
|
||||
import re
|
||||
match = re.search(r'(\d+)\s*[Nn]ight\s*[Mm]inimum', message)
|
||||
if not match:
|
||||
match = re.search(r'[Mm]inimum\s+(\d+)\s*(?:night|period)', message)
|
||||
if match:
|
||||
min_stay = int(match.group(1))
|
||||
|
||||
# Extract advance booking requirement from message
|
||||
min_advance_days = None
|
||||
if message:
|
||||
import re
|
||||
advance_match = re.search(r'(\d+)\s*days?\s*in\s*advance', message, re.IGNORECASE)
|
||||
if advance_match:
|
||||
min_advance_days = int(advance_match.group(1))
|
||||
|
||||
tariff_info = {
|
||||
'name': tariff.get('tariff_label', 'Unknown'),
|
||||
'description': tariff.get('tariff_short_description', ''),
|
||||
'rate': day_rate, # Day-specific rate (None if not available)
|
||||
'average_nightly': float(tariff.get('average_nightly_tariff', 0) or 0),
|
||||
'success': str(tariff.get('tariff_success', False)).lower() in ('true', '1'),
|
||||
'message': message,
|
||||
'sort_order': idx, # Preserve Newbook ordering
|
||||
'min_stay': min_stay, # Minimum nights required (if any)
|
||||
'min_advance_days': min_advance_days, # Advance booking requirement (if any)
|
||||
}
|
||||
|
||||
summary['tariffs'].append(tariff_info)
|
||||
|
||||
return summary
|
||||
|
||||
def _parse_all_categories_tariffs(self, data: dict, for_date: date) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
Parse tariffs from API response for ALL categories.
|
||||
|
||||
When category_id is omitted, data.data contains category IDs as keys,
|
||||
each with their own tariffs_available.
|
||||
|
||||
Args:
|
||||
data: Full API response
|
||||
for_date: The date we queried
|
||||
|
||||
Returns:
|
||||
Dict of {category_id: [{date, gross_rate, net_rate, tariffs_data}]}
|
||||
"""
|
||||
results: Dict[str, List[Dict]] = {}
|
||||
|
||||
if not isinstance(data.get("data"), dict):
|
||||
return results
|
||||
|
||||
for key, cat_data in data["data"].items():
|
||||
# Category IDs are numeric strings like "1", "8", etc.
|
||||
if not (key.isdigit() or str(key).isnumeric()):
|
||||
continue
|
||||
|
||||
if not isinstance(cat_data, dict):
|
||||
continue
|
||||
|
||||
category_id = str(key)
|
||||
tariffs_available = cat_data.get("tariffs_available", [])
|
||||
|
||||
if not tariffs_available:
|
||||
continue
|
||||
|
||||
# Get the first (best) tariff for gross/net calculation
|
||||
first_tariff = tariffs_available[0]
|
||||
tariffs_quoted = first_tariff.get("tariffs_quoted", {})
|
||||
inventory_items = first_tariff.get("inventory_items", [])
|
||||
|
||||
# Get rate for this date
|
||||
date_key = for_date.isoformat()
|
||||
gross_rate = Decimal('0')
|
||||
net_rate = Decimal('0')
|
||||
|
||||
if isinstance(tariffs_quoted, dict) and date_key in tariffs_quoted:
|
||||
day_tariff = tariffs_quoted[date_key]
|
||||
gross_rate = Decimal(str(day_tariff.get('amount', 0) or 0))
|
||||
|
||||
# Calculate included inventory per night
|
||||
included_inventory = Decimal('0')
|
||||
for item in inventory_items:
|
||||
already_included = item.get('amount_already_included_in_tariff_total', '')
|
||||
if str(already_included).lower() == 'true':
|
||||
included_inventory += Decimal(str(item.get('amount', 0) or 0))
|
||||
|
||||
gross_after_inventory = gross_rate - included_inventory
|
||||
net_rate = (gross_after_inventory / (1 + self.vat_rate)).quantize(Decimal('0.01'))
|
||||
else:
|
||||
# Fallback to average
|
||||
gross_rate = Decimal(str(first_tariff.get('average_nightly_tariff', 0) or 0))
|
||||
net_rate = (gross_rate / (1 + self.vat_rate)).quantize(Decimal('0.01'))
|
||||
|
||||
# Build tariffs summary for all options
|
||||
tariffs_data = self._build_tariffs_summary(tariffs_available, for_date)
|
||||
|
||||
results[category_id] = [{
|
||||
'date': for_date,
|
||||
'gross_rate': float(gross_rate),
|
||||
'net_rate': float(net_rate),
|
||||
'tariffs_data': tariffs_data
|
||||
}]
|
||||
|
||||
return results
|
||||
|
||||
531
backend/services/reconciliation_service.py
Normal file
531
backend/services/reconciliation_service.py
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
"""
|
||||
Reconciliation Business Logic Service
|
||||
|
||||
Ported from the WordPress plugin hotel-cashup-reconciliation.
|
||||
Handles payment categorization, variance calculation, and report aggregation.
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import List, Dict, Optional, Any
|
||||
from decimal import Decimal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================
|
||||
# PAYMENT CATEGORIZATION
|
||||
# ============================================
|
||||
|
||||
def identify_card_type(transaction: dict) -> str:
|
||||
"""
|
||||
Categorize a Newbook transaction into a card type.
|
||||
|
||||
Ported from PHP: HCR_Newbook_API::identify_card_type()
|
||||
|
||||
Returns: 'cash', 'visa_mc', 'amex', 'bacs', or 'other'
|
||||
"""
|
||||
# Handle both old 'type' field and new 'payment_type' field
|
||||
ptype = (transaction.get('payment_type') or transaction.get('type') or '').lower()
|
||||
method = (transaction.get('method') or '').lower()
|
||||
transaction_method = (transaction.get('payment_transaction_method') or '').lower()
|
||||
combined = f"{ptype} {method}"
|
||||
|
||||
# Cash must be identified first
|
||||
if 'cash' in combined:
|
||||
return 'cash'
|
||||
|
||||
# BACS/Bank transfers
|
||||
if any(kw in combined for kw in ['eft', 'bacs', 'bank transfer', 'banktransfer', 'direct debit']):
|
||||
return 'bacs'
|
||||
|
||||
# Amex - must be explicitly identified
|
||||
if 'amex' in combined or 'american express' in combined:
|
||||
return 'amex'
|
||||
|
||||
# Visa/Mastercard - must be explicitly identified
|
||||
if any(kw in combined for kw in ['visa', 'mastercard', 'master card', 'mc']):
|
||||
return 'visa_mc'
|
||||
|
||||
# For gateway/automated transactions, default to visa_mc (most common card type)
|
||||
if transaction_method in ('automated', 'gateway', 'cc_gateway'):
|
||||
if any(kw in combined for kw in ['card', 'credit', 'debit']):
|
||||
return 'visa_mc'
|
||||
# Gateway transactions are almost always card payments
|
||||
return 'visa_mc'
|
||||
|
||||
if ptype:
|
||||
logger.warning(f"Unidentified payment type: '{ptype}' (method: '{method}', transaction_method: '{transaction_method}')")
|
||||
|
||||
return 'other'
|
||||
|
||||
|
||||
def convert_newbook_amount(amount: float) -> float:
|
||||
"""
|
||||
Convert Newbook amount from accounting perspective to revenue perspective.
|
||||
|
||||
In Newbook: payments are negative, refunds are positive.
|
||||
For reconciliation: payments should be positive, refunds negative.
|
||||
"""
|
||||
return -float(amount)
|
||||
|
||||
|
||||
def process_transaction(transaction: dict) -> Optional[dict]:
|
||||
"""
|
||||
Process a single Newbook transaction into a payment record.
|
||||
|
||||
Returns None if the transaction should be skipped.
|
||||
"""
|
||||
item_type = transaction.get('item_type', '')
|
||||
|
||||
# Only process payments, refunds, and voided transactions
|
||||
if item_type not in ('payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided'):
|
||||
return None
|
||||
|
||||
# Skip balance transfers (system-generated, always net to zero)
|
||||
payment_type = transaction.get('payment_type', '')
|
||||
if payment_type == 'balance_transfer':
|
||||
return None
|
||||
|
||||
amount = convert_newbook_amount(float(transaction.get('item_amount', 0)))
|
||||
|
||||
return {
|
||||
'payment_id': transaction.get('item_id', ''),
|
||||
'booking_id': str(transaction.get('booking_id', '')),
|
||||
'guest_name': transaction.get('account_for_name', ''),
|
||||
'payment_date': transaction.get('item_date', ''),
|
||||
'payment_type': payment_type,
|
||||
'payment_method': '',
|
||||
'transaction_method': transaction.get('payment_transaction_method', 'manual'),
|
||||
'card_type': identify_card_type(transaction),
|
||||
'amount': amount,
|
||||
'tendered': 0,
|
||||
'processed_by': '',
|
||||
'item_type': item_type,
|
||||
'description': transaction.get('item_description', ''),
|
||||
}
|
||||
|
||||
|
||||
def categorize_payments(raw_transactions: List[dict]) -> List[dict]:
|
||||
"""
|
||||
Process raw Newbook API transactions into categorized payment records.
|
||||
|
||||
Filters out non-payment items and balance transfers, converts amounts,
|
||||
and identifies card types.
|
||||
"""
|
||||
payments = []
|
||||
for transaction in raw_transactions:
|
||||
payment = process_transaction(transaction)
|
||||
if payment is not None:
|
||||
payments.append(payment)
|
||||
return payments
|
||||
|
||||
|
||||
def calculate_payment_totals(payments: List[dict]) -> dict:
|
||||
"""
|
||||
Calculate payment totals by reconciliation category.
|
||||
|
||||
Ported from PHP: HCR_Newbook_API::calculate_payment_totals()
|
||||
|
||||
Categories:
|
||||
- cash: Physical cash payments
|
||||
- manual_visa_mc: Card machine (PDQ) Visa/MC payments
|
||||
- manual_amex: Card machine (PDQ) Amex payments
|
||||
- gateway_visa_mc: Online/gateway Visa/MC payments
|
||||
- gateway_amex: Online/gateway Amex payments
|
||||
- bacs: Bank transfers
|
||||
"""
|
||||
totals = {
|
||||
'cash': 0.0,
|
||||
'manual_visa_mc': 0.0,
|
||||
'manual_amex': 0.0,
|
||||
'gateway_visa_mc': 0.0,
|
||||
'gateway_amex': 0.0,
|
||||
'bacs': 0.0
|
||||
}
|
||||
|
||||
for payment in payments:
|
||||
amount = float(payment.get('amount', 0))
|
||||
transaction_method = (payment.get('transaction_method') or '').lower()
|
||||
card_type = payment.get('card_type', '')
|
||||
|
||||
if card_type == 'cash':
|
||||
totals['cash'] += amount
|
||||
elif card_type == 'bacs':
|
||||
totals['bacs'] += amount
|
||||
elif transaction_method == 'manual':
|
||||
if card_type == 'amex':
|
||||
totals['manual_amex'] += amount
|
||||
elif card_type == 'visa_mc':
|
||||
totals['manual_visa_mc'] += amount
|
||||
elif transaction_method in ('automated', 'gateway', 'cc_gateway'):
|
||||
if card_type == 'amex':
|
||||
totals['gateway_amex'] += amount
|
||||
elif card_type == 'visa_mc':
|
||||
totals['gateway_visa_mc'] += amount
|
||||
|
||||
# Round all totals to 2 decimal places
|
||||
return {k: round(v, 2) for k, v in totals.items()}
|
||||
|
||||
|
||||
# ============================================
|
||||
# TILL SYSTEM TRANSACTIONS
|
||||
# ============================================
|
||||
|
||||
def parse_till_transactions(raw_transactions: List[dict]) -> dict:
|
||||
"""
|
||||
Parse till system transactions from Newbook transaction data.
|
||||
Extracts transactions where method is "manual" and item_description follows:
|
||||
"Ticket: {number} - {payment_type}"
|
||||
|
||||
Returns dict grouped by payment type with count and total.
|
||||
"""
|
||||
till_payments = {}
|
||||
ticket_pattern = re.compile(r'^Ticket:\s*(\d+)\s*-\s*(.+)$', re.IGNORECASE)
|
||||
|
||||
for transaction in raw_transactions:
|
||||
item_type = transaction.get('item_type', '')
|
||||
if item_type not in ('payments_raised', 'refunds_raised', 'payments_voided', 'refunds_voided'):
|
||||
continue
|
||||
|
||||
method = transaction.get('payment_transaction_method', '')
|
||||
if method != 'manual':
|
||||
continue
|
||||
|
||||
description = transaction.get('item_description', '')
|
||||
match = ticket_pattern.match(description)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
payment_type = match.group(2).strip()
|
||||
|
||||
# Skip balance transfers
|
||||
if payment_type == 'balance_transfer':
|
||||
continue
|
||||
|
||||
amount = convert_newbook_amount(float(transaction.get('item_amount', 0)))
|
||||
if amount == 0:
|
||||
continue
|
||||
|
||||
if payment_type not in till_payments:
|
||||
till_payments[payment_type] = {
|
||||
'payment_type': payment_type,
|
||||
'quantity': 0,
|
||||
'total': 0.0,
|
||||
'transactions': []
|
||||
}
|
||||
|
||||
till_payments[payment_type]['quantity'] += 1
|
||||
till_payments[payment_type]['total'] += amount
|
||||
till_payments[payment_type]['transactions'].append({
|
||||
'ticket': match.group(1),
|
||||
'amount': amount,
|
||||
'item_type': item_type
|
||||
})
|
||||
|
||||
# Round totals
|
||||
for key in till_payments:
|
||||
till_payments[key]['total'] = round(till_payments[key]['total'], 2)
|
||||
|
||||
return till_payments
|
||||
|
||||
|
||||
# ============================================
|
||||
# TRANSACTION BREAKDOWN
|
||||
# ============================================
|
||||
|
||||
def build_transaction_breakdown(payments: List[dict]) -> dict:
|
||||
"""
|
||||
Group processed payments into a transaction breakdown for display.
|
||||
|
||||
Groups:
|
||||
- reception_manual: Manual payments at reception (PDQ entered by staff)
|
||||
- reception_gateway: Automated/gateway payments at reception
|
||||
- restaurant_bar: Payments from till system (description contains "Ticket:")
|
||||
|
||||
Each group is further sub-grouped by payment type label.
|
||||
Returns dict of groups, each containing sub-groups with transaction lists.
|
||||
"""
|
||||
ticket_pattern = re.compile(r'Ticket:\s*(\d+)\s*-\s*(.+)', re.IGNORECASE)
|
||||
|
||||
reception_manual: Dict[str, list] = {}
|
||||
reception_gateway: Dict[str, list] = {}
|
||||
restaurant_bar: Dict[str, list] = {}
|
||||
|
||||
for p in payments:
|
||||
transaction_method = (p.get('transaction_method') or '').lower()
|
||||
card_type = p.get('card_type', 'other')
|
||||
payment_type = p.get('payment_type', '')
|
||||
item_type = p.get('item_type', '')
|
||||
amount = float(p.get('amount', 0))
|
||||
guest_name = p.get('guest_name', '')
|
||||
payment_date = p.get('payment_date', '')
|
||||
description = p.get('description', '')
|
||||
is_voided = item_type in ('payments_voided', 'refunds_voided')
|
||||
|
||||
# Extract time from date string
|
||||
time_str = ''
|
||||
if payment_date and ' ' in str(payment_date):
|
||||
time_str = str(payment_date).split(' ')[1][:5] # HH:MM
|
||||
|
||||
# Determine display type label
|
||||
type_label = payment_type.title() if payment_type else 'Other'
|
||||
if card_type == 'cash':
|
||||
type_label = 'Cash'
|
||||
elif card_type == 'bacs':
|
||||
type_label = 'BACS'
|
||||
elif card_type == 'amex':
|
||||
type_label = 'Amex'
|
||||
elif card_type == 'visa_mc':
|
||||
type_label = 'Card'
|
||||
|
||||
# Check for restaurant/bar till ticket pattern in description
|
||||
ticket_match = ticket_pattern.search(description) if description else None
|
||||
details = guest_name
|
||||
if ticket_match:
|
||||
ticket_num = ticket_match.group(1)
|
||||
ticket_type = ticket_match.group(2).strip()
|
||||
details = f"Ticket #{ticket_num} - {ticket_type}"
|
||||
type_label = ticket_type.title() if ticket_type else type_label
|
||||
|
||||
entry = {
|
||||
'time': time_str,
|
||||
'type': type_label,
|
||||
'details': details,
|
||||
'amount': round(amount, 2),
|
||||
'is_voided': is_voided,
|
||||
'is_refund': item_type in ('refunds_raised', 'refunds_voided'),
|
||||
'item_type': item_type,
|
||||
'payment_id': p.get('payment_id', ''),
|
||||
'booking_id': p.get('booking_id', ''),
|
||||
}
|
||||
|
||||
# Route to appropriate group
|
||||
if ticket_match:
|
||||
if type_label not in restaurant_bar:
|
||||
restaurant_bar[type_label] = []
|
||||
restaurant_bar[type_label].append(entry)
|
||||
elif transaction_method in ('automated', 'gateway', 'cc_gateway'):
|
||||
if type_label not in reception_gateway:
|
||||
reception_gateway[type_label] = []
|
||||
reception_gateway[type_label].append(entry)
|
||||
else:
|
||||
# Manual and default go to reception_manual
|
||||
if type_label not in reception_manual:
|
||||
reception_manual[type_label] = []
|
||||
reception_manual[type_label].append(entry)
|
||||
|
||||
# Calculate subtotals for each group
|
||||
def with_subtotals(group: Dict[str, list]) -> dict:
|
||||
result = {}
|
||||
group_total = 0.0
|
||||
group_count = 0
|
||||
for key, transactions in group.items():
|
||||
subtotal = round(sum(t['amount'] for t in transactions), 2)
|
||||
result[key] = {
|
||||
'transactions': transactions,
|
||||
'subtotal': subtotal,
|
||||
'count': len(transactions),
|
||||
}
|
||||
group_total += subtotal
|
||||
group_count += len(transactions)
|
||||
return {'groups': result, 'total': round(group_total, 2), 'count': group_count}
|
||||
|
||||
return {
|
||||
'reception_manual': with_subtotals(reception_manual),
|
||||
'reception_gateway': with_subtotals(reception_gateway),
|
||||
'restaurant_bar': with_subtotals(restaurant_bar),
|
||||
}
|
||||
|
||||
|
||||
# ============================================
|
||||
# VARIANCE CALCULATION
|
||||
# ============================================
|
||||
|
||||
def calculate_variance(banked: float, reported: float) -> float:
|
||||
"""
|
||||
Calculate variance between banked (manual count) and reported (Newbook).
|
||||
|
||||
Positive = over (extra cash/payments found)
|
||||
Negative = short (missing cash/payments)
|
||||
"""
|
||||
return round(banked - reported, 2)
|
||||
|
||||
|
||||
def get_variance_status(variance: float, threshold: float = 10.0) -> str:
|
||||
"""
|
||||
Determine variance status for display.
|
||||
|
||||
Returns: 'balanced', 'over', or 'short'
|
||||
"""
|
||||
if abs(variance) <= threshold:
|
||||
return 'balanced'
|
||||
elif variance > 0:
|
||||
return 'over'
|
||||
else:
|
||||
return 'short'
|
||||
|
||||
|
||||
def build_reconciliation_rows(
|
||||
banked_totals: dict,
|
||||
reported_totals: dict
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Build reconciliation comparison rows for each category.
|
||||
|
||||
banked_totals: From manual entry (cash count + card machines)
|
||||
reported_totals: From Newbook payments
|
||||
|
||||
Returns list of rows with category, banked, reported, variance.
|
||||
"""
|
||||
categories = [
|
||||
('Cash', 'cash'),
|
||||
('PDQ Visa/MC', 'manual_visa_mc'),
|
||||
('PDQ Amex', 'manual_amex'),
|
||||
('Gateway Visa/MC', 'gateway_visa_mc'),
|
||||
('Gateway Amex', 'gateway_amex'),
|
||||
('BACS', 'bacs'),
|
||||
]
|
||||
|
||||
rows = []
|
||||
for label, key in categories:
|
||||
banked = banked_totals.get(key, 0.0)
|
||||
reported = reported_totals.get(key, 0.0)
|
||||
variance = calculate_variance(banked, reported)
|
||||
rows.append({
|
||||
'category': label,
|
||||
'key': key,
|
||||
'banked_amount': round(banked, 2),
|
||||
'reported_amount': round(reported, 2),
|
||||
'variance': variance,
|
||||
'status': get_variance_status(variance)
|
||||
})
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
# ============================================
|
||||
# MULTI-DAY REPORT AGGREGATION
|
||||
# ============================================
|
||||
|
||||
def build_multi_day_report(
|
||||
cash_ups: List[dict],
|
||||
payment_totals_by_date: Dict[str, dict],
|
||||
daily_stats: List[dict],
|
||||
sales_breakdown: List[dict],
|
||||
) -> dict:
|
||||
"""
|
||||
Build multi-day report with 3 tables:
|
||||
1. Daily Reconciliation Summary (banked vs reported by category per day)
|
||||
2. Sales Breakdown (GL categories vs days)
|
||||
3. Occupancy Stats (rooms, people, rates per day)
|
||||
|
||||
Returns dict with three table datasets.
|
||||
"""
|
||||
# Table 1: Daily Reconciliation Summary
|
||||
recon_summary = []
|
||||
total_banked = {
|
||||
'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0,
|
||||
'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0
|
||||
}
|
||||
total_reported = {
|
||||
'cash': 0, 'manual_visa_mc': 0, 'manual_amex': 0,
|
||||
'gateway_visa_mc': 0, 'gateway_amex': 0, 'bacs': 0
|
||||
}
|
||||
|
||||
for cash_up in cash_ups:
|
||||
date_str = cash_up['session_date']
|
||||
reported = payment_totals_by_date.get(date_str, {})
|
||||
|
||||
# Build banked totals from cash_up data
|
||||
banked = {
|
||||
'cash': float(cash_up.get('total_cash_counted', 0)),
|
||||
'manual_visa_mc': 0.0,
|
||||
'manual_amex': 0.0,
|
||||
'gateway_visa_mc': 0.0,
|
||||
'gateway_amex': 0.0,
|
||||
'bacs': 0.0
|
||||
}
|
||||
|
||||
# Card machine totals from cash_up
|
||||
for card in cash_up.get('card_machines', []):
|
||||
machine_name = card.get('machine_name', '').lower()
|
||||
banked['manual_visa_mc'] += float(card.get('visa_mc_amount', 0))
|
||||
banked['manual_amex'] += float(card.get('amex_amount', 0))
|
||||
|
||||
# Reported amounts from Newbook
|
||||
reported_amounts = {
|
||||
'cash': float(reported.get('cash', 0)),
|
||||
'manual_visa_mc': float(reported.get('manual_visa_mc', 0)),
|
||||
'manual_amex': float(reported.get('manual_amex', 0)),
|
||||
'gateway_visa_mc': float(reported.get('gateway_visa_mc', 0)),
|
||||
'gateway_amex': float(reported.get('gateway_amex', 0)),
|
||||
'bacs': float(reported.get('bacs', 0)),
|
||||
}
|
||||
|
||||
# Calculate row variances
|
||||
row_variance = {}
|
||||
for key in banked:
|
||||
row_variance[key] = round(banked[key] - reported_amounts[key], 2)
|
||||
total_banked[key] += banked[key]
|
||||
total_reported[key] += reported_amounts[key]
|
||||
|
||||
recon_summary.append({
|
||||
'date': date_str,
|
||||
'status': cash_up.get('status', ''),
|
||||
'banked': {k: round(v, 2) for k, v in banked.items()},
|
||||
'reported': {k: round(v, 2) for k, v in reported_amounts.items()},
|
||||
'variance': row_variance,
|
||||
'banked_total': round(sum(banked.values()), 2),
|
||||
'reported_total': round(sum(reported_amounts.values()), 2),
|
||||
})
|
||||
|
||||
# Totals row
|
||||
total_variance = {}
|
||||
for key in total_banked:
|
||||
total_variance[key] = round(total_banked[key] - total_reported[key], 2)
|
||||
|
||||
recon_totals = {
|
||||
'banked': {k: round(v, 2) for k, v in total_banked.items()},
|
||||
'reported': {k: round(v, 2) for k, v in total_reported.items()},
|
||||
'variance': total_variance,
|
||||
'banked_total': round(sum(total_banked.values()), 2),
|
||||
'reported_total': round(sum(total_reported.values()), 2),
|
||||
}
|
||||
|
||||
# Table 2: Sales Breakdown
|
||||
sales_by_date = {}
|
||||
all_categories = set()
|
||||
for row in sales_breakdown:
|
||||
d = row['business_date']
|
||||
cat = row['category']
|
||||
amt = float(row['net_amount'])
|
||||
all_categories.add(cat)
|
||||
if d not in sales_by_date:
|
||||
sales_by_date[d] = {}
|
||||
sales_by_date[d][cat] = amt
|
||||
|
||||
# Table 3: Occupancy Stats
|
||||
occupancy_data = []
|
||||
for stat in daily_stats:
|
||||
occupancy_data.append({
|
||||
'date': stat['business_date'],
|
||||
'gross_sales': float(stat.get('gross_sales', 0)),
|
||||
'rooms_sold': int(stat.get('rooms_sold', 0)),
|
||||
'total_people': int(stat.get('total_people', 0)),
|
||||
'debtors_creditors': float(stat.get('debtors_creditors_balance', 0)),
|
||||
})
|
||||
|
||||
return {
|
||||
'reconciliation_summary': {
|
||||
'rows': recon_summary,
|
||||
'totals': recon_totals,
|
||||
},
|
||||
'sales_breakdown': {
|
||||
'categories': sorted(list(all_categories)),
|
||||
'by_date': sales_by_date,
|
||||
},
|
||||
'occupancy': {
|
||||
'rows': occupancy_data,
|
||||
}
|
||||
}
|
||||
177
backend/services/resos_client.py
Normal file
177
backend/services/resos_client.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"""
|
||||
Resos API Client
|
||||
|
||||
CRITICAL: This client is READ-ONLY. All methods use GET requests only.
|
||||
NO data is written, modified, or deleted in Resos.
|
||||
Data flows ONE WAY: Resos → Local Database
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
import base64
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResosAPIError(Exception):
|
||||
"""Custom exception for Resos API errors"""
|
||||
pass
|
||||
|
||||
|
||||
class ResosClient:
|
||||
"""
|
||||
Async client for Resos API
|
||||
|
||||
Rate limiting: ~60 requests/min, using 1s delay between requests
|
||||
Pagination: Uses skip/limit, max 100 per request
|
||||
Date filtering: Uses fromDateTime/toDateTime
|
||||
"""
|
||||
|
||||
BASE_URL = "https://api.resos.com/v1"
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
# Use provided credentials or fall back to environment variables
|
||||
self.api_key = api_key or os.getenv("RESOS_API_KEY")
|
||||
if self.api_key:
|
||||
# HTTP Basic Auth: base64_encode(api_key + ':')
|
||||
self.auth_header = f"Basic {base64.b64encode(f'{self.api_key}:'.encode()).decode()}"
|
||||
else:
|
||||
self.auth_header = None
|
||||
logger.warning("Resos API key not configured")
|
||||
|
||||
@classmethod
|
||||
async def from_db(cls, db):
|
||||
"""Create client with credentials from database"""
|
||||
from api.config import _get_config_value
|
||||
|
||||
api_key = await _get_config_value(db, "resos_api_key")
|
||||
return cls(api_key=api_key)
|
||||
|
||||
async def __aenter__(self):
|
||||
self.client = httpx.AsyncClient(timeout=30.0)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.client.aclose()
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""Test API connection by fetching opening hours"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.BASE_URL}/openingHours",
|
||||
headers={"Authorization": self.auth_header}
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Resos connection test failed: {e}")
|
||||
return False
|
||||
|
||||
async def get_bookings(
|
||||
self,
|
||||
from_date: date,
|
||||
to_date: date
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Fetch bookings for date range with pagination and rate limiting.
|
||||
|
||||
Returns list of booking objects with structure:
|
||||
{
|
||||
'_id': 'booking_id',
|
||||
'date': '2026-01-20',
|
||||
'time': '19:00',
|
||||
'people': 2,
|
||||
'status': 'confirmed',
|
||||
'source': 'website',
|
||||
'guest': {...},
|
||||
'customFields': [...],
|
||||
'restaurantNotes': [...]
|
||||
}
|
||||
"""
|
||||
all_bookings = []
|
||||
offset = 0
|
||||
|
||||
from_datetime = f"{from_date}T00:00:00"
|
||||
to_datetime = f"{to_date}T23:59:59"
|
||||
|
||||
while True:
|
||||
logger.info(f"Fetching Resos bookings: {from_date} to {to_date} (offset: {offset})")
|
||||
|
||||
response = await self.client.get(
|
||||
f"{self.BASE_URL}/bookings",
|
||||
headers={"Authorization": self.auth_header},
|
||||
params={
|
||||
"fromDateTime": from_datetime,
|
||||
"toDateTime": to_datetime,
|
||||
"limit": 100,
|
||||
"skip": offset
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_body = response.text
|
||||
logger.error(f"Resos API error {response.status_code}: {error_body}")
|
||||
raise ResosAPIError(f"Failed to fetch bookings: {response.status_code} - {error_body}")
|
||||
|
||||
data = response.json()
|
||||
page_bookings = data if isinstance(data, list) else []
|
||||
|
||||
if not page_bookings:
|
||||
break
|
||||
|
||||
all_bookings.extend(page_bookings)
|
||||
logger.info(f"Fetched {len(page_bookings)} bookings (offset {offset})")
|
||||
|
||||
# If we got fewer than the limit, we've reached the end
|
||||
if len(page_bookings) < 100:
|
||||
break
|
||||
|
||||
offset += 100
|
||||
|
||||
# Rate limiting: 1 request per second
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.info(f"Total bookings fetched: {len(all_bookings)}")
|
||||
return all_bookings
|
||||
|
||||
async def get_opening_hours(self) -> List[dict]:
|
||||
"""
|
||||
Fetch opening hours/service periods
|
||||
|
||||
Returns list of opening hour objects:
|
||||
{
|
||||
'_id': 'opening_hour_id',
|
||||
'name': 'Dinner',
|
||||
'startTime': '18:00',
|
||||
'endTime': '22:00',
|
||||
'days': ['monday', 'tuesday', 'wednesday', ...]
|
||||
}
|
||||
"""
|
||||
response = await self.client.get(
|
||||
f"{self.BASE_URL}/openingHours",
|
||||
headers={"Authorization": self.auth_header},
|
||||
params={"showDeleted": "false", "onlySpecial": "false"}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ResosAPIError(f"Failed to fetch opening hours: {response.status_code}")
|
||||
|
||||
return response.json()
|
||||
|
||||
async def get_custom_field_definitions(self) -> List[dict]:
|
||||
"""
|
||||
Fetch custom field definitions
|
||||
|
||||
Returns field definitions with choice options for dropdowns/radios
|
||||
"""
|
||||
response = await self.client.get(
|
||||
f"{self.BASE_URL}/customFields",
|
||||
headers={"Authorization": self.auth_header}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ResosAPIError(f"Failed to fetch custom fields: {response.status_code}")
|
||||
|
||||
return response.json()
|
||||
20
backend/services/scraper_backends/__init__.py
Normal file
20
backend/services/scraper_backends/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""
|
||||
Scraper backends for booking.com rate scraping.
|
||||
|
||||
Provides pluggable backends to allow switching between:
|
||||
- playwright_local: Direct Playwright (default)
|
||||
- playwright_proxy: Playwright with rotating proxies (future)
|
||||
- apify_backend: Apify scraping service (future)
|
||||
"""
|
||||
|
||||
from .base import ScraperBackend, ScraperResult, HotelData, RateData, AvailabilityStatus
|
||||
from .playwright_local import PlaywrightLocalBackend
|
||||
|
||||
__all__ = [
|
||||
'ScraperBackend',
|
||||
'ScraperResult',
|
||||
'HotelData',
|
||||
'RateData',
|
||||
'AvailabilityStatus',
|
||||
'PlaywrightLocalBackend',
|
||||
]
|
||||
152
backend/services/scraper_backends/base.py
Normal file
152
backend/services/scraper_backends/base.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""
|
||||
Abstract base class for booking.com scraper backends.
|
||||
|
||||
Defines the interface that all scraper backends must implement,
|
||||
allowing easy switching between local Playwright, proxied Playwright,
|
||||
or external services like Apify.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AvailabilityStatus(str, Enum):
|
||||
"""Availability status for a hotel rate."""
|
||||
AVAILABLE = 'available' # Rate found, bookable
|
||||
SOLD_OUT = 'sold_out' # Hotel shows no availability
|
||||
NO_DATA = 'no_data' # Couldn't determine (scraper issue)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RateData:
|
||||
"""Rate data for a single hotel on a single date."""
|
||||
hotel_id: Optional[str] = None # Our internal hotel_id (filled after DB lookup)
|
||||
booking_com_id: str = '' # Hotel ID from booking.com
|
||||
rate_date: date = None
|
||||
availability_status: AvailabilityStatus = AvailabilityStatus.NO_DATA
|
||||
rate_gross: Optional[Decimal] = None
|
||||
currency: str = 'GBP'
|
||||
room_type: Optional[str] = None
|
||||
breakfast_included: Optional[bool] = None
|
||||
free_cancellation: Optional[bool] = None
|
||||
no_prepayment: Optional[bool] = None
|
||||
rooms_left: Optional[int] = None # "Only X rooms left"
|
||||
available_qty: Optional[int] = None # Future: from hotel page dropdown
|
||||
|
||||
|
||||
@dataclass
|
||||
class HotelData:
|
||||
"""Hotel data discovered from search results."""
|
||||
booking_com_id: str
|
||||
name: str
|
||||
booking_com_url: Optional[str] = None
|
||||
star_rating: Optional[Decimal] = None
|
||||
review_score: Optional[Decimal] = None
|
||||
review_count: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScraperResult:
|
||||
"""Result from a scraping operation."""
|
||||
success: bool
|
||||
blocked: bool = False # True if anti-scrape blocking detected
|
||||
block_reason: Optional[str] = None # CAPTCHA, rate limit, etc.
|
||||
hotels: List[HotelData] = field(default_factory=list)
|
||||
rates: List[RateData] = field(default_factory=list)
|
||||
error_message: Optional[str] = None
|
||||
page_content_sample: Optional[str] = None # For debugging
|
||||
|
||||
|
||||
class ScraperBackend(ABC):
|
||||
"""
|
||||
Abstract base class for scraper backends.
|
||||
|
||||
All backends must implement these methods to provide a consistent
|
||||
interface for the main booking_scraper.py service.
|
||||
"""
|
||||
|
||||
# Common block detection signals
|
||||
BLOCK_SIGNALS = [
|
||||
'captcha',
|
||||
'unusual traffic',
|
||||
'access denied',
|
||||
'please verify',
|
||||
'too many requests',
|
||||
'are you a robot',
|
||||
'verify you are human',
|
||||
'security check',
|
||||
]
|
||||
|
||||
@abstractmethod
|
||||
async def scrape_location_search(
|
||||
self,
|
||||
location: str,
|
||||
check_in: date,
|
||||
check_out: date,
|
||||
adults: int = 2,
|
||||
pages: int = 2
|
||||
) -> ScraperResult:
|
||||
"""
|
||||
Scrape booking.com location search results.
|
||||
|
||||
Args:
|
||||
location: Location name (e.g., "Bowness-on-Windermere")
|
||||
check_in: Check-in date
|
||||
check_out: Check-out date (typically check_in + 1 for single night)
|
||||
adults: Number of adults for search
|
||||
pages: Number of search result pages to scrape
|
||||
|
||||
Returns:
|
||||
ScraperResult with hotels and rates found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def scrape_hotel_page(
|
||||
self,
|
||||
hotel_url: str,
|
||||
check_in: date,
|
||||
check_out: date,
|
||||
adults: int = 2
|
||||
) -> ScraperResult:
|
||||
"""
|
||||
Scrape an individual hotel page for detailed rates.
|
||||
|
||||
Future expansion - not used in initial implementation.
|
||||
Will provide available_qty from room dropdowns.
|
||||
|
||||
Args:
|
||||
hotel_url: Full booking.com URL for the hotel
|
||||
check_in: Check-in date
|
||||
check_out: Check-out date
|
||||
adults: Number of adults
|
||||
|
||||
Returns:
|
||||
ScraperResult with detailed rate information
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def close(self):
|
||||
"""Clean up any resources (browser instances, etc.)."""
|
||||
pass
|
||||
|
||||
def detect_blocking(self, page_content: str) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if page content shows anti-scrape response.
|
||||
|
||||
Args:
|
||||
page_content: HTML content of the page
|
||||
|
||||
Returns:
|
||||
Tuple of (is_blocked, reason)
|
||||
"""
|
||||
content_lower = page_content.lower()
|
||||
for signal in self.BLOCK_SIGNALS:
|
||||
if signal in content_lower:
|
||||
return True, signal
|
||||
return False, None
|
||||
401
backend/services/scraper_backends/playwright_local.py
Normal file
401
backend/services/scraper_backends/playwright_local.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"""
|
||||
Local Playwright backend for booking.com scraping.
|
||||
|
||||
Uses Playwright with Chromium to scrape search results.
|
||||
No proxy - direct connection. Suitable for low-volume scraping.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from playwright.async_api import async_playwright, Browser, BrowserContext, Page
|
||||
|
||||
from .base import (
|
||||
ScraperBackend,
|
||||
ScraperResult,
|
||||
HotelData,
|
||||
RateData,
|
||||
AvailabilityStatus
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaywrightLocalBackend(ScraperBackend):
|
||||
"""
|
||||
Local Playwright backend using Chromium.
|
||||
|
||||
Features:
|
||||
- Rotates user agents
|
||||
- Random delays between requests
|
||||
- Mimics human scroll behavior
|
||||
- Uses data-testid selectors for stability
|
||||
"""
|
||||
|
||||
USER_AGENTS = [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||
]
|
||||
|
||||
def __init__(self, proxy_config: dict = None):
|
||||
"""
|
||||
Initialize the backend.
|
||||
|
||||
Args:
|
||||
proxy_config: Optional proxy configuration (for future use)
|
||||
"""
|
||||
self.proxy_config = proxy_config
|
||||
self._playwright = None
|
||||
self._browser: Optional[Browser] = None
|
||||
|
||||
async def _ensure_browser(self) -> Browser:
|
||||
"""Ensure browser is running, start if needed."""
|
||||
if self._browser is None or not self._browser.is_connected():
|
||||
self._playwright = await async_playwright().start()
|
||||
self._browser = await self._playwright.chromium.launch(
|
||||
headless=True,
|
||||
args=[
|
||||
'--disable-blink-features=AutomationControlled',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
]
|
||||
)
|
||||
return self._browser
|
||||
|
||||
async def _create_context(self) -> BrowserContext:
|
||||
"""Create a new browser context with random user agent."""
|
||||
browser = await self._ensure_browser()
|
||||
context = await browser.new_context(
|
||||
user_agent=random.choice(self.USER_AGENTS),
|
||||
viewport={'width': 1920, 'height': 1080},
|
||||
locale='en-GB',
|
||||
timezone_id='Europe/London',
|
||||
)
|
||||
return context
|
||||
|
||||
def _build_search_url(
|
||||
self,
|
||||
location: str,
|
||||
check_in: date,
|
||||
check_out: date,
|
||||
adults: int,
|
||||
offset: int = 0
|
||||
) -> str:
|
||||
"""Build booking.com search URL with parameters."""
|
||||
params = {
|
||||
'ss': location,
|
||||
'checkin': check_in.isoformat(),
|
||||
'checkout': check_out.isoformat(),
|
||||
'group_adults': adults,
|
||||
'no_rooms': 1,
|
||||
'group_children': 0,
|
||||
}
|
||||
if offset > 0:
|
||||
params['offset'] = offset
|
||||
|
||||
return f"https://www.booking.com/searchresults.en-gb.html?{urlencode(params)}"
|
||||
|
||||
def _parse_price(self, price_text: str) -> Optional[Decimal]:
|
||||
"""Parse price from text like '£150' or 'GBP 150'."""
|
||||
if not price_text:
|
||||
return None
|
||||
# Remove currency symbols and extract number
|
||||
cleaned = re.sub(r'[£$€,\s]', '', price_text)
|
||||
# Find first number (including decimals)
|
||||
match = re.search(r'[\d,]+(?:\.\d{2})?', cleaned)
|
||||
if match:
|
||||
try:
|
||||
return Decimal(match.group().replace(',', ''))
|
||||
except InvalidOperation:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _extract_hotel_id(self, url: str) -> Optional[str]:
|
||||
"""Extract hotel ID from booking.com URL."""
|
||||
if not url:
|
||||
return None
|
||||
# URL format: /hotel/gb/hotel-name.en-gb.html or ?dest_id=123
|
||||
# Try to extract from URL path
|
||||
match = re.search(r'/hotel/[a-z]{2}/([^/]+)\.', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Try dest_id parameter
|
||||
match = re.search(r'dest_id=(-?\d+)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
async def _human_like_scroll(self, page: Page):
|
||||
"""Simulate human-like scrolling behavior."""
|
||||
# Scroll down in increments
|
||||
for _ in range(3):
|
||||
await page.mouse.wheel(0, random.randint(300, 600))
|
||||
await asyncio.sleep(random.uniform(0.3, 0.8))
|
||||
|
||||
async def _extract_search_results(self, page: Page, rate_date: date) -> tuple[List[HotelData], List[RateData]]:
|
||||
"""Extract hotel and rate data from search results page."""
|
||||
hotels = []
|
||||
rates = []
|
||||
|
||||
# Wait for property cards - booking.com uses data-testid
|
||||
try:
|
||||
await page.wait_for_selector('[data-testid="property-card"]', timeout=15000)
|
||||
except Exception as e:
|
||||
logger.warning(f"No property cards found: {e}")
|
||||
return hotels, rates
|
||||
|
||||
# Get all property cards
|
||||
cards = await page.query_selector_all('[data-testid="property-card"]')
|
||||
logger.info(f"Found {len(cards)} property cards")
|
||||
|
||||
for card in cards:
|
||||
try:
|
||||
hotel = HotelData(booking_com_id='', name='')
|
||||
rate = RateData(rate_date=rate_date)
|
||||
|
||||
# Hotel name
|
||||
name_el = await card.query_selector('[data-testid="title"]')
|
||||
if name_el:
|
||||
hotel.name = (await name_el.inner_text()).strip()
|
||||
|
||||
if not hotel.name:
|
||||
continue # Skip if no name found
|
||||
|
||||
# Hotel URL and ID
|
||||
link_el = await card.query_selector('[data-testid="title-link"]')
|
||||
if link_el:
|
||||
hotel.booking_com_url = await link_el.get_attribute('href')
|
||||
hotel.booking_com_id = self._extract_hotel_id(hotel.booking_com_url) or ''
|
||||
|
||||
rate.booking_com_id = hotel.booking_com_id
|
||||
|
||||
# Star rating - look for star icons or rating text
|
||||
stars_el = await card.query_selector('[data-testid="rating-stars"]')
|
||||
if stars_el:
|
||||
stars_text = await stars_el.get_attribute('aria-label') or ''
|
||||
match = re.search(r'(\d+)', stars_text)
|
||||
if match:
|
||||
hotel.star_rating = Decimal(match.group(1))
|
||||
|
||||
# Review score
|
||||
score_el = await card.query_selector('[data-testid="review-score"]')
|
||||
if score_el:
|
||||
score_text = await score_el.inner_text()
|
||||
match = re.search(r'([\d.]+)', score_text)
|
||||
if match:
|
||||
try:
|
||||
hotel.review_score = Decimal(match.group(1))
|
||||
except InvalidOperation:
|
||||
pass
|
||||
|
||||
# Check for no availability message FIRST
|
||||
no_avail_el = await card.query_selector('[data-testid="availability-message"]')
|
||||
if no_avail_el:
|
||||
avail_text = (await no_avail_el.inner_text()).lower()
|
||||
if 'no availability' in avail_text or 'sold out' in avail_text:
|
||||
rate.availability_status = AvailabilityStatus.SOLD_OUT
|
||||
hotels.append(hotel)
|
||||
rates.append(rate)
|
||||
continue
|
||||
|
||||
# Price
|
||||
price_el = await card.query_selector('[data-testid="price-and-discounted-price"]')
|
||||
if not price_el:
|
||||
# Try alternative selector
|
||||
price_el = await card.query_selector('[data-testid="price"]')
|
||||
|
||||
if price_el:
|
||||
price_text = await price_el.inner_text()
|
||||
rate.rate_gross = self._parse_price(price_text)
|
||||
if rate.rate_gross:
|
||||
rate.availability_status = AvailabilityStatus.AVAILABLE
|
||||
|
||||
# Room type
|
||||
room_el = await card.query_selector('[data-testid="recommended-units"]')
|
||||
if room_el:
|
||||
rate.room_type = (await room_el.inner_text()).strip()
|
||||
|
||||
# Rate option badges - try multiple selectors
|
||||
# Breakfast included
|
||||
breakfast_el = await card.query_selector('[data-testid="breakfast-included"]')
|
||||
if not breakfast_el:
|
||||
# Check text content for breakfast mentions
|
||||
card_text = (await card.inner_text()).lower()
|
||||
rate.breakfast_included = 'breakfast included' in card_text
|
||||
else:
|
||||
rate.breakfast_included = True
|
||||
|
||||
# Free cancellation
|
||||
cancel_el = await card.query_selector('[data-testid="cancellation-policy"]')
|
||||
if cancel_el:
|
||||
cancel_text = (await cancel_el.inner_text()).lower()
|
||||
rate.free_cancellation = 'free cancellation' in cancel_text
|
||||
else:
|
||||
card_text = (await card.inner_text()).lower()
|
||||
rate.free_cancellation = 'free cancellation' in card_text
|
||||
|
||||
# No prepayment
|
||||
prepay_el = await card.query_selector('[data-testid="no-prepayment"]')
|
||||
if prepay_el:
|
||||
rate.no_prepayment = True
|
||||
else:
|
||||
card_text = (await card.inner_text()).lower()
|
||||
rate.no_prepayment = 'no prepayment' in card_text
|
||||
|
||||
# Rooms left / scarcity indicator
|
||||
scarcity_el = await card.query_selector('[data-testid="availability-rate"]')
|
||||
if scarcity_el:
|
||||
scarcity_text = await scarcity_el.inner_text()
|
||||
match = re.search(r'(\d+)\s*room', scarcity_text.lower())
|
||||
if match:
|
||||
rate.rooms_left = int(match.group(1))
|
||||
|
||||
hotels.append(hotel)
|
||||
rates.append(rate)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error extracting hotel data: {e}")
|
||||
continue
|
||||
|
||||
return hotels, rates
|
||||
|
||||
async def scrape_location_search(
|
||||
self,
|
||||
location: str,
|
||||
check_in: date,
|
||||
check_out: date,
|
||||
adults: int = 2,
|
||||
pages: int = 2
|
||||
) -> ScraperResult:
|
||||
"""
|
||||
Scrape booking.com location search results.
|
||||
|
||||
Args:
|
||||
location: Location name
|
||||
check_in: Check-in date
|
||||
check_out: Check-out date (check_in + 1 for single night rate)
|
||||
adults: Number of adults
|
||||
pages: Number of result pages to scrape
|
||||
|
||||
Returns:
|
||||
ScraperResult with hotels and rates found
|
||||
"""
|
||||
all_hotels = []
|
||||
all_rates = []
|
||||
seen_hotel_ids = set()
|
||||
|
||||
context = None
|
||||
page = None
|
||||
|
||||
try:
|
||||
context = await self._create_context()
|
||||
page = await context.new_page()
|
||||
|
||||
for page_num in range(pages):
|
||||
# Random delay between pages (3-7 seconds)
|
||||
if page_num > 0:
|
||||
delay = random.uniform(3, 7)
|
||||
logger.info(f"Waiting {delay:.1f}s before page {page_num + 1}")
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Build URL with offset for pagination (25 results per page)
|
||||
url = self._build_search_url(
|
||||
location, check_in, check_out, adults,
|
||||
offset=page_num * 25
|
||||
)
|
||||
|
||||
logger.info(f"Scraping page {page_num + 1}: {url}")
|
||||
|
||||
try:
|
||||
await page.goto(url, wait_until='networkidle', timeout=30000)
|
||||
except Exception as e:
|
||||
logger.warning(f"Page load timeout, continuing: {e}")
|
||||
|
||||
# Check for blocking
|
||||
content = await page.content()
|
||||
is_blocked, reason = self.detect_blocking(content)
|
||||
if is_blocked:
|
||||
logger.warning(f"Blocking detected: {reason}")
|
||||
return ScraperResult(
|
||||
success=False,
|
||||
blocked=True,
|
||||
block_reason=reason,
|
||||
hotels=all_hotels,
|
||||
rates=all_rates,
|
||||
page_content_sample=content[:1000]
|
||||
)
|
||||
|
||||
# Human-like scrolling
|
||||
await self._human_like_scroll(page)
|
||||
|
||||
# Extract data
|
||||
hotels, rates = await self._extract_search_results(page, check_in)
|
||||
|
||||
# Deduplicate by booking_com_id
|
||||
for hotel, rate in zip(hotels, rates):
|
||||
if hotel.booking_com_id and hotel.booking_com_id not in seen_hotel_ids:
|
||||
seen_hotel_ids.add(hotel.booking_com_id)
|
||||
all_hotels.append(hotel)
|
||||
all_rates.append(rate)
|
||||
|
||||
logger.info(f"Page {page_num + 1}: found {len(hotels)} hotels, {len(all_hotels)} total unique")
|
||||
|
||||
return ScraperResult(
|
||||
success=True,
|
||||
blocked=False,
|
||||
hotels=all_hotels,
|
||||
rates=all_rates
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Scrape error: {e}")
|
||||
return ScraperResult(
|
||||
success=False,
|
||||
blocked=False,
|
||||
error_message=str(e),
|
||||
hotels=all_hotels,
|
||||
rates=all_rates
|
||||
)
|
||||
finally:
|
||||
if page:
|
||||
await page.close()
|
||||
if context:
|
||||
await context.close()
|
||||
|
||||
async def scrape_hotel_page(
|
||||
self,
|
||||
hotel_url: str,
|
||||
check_in: date,
|
||||
check_out: date,
|
||||
adults: int = 2
|
||||
) -> ScraperResult:
|
||||
"""
|
||||
Scrape individual hotel page for detailed rates.
|
||||
|
||||
Future expansion - placeholder for now.
|
||||
Will extract available_qty from room dropdowns.
|
||||
"""
|
||||
# Not implemented in Phase 2a
|
||||
logger.warning("scrape_hotel_page not yet implemented")
|
||||
return ScraperResult(
|
||||
success=False,
|
||||
error_message="Hotel page scraping not yet implemented"
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
"""Clean up browser resources."""
|
||||
if self._browser:
|
||||
await self._browser.close()
|
||||
self._browser = None
|
||||
if self._playwright:
|
||||
await self._playwright.stop()
|
||||
self._playwright = None
|
||||
Loading…
Add table
Add a link
Reference in a new issue