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
150
backend/jobs/metrics_aggregation.py
Normal file
150
backend/jobs/metrics_aggregation.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""
|
||||
Metrics aggregation job for forecast_data database
|
||||
Populates daily_metrics from newbook_bookings_stats
|
||||
|
||||
This is the data source for forecasting models (Prophet, XGBoost, CatBoost).
|
||||
"""
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
from database import SyncSessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_metrics_aggregation(
|
||||
from_date: Optional[date] = None,
|
||||
to_date: Optional[date] = None
|
||||
):
|
||||
"""
|
||||
Populate daily_metrics table from newbook_bookings_stats.
|
||||
|
||||
This provides the historical actuals needed for forecasting models.
|
||||
|
||||
Args:
|
||||
from_date: Start date (defaults to 2 years ago)
|
||||
to_date: End date (defaults to yesterday)
|
||||
"""
|
||||
logger.info("Starting metrics aggregation job")
|
||||
|
||||
db = next(iter([SyncSessionLocal()]))
|
||||
|
||||
try:
|
||||
# Default date range: 2 years of history
|
||||
if from_date is None:
|
||||
from_date = date.today() - timedelta(days=730)
|
||||
if to_date is None:
|
||||
to_date = date.today() - timedelta(days=1)
|
||||
|
||||
logger.info(f"Aggregating metrics from {from_date} to {to_date}")
|
||||
|
||||
# Get data from newbook_bookings_stats
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT
|
||||
date,
|
||||
booking_count, -- room nights (occupied rooms)
|
||||
total_occupancy_pct, -- occupancy percentage
|
||||
guests_count, -- total guests
|
||||
adults_count,
|
||||
children_count,
|
||||
rooms_count, -- available rooms
|
||||
bookable_count -- bookable rooms (rooms - maintenance)
|
||||
FROM newbook_bookings_stats
|
||||
WHERE date BETWEEN :from_date AND :to_date
|
||||
ORDER BY date
|
||||
"""),
|
||||
{"from_date": from_date, "to_date": to_date}
|
||||
)
|
||||
stats_rows = result.fetchall()
|
||||
|
||||
if not stats_rows:
|
||||
logger.warning("No data found in newbook_bookings_stats")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(stats_rows)} days of data to aggregate")
|
||||
|
||||
# Metrics to populate
|
||||
metrics_count = 0
|
||||
|
||||
for row in stats_rows:
|
||||
d = row.date
|
||||
|
||||
# Define metrics from newbook_bookings_stats
|
||||
metrics_to_insert = []
|
||||
|
||||
# Room nights (occupied rooms)
|
||||
if row.booking_count is not None:
|
||||
metrics_to_insert.append(("hotel_room_nights", row.booking_count))
|
||||
|
||||
# Occupancy percentage
|
||||
if row.total_occupancy_pct is not None:
|
||||
metrics_to_insert.append(("hotel_occupancy_pct", float(row.total_occupancy_pct)))
|
||||
|
||||
# Guest count
|
||||
if row.guests_count is not None:
|
||||
metrics_to_insert.append(("hotel_guests", row.guests_count))
|
||||
|
||||
# Insert/update all metrics
|
||||
for metric_code, actual_value in metrics_to_insert:
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO daily_metrics (date, metric_code, actual_value, source, updated_at)
|
||||
VALUES (:date, :metric_code, :actual_value, 'newbook', NOW())
|
||||
ON CONFLICT (date, metric_code) DO UPDATE SET
|
||||
actual_value = :actual_value,
|
||||
updated_at = NOW()
|
||||
"""),
|
||||
{
|
||||
"date": d,
|
||||
"metric_code": metric_code,
|
||||
"actual_value": actual_value
|
||||
}
|
||||
)
|
||||
metrics_count += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Aggregated {metrics_count} metric records from {len(stats_rows)} days")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Metrics aggregation failed: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def backfill_daily_metrics():
|
||||
"""
|
||||
Backfill all available history from newbook_bookings_stats to daily_metrics.
|
||||
Call this once when setting up forecasting on forecast_data database.
|
||||
"""
|
||||
logger.info("Starting full backfill of daily_metrics")
|
||||
|
||||
db = next(iter([SyncSessionLocal()]))
|
||||
|
||||
try:
|
||||
# Find the earliest date in newbook_bookings_stats
|
||||
result = db.execute(text("SELECT MIN(date) as min_date FROM newbook_bookings_stats"))
|
||||
row = result.fetchone()
|
||||
|
||||
if not row or not row.min_date:
|
||||
logger.warning("No data in newbook_bookings_stats to backfill")
|
||||
return
|
||||
|
||||
from_date = row.min_date
|
||||
to_date = date.today() - timedelta(days=1)
|
||||
|
||||
logger.info(f"Backfilling from {from_date} to {to_date}")
|
||||
|
||||
await run_metrics_aggregation(from_date=from_date, to_date=to_date)
|
||||
|
||||
logger.info("Backfill completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backfill failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
Loading…
Add table
Add a link
Reference in a new issue