""" Rate Parity Check Job Compares our own hotel's scraped Booking.com rate against our Newbook rate for each date, allowing for a configured expected markup — we deliberately price Booking.com higher to cover commission, so parity is measured against expected = newbook × (1 + markup%), not raw equality. Deviations beyond the tolerance are persisted as rate_parity_alerts rows (feeding the Market View badge); dates back within tolerance auto-resolve their active alert. Acknowledged alerts are left alone — acknowledging a date suppresses re-alerting for it until the alert is resolved by the rates coming back in line. Config (system_config): parity_check_enabled true/false (default true) parity_markup_value expected Booking.com premium over Newbook (default 0) parity_markup_unit 'pct' or 'gbp' (default pct) parity_tolerance_value allowed deviation from expected before alerting (default 2) parity_tolerance_unit 'pct' or 'gbp' (default pct) (legacy fallbacks: parity_expected_markup_pct, parity_tolerance_pct) Schedule: daily at 06:45, after the 05:20 Newbook fetch and 05:30 scrape. """ import logging from datetime import date, timedelta from sqlalchemy import text from database import SyncSessionLocal logger = logging.getLogger(__name__) HORIZON_DAYS = 90 def get_parity_config(db) -> dict: rows = db.execute( text("""SELECT config_key, config_value FROM system_config WHERE config_key IN ('parity_check_enabled', 'parity_markup_value', 'parity_markup_unit', 'parity_tolerance_value', 'parity_tolerance_unit', 'parity_expected_markup_pct', 'parity_tolerance_pct')""") ).fetchall() cfg = {r[0]: r[1] for r in rows} def num(key: str, default: float, legacy_key: str = None) -> float: raw = cfg.get(key) if raw in (None, '') and legacy_key: raw = cfg.get(legacy_key) try: return float(raw if raw not in (None, '') else default) except (TypeError, ValueError): return default def unit(key: str) -> str: return 'gbp' if (cfg.get(key) or 'pct').lower() in ('gbp', '£', 'abs') else 'pct' enabled_raw = (cfg.get('parity_check_enabled') or 'true').lower() return { "enabled": enabled_raw in ('true', '1', 'yes', 'enabled'), "markup_value": num('parity_markup_value', 0.0, 'parity_expected_markup_pct'), "markup_unit": unit('parity_markup_unit'), "tolerance_value": num('parity_tolerance_value', 2.0, 'parity_tolerance_pct'), "tolerance_unit": unit('parity_tolerance_unit'), } def expected_booking_rate(newbook_rate: float, cfg: dict) -> float: """Expected Booking.com rate: Newbook + markup (% or flat £).""" if cfg["markup_unit"] == 'gbp': return newbook_rate + cfg["markup_value"] return newbook_rate * (1 + cfg["markup_value"] / 100) def evaluate_parity(booking_rate: float, newbook_rate: float, cfg: dict): """Compare actual Booking.com rate against expected. Returns (deviation_pct, deviation_gbp, breach) or None if expected invalid. The breach test uses the tolerance in its own unit (% or £).""" expected = expected_booking_rate(newbook_rate, cfg) if expected <= 0: return None dev_gbp = booking_rate - expected dev_pct = dev_gbp / expected * 100 if cfg["tolerance_unit"] == 'gbp': breach = abs(dev_gbp) > cfg["tolerance_value"] else: breach = abs(dev_pct) > cfg["tolerance_value"] return dev_pct, dev_gbp, breach def run_parity_check() -> dict: db = SyncSessionLocal() try: cfg = get_parity_config(db) if not cfg["enabled"]: logger.info("Parity check skipped (disabled)") return {"status": "disabled"} start = date.today() end = start + timedelta(days=HORIZON_DAYS) # Own hotel's latest Booking.com lead-in rate per date booking_rows = db.execute(text(""" SELECT DISTINCT ON (r.rate_date) r.rate_date, r.rate_gross, r.room_type FROM booking_com_rates r JOIN booking_com_hotels h ON r.hotel_id = h.id WHERE h.tier = 'own' AND r.rate_date BETWEEN :fd AND :td AND r.rate_gross IS NOT NULL ORDER BY r.rate_date, r.scraped_at DESC """), {"fd": start, "td": end}).fetchall() booking = {r[0]: {"rate": float(r[1]), "room": r[2]} for r in booking_rows} # Cheapest current Newbook rate per date (latest snapshot per category) newbook_rows = db.execute(text(""" SELECT rate_date, MIN(rate_gross) AS newbook_rate FROM ( SELECT DISTINCT ON (rate_date, category_id) rate_date, rate_gross FROM newbook_current_rates WHERE rate_date BETWEEN :fd AND :td AND rate_gross IS NOT NULL AND rate_gross > 0 ORDER BY rate_date, category_id, valid_from DESC ) t GROUP BY rate_date """), {"fd": start, "td": end}).fetchall() newbook = {r[0]: float(r[1]) for r in newbook_rows} # Latest alert per date in the horizon alert_rows = db.execute(text(""" SELECT DISTINCT ON (rate_date) id, rate_date, alert_status, difference_pct FROM rate_parity_alerts WHERE rate_date BETWEEN :fd AND :td ORDER BY rate_date, created_at DESC """), {"fd": start, "td": end}).fetchall() latest_alert = {r[1]: {"id": r[0], "status": r[2], "diff": float(r[3] or 0)} for r in alert_rows} common_dates = sorted(set(booking) & set(newbook)) created = updated = resolved = 0 for d in common_dates: result = evaluate_parity(booking[d]["rate"], newbook[d], cfg) if result is None: continue dev, _dev_gbp, breach = result alert = latest_alert.get(d) if breach: if alert and alert["status"] == "active": if round(dev, 2) != round(alert["diff"], 2): db.execute(text(""" UPDATE rate_parity_alerts SET newbook_rate = :nb, booking_com_rate = :bc, difference_pct = :diff, alert_type = :atype, room_category = :room WHERE id = :id """), { "id": alert["id"], "nb": newbook[d], "bc": booking[d]["rate"], "diff": round(dev, 2), "atype": "higher" if dev > 0 else "lower", "room": booking[d]["room"], }) updated += 1 elif alert and alert["status"] == "acknowledged": pass # user has dealt with this date — don't nag else: db.execute(text(""" INSERT INTO rate_parity_alerts (rate_date, room_category, newbook_rate, booking_com_rate, difference_pct, alert_type, alert_status) VALUES (:date, :room, :nb, :bc, :diff, :atype, 'active') """), { "date": d, "room": booking[d]["room"], "nb": newbook[d], "bc": booking[d]["rate"], "diff": round(dev, 2), "atype": "higher" if dev > 0 else "lower", }) created += 1 else: if alert and alert["status"] in ("active", "acknowledged"): db.execute( text("UPDATE rate_parity_alerts SET alert_status = 'resolved' WHERE id = :id"), {"id": alert["id"]} ) resolved += 1 db.commit() summary = { "status": "ok", "dates_compared": len(common_dates), "created": created, "updated": updated, "resolved": resolved, "markup_value": cfg["markup_value"], "markup_unit": cfg["markup_unit"], "tolerance_value": cfg["tolerance_value"], "tolerance_unit": cfg["tolerance_unit"], } logger.info(f"Parity check: {summary}") return summary except Exception: db.rollback() raise finally: db.close()