From 493e87b2dc58c64ffbeac094c221529c1dbfe4c1 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 6 Jul 2026 07:30:41 +0000 Subject: [PATCH] Rate parity: expected-markup config, alert writer job, alerts UI + direct-link UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity was read-only — the alerts table had no producer, so the Market View badge could never fire. Now: - jobs/check_rate_parity.py: daily 06:45 job comparing own Booking.com lead-in rate vs cheapest Newbook rate per date, measured against an EXPECTED markup (we deliberately price Booking.com higher to cover commission): alert when deviation from newbook*(1+markup%) exceeds the tolerance. Creates/updates active alerts, auto-resolves dates back in line, leaves acknowledged dates alone. - config keys: parity_check_enabled, parity_expected_markup_pct, parity_tolerance_pct (system_config) - POST /competitors/parity/check manual trigger; GET /parity now uses the same markup/tolerance and cheapest-across-categories Newbook rate - Settings -> Rate Parity tab: markup %, tolerance %, enable toggle, run-now with result summary - Market View -> Parity Alerts tab: status-filtered list w/ acknowledge - Market View -> Hotels: direct-link dropdown per competitor (new PUT /competitors/hotels/{id}/direct-link) — closes the never-written direct_hotel_id gap so the matrix direct-rates sub-row can populate Co-Authored-By: Claude Fable 5 --- backend/api/competitors.py | 81 +++++++++++-- backend/jobs/check_rate_parity.py | 176 +++++++++++++++++++++++++++ backend/scheduler.py | 17 ++- frontend/src/pages/MarketView.tsx | 194 +++++++++++++++++++++++++++++- frontend/src/pages/Settings.tsx | 139 +++++++++++++++++++++ 5 files changed, 595 insertions(+), 12 deletions(-) create mode 100644 backend/jobs/check_rate_parity.py diff --git a/backend/api/competitors.py b/backend/api/competitors.py index 332a41b..81a4831 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -551,6 +551,39 @@ async def update_hotel_notes( return {"status": "success"} +class DirectLinkUpdate(BaseModel): + direct_hotel_id: Optional[int] = None + + +@router.put("/hotels/{hotel_id}/direct-link") +async def update_hotel_direct_link( + hotel_id: int, + payload: DirectLinkUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user) +): + """Link (or unlink with null) a Booking.com hotel to a direct booking + engine competitor, enabling the direct-rates sub-row in the rate matrix.""" + if payload.direct_hotel_id is not None: + exists = await db.execute( + text("SELECT 1 FROM direct_competitor_hotels WHERE id = :id"), + {'id': payload.direct_hotel_id} + ) + if not exists.fetchone(): + raise HTTPException(status_code=404, detail="Direct hotel not found") + + result = await db.execute( + text("UPDATE booking_com_hotels SET direct_hotel_id = :did WHERE id = :hotel_id RETURNING id"), + {'hotel_id': hotel_id, 'did': payload.direct_hotel_id} + ) + + if not result.fetchone(): + raise HTTPException(status_code=404, detail="Hotel not found") + + await db.commit() + return {"status": "success"} + + # ============================================ # COMPETITOR RATES MATRIX # ============================================ @@ -711,21 +744,32 @@ async def get_rate_parity( ) booking_rates = {row.rate_date: dict(row._mapping) for row in booking_rates_result.fetchall()} - # Get Newbook rates (best rate per date across categories) + # Get Newbook rates (cheapest per date across latest category snapshots) newbook_rates_result = await db.execute( text(""" - SELECT DISTINCT ON (rate_date) - rate_date, - rate_gross as newbook_rate, - category_id - FROM newbook_current_rates - WHERE rate_date >= :from_date AND rate_date <= :to_date - ORDER BY rate_date, valid_from DESC + 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 >= :from_date AND rate_date <= :to_date + AND rate_gross IS NOT NULL AND rate_gross > 0 + ORDER BY rate_date, category_id, valid_from DESC + ) t GROUP BY rate_date """), {'from_date': start, 'to_date': end} ) newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()} + # Expected-markup config: we deliberately price Booking.com higher to + # cover commission, so parity is measured against newbook × (1 + markup%) + from jobs.check_rate_parity import get_parity_config, deviation_from_expected + from database import SyncSessionLocal + cfg_db = SyncSessionLocal() + try: + cfg = get_parity_config(cfg_db) + finally: + cfg_db.close() + # Compare rates parity_issues = [] all_dates = set(booking_rates.keys()) | set(newbook_rates.keys()) @@ -743,13 +787,17 @@ async def get_rate_parity( if not booking_rate or not newbook_rate: continue - diff_pct = ((float(booking_rate) - float(newbook_rate)) / float(newbook_rate)) * 100 + expected_rate = float(newbook_rate) * (1 + cfg["markup_pct"] / 100) + diff_pct = deviation_from_expected(float(booking_rate), float(newbook_rate), cfg["markup_pct"]) + if diff_pct is None: + continue - if abs(diff_pct) > 1: # More than 1% difference + if abs(diff_pct) > cfg["tolerance_pct"]: parity_issues.append({ 'rate_date': rate_date.isoformat(), 'booking_rate': float(booking_rate), 'newbook_rate': float(newbook_rate), + 'expected_rate': round(expected_rate, 2), 'difference_pct': round(diff_pct, 2), 'alert_type': 'higher' if diff_pct > 0 else 'lower', 'booking_room_type': booking.get('booking_room_type'), @@ -759,11 +807,24 @@ async def get_rate_parity( return { 'from_date': start.isoformat(), 'to_date': end.isoformat(), + 'expected_markup_pct': cfg["markup_pct"], + 'tolerance_pct': cfg["tolerance_pct"], 'issues_count': len(parity_issues), 'issues': parity_issues } +@router.post("/parity/check") +async def trigger_parity_check( + current_user: dict = Depends(get_current_user) +): + """Run the parity check now (same logic as the daily 06:45 job).""" + import asyncio + from jobs.check_rate_parity import run_parity_check + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, run_parity_check) + + # ============================================ # PARITY ALERTS # ============================================ diff --git a/backend/jobs/check_rate_parity.py b/backend/jobs/check_rate_parity.py new file mode 100644 index 0000000..7c06c65 --- /dev/null +++ b/backend/jobs/check_rate_parity.py @@ -0,0 +1,176 @@ +""" +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_expected_markup_pct expected Booking.com premium over Newbook (default 0) + parity_tolerance_pct allowed deviation from expected before alerting (default 2) + +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_expected_markup_pct', + 'parity_tolerance_pct')""") + ).fetchall() + cfg = {r[0]: r[1] for r in rows} + + def num(key: str, default: float) -> float: + try: + return float(cfg.get(key) or default) + except (TypeError, ValueError): + return default + + enabled_raw = (cfg.get('parity_check_enabled') or 'true').lower() + return { + "enabled": enabled_raw in ('true', '1', 'yes', 'enabled'), + "markup_pct": num('parity_expected_markup_pct', 0.0), + "tolerance_pct": num('parity_tolerance_pct', 2.0), + } + + +def deviation_from_expected(booking_rate: float, newbook_rate: float, markup_pct: float) -> float | None: + """% deviation of the actual Booking.com rate from the expected + (Newbook × (1 + markup%)) rate. None if expected is not positive.""" + expected = newbook_rate * (1 + markup_pct / 100) + if expected <= 0: + return None + return (booking_rate - expected) / expected * 100 + + +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: + dev = deviation_from_expected(booking[d]["rate"], newbook[d], cfg["markup_pct"]) + if dev is None: + continue + alert = latest_alert.get(d) + + if abs(dev) > cfg["tolerance_pct"]: + 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_pct": cfg["markup_pct"], + "tolerance_pct": cfg["tolerance_pct"], + } + logger.info(f"Parity check: {summary}") + return summary + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/backend/scheduler.py b/backend/scheduler.py index 26bde98..6ddfef9 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -61,6 +61,13 @@ async def run_scheduled_direct_scrape(): await loop.run_in_executor(None, run_scrape_all_direct) +async def run_scheduled_parity_check(): + from jobs.check_rate_parity import run_parity_check + import asyncio + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, run_parity_check) + + async def run_scheduled_booking_scrape_async(): from jobs.scrape_booking_rates import run_scheduled_booking_scrape import asyncio @@ -116,8 +123,16 @@ def start_scheduler(): replace_existing=True, ) + # Rate parity check — daily at 06:45, after Newbook fetch + Booking scrape + scheduler.add_job( + run_scheduled_parity_check, + CronTrigger(hour=6, minute=45), + id='parity_check', + replace_existing=True, + ) + scheduler.start() - logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00") + logger.info(f"Scheduler started: booking scrape at {scrape_hour:02d}:{scrape_minute:02d}, rates fetch at {rates_hour:02d}:{rates_minute:02d}, direct scrape at 06:00, parity check at 06:45") def shutdown_scheduler(): diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 6e64f8f..a504590 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -254,7 +254,7 @@ const inputLabelStyle: React.CSSProperties = { // TABS // ============================================ -type TabId = 'matrix' | 'hotels' | 'settings' +type TabId = 'matrix' | 'hotels' | 'parity' | 'settings' // ============================================ // STATUS PANEL @@ -733,6 +733,162 @@ const CoverageGrid: React.FC<{ coverage: CoverageResponse }> = ({ coverage }) => // HOTELS TAB // ============================================ +// ============================================ +// PARITY ALERTS TAB +// ============================================ + +interface ParityAlert { + id: number + rate_date: string + room_category: string | null + newbook_rate: number | null + booking_com_rate: number | null + difference_pct: number | null + alert_type: string + alert_status: string + created_at: string | null + acknowledged_at: string | null + acknowledged_by: string | null +} + +const ParityAlertsTab: React.FC = () => { + const queryClient = useQueryClient() + const [statusFilter, setStatusFilter] = useState('active') + + const { data: alerts, isLoading } = useQuery({ + queryKey: ['parity-alerts', statusFilter], + queryFn: async () => { + const params = statusFilter ? `?status=${statusFilter}` : '' + return (await api.get(`/competitors/parity/alerts${params}`)).data + }, + }) + + const { data: parityConfig } = useQuery>({ + queryKey: ['system-config'], + queryFn: async () => (await api.get('/competitors/config/system')).data, + }) + + const ackMutation = useMutation({ + mutationFn: async (alertId: number) => + (await api.put(`/competitors/parity/alerts/${alertId}/acknowledge`)).data, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['parity-alerts'] }) + queryClient.invalidateQueries({ queryKey: ['parity-alert-count'] }) + }, + }) + + const markup = parityConfig?.['parity_expected_markup_pct'] ?? '0' + const tolerance = parityConfig?.['parity_tolerance_pct'] ?? '2' + + const thStyle: React.CSSProperties = { + textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600, + color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.04em', + borderBottom: '1px solid var(--border)', + } + const tdStyle: React.CSSProperties = { + padding: '9px 12px', fontSize: 13, borderBottom: '1px solid var(--border)', + } + + return ( +
+
+ Dates where our Booking.com rate deviates from the expected level + (Newbook rate + {markup}% markup, ±{tolerance}% tolerance). Checked daily at 06:45 — + adjust the markup and tolerance in Settings → Rate Parity. Acknowledge a date once + dealt with; alerts auto-resolve when the rates come back in line. +
+ +
+ Status: + {['active', 'acknowledged', 'resolved', ''].map(s => ( + + ))} +
+ + {isLoading && ( +
Loading alerts...
+ )} + + {!isLoading && (!alerts || alerts.length === 0) && ( +
+

No {statusFilter || ''} parity alerts

+

+ {statusFilter === 'active' + ? 'Booking.com is pricing within the expected band of Newbook.' + : 'Nothing here yet.'} +

+
+ )} + + {!isLoading && alerts && alerts.length > 0 && ( +
+ + + + + + + + + + + + + + {alerts.map(a => ( + + + + + + + + + + ))} + +
DateNewbookBooking.comDeviationRoomStatus
{a.rate_date}{a.newbook_rate != null ? `£${a.newbook_rate.toFixed(2)}` : '—'}{a.booking_com_rate != null ? `£${a.booking_com_rate.toFixed(2)}` : '—'} + {a.difference_pct != null && ( + + {a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}% vs expected + + )} + {a.room_category || '—'} + + {a.alert_status} + + + {a.alert_status === 'active' && ( + + )} + {a.alert_status === 'acknowledged' && a.acknowledged_by && ( + by {a.acknowledged_by} + )} +
+
+ )} +
+ ) +} + const HotelsTab: React.FC = () => { const queryClient = useQueryClient() const [tierFilter, setTierFilter] = useState('') @@ -754,6 +910,24 @@ const HotelsTab: React.FC = () => { }, }) + // Direct booking-engine hotels, for linking (powers the matrix direct-rates sub-row) + const { data: directHotels } = useQuery<{ id: number; name: string }[]>({ + queryKey: ['direct-hotels-link-options'], + queryFn: async () => (await api.get('/direct/hotels')).data, + retry: false, + }) + + const linkMutation = useMutation({ + mutationFn: async ({ hotelId, directId }: { hotelId: number, directId: number | null }) => { + return (await api.put(`/competitors/hotels/${hotelId}/direct-link`, { direct_hotel_id: directId })).data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['competitor-hotels'] }) + queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] }) + queryClient.invalidateQueries({ queryKey: ['market-direct-rates'] }) + }, + }) + const grouped = useMemo(() => { if (!hotels) return { own: [], competitor: [], market: [] } return { @@ -775,6 +949,22 @@ const HotelsTab: React.FC = () => {
+ {hotel.tier === 'competitor' && directHotels && directHotels.length > 0 && ( + + )} onSave('parity_check_enabled', e.target.checked ? 'true' : 'false')} + style={{ width: 15, height: 15, accentColor: 'var(--gold)' }} + /> + Run daily parity check + + +
+
+ + setMarkup(e.target.value)} + placeholder="e.g. 15" + /> +
+ How much higher Booking.com should be than Newbook. +
+
+
+ + setTolerance(e.target.value)} + placeholder="e.g. 2" + /> +
+ Allowed deviation from expected before alerting. +
+
+
+ +
+ + +
+ + {checkResult && ( +
+ {checkResult.status === 'disabled' + ? 'Check is disabled — enable it above first.' + : `Compared ${checkResult.dates_compared} dates — ${checkResult.created} new alerts, ${checkResult.updated} updated, ${checkResult.resolved} resolved.`} +
+ )} +
+ + + ) +} + // ─── Scraper Proxy Tab ──────────────────────────────────────────────────────── interface ProxyConfigData {