diff --git a/backend/api/competitors.py b/backend/api/competitors.py index ac90d75..fec44d1 100644 --- a/backend/api/competitors.py +++ b/backend/api/competitors.py @@ -770,6 +770,66 @@ async def get_rate_parity( } +@router.get("/own-direct-rates") +async def get_own_direct_rates( + from_date: Optional[str] = None, + to_date: Optional[str] = None, + current_user: dict = Depends(get_current_user) +): + """Our own hotel's best-available direct rate per date, from Newbook — + cheapest bookable non-dinner tariff (same selection rules as the parity + check). Used for the own-hotel direct sub-row in the rate matrix.""" + import asyncio + from jobs.check_rate_parity import _candidate_tariffs + from database import SyncSessionLocal + + today = date.today() + start = date.fromisoformat(from_date) if from_date else today + end = date.fromisoformat(to_date) if to_date else today + timedelta(days=30) + + def _run(): + sdb = SyncSessionLocal() + try: + rows = sdb.execute(text(""" + SELECT DISTINCT ON (rate_date, category_id) + rate_date, category_id, rate_gross, tariffs_data + FROM newbook_current_rates + WHERE rate_date BETWEEN :fd AND :td + ORDER BY rate_date, category_id, valid_from DESC + """), {"fd": start, "td": end}).mappings().all() + finally: + sdb.close() + + by_date: dict = {} + for r in rows: + by_date.setdefault(r["rate_date"], []).append(r) + + out = {} + for d, cat_rows in by_date.items(): + days_ahead = (d - today).days + candidates = [] + for cat in cat_rows: + candidates.extend(_candidate_tariffs(cat["tariffs_data"], days_ahead)) + pool = [c for c in candidates if not c["dinner"]] or candidates + if pool: + best = min(pool, key=lambda c: c["rate"]) + out[d.isoformat()] = {"rate": best["rate"], "tariff": best["name"]} + else: + headline = [float(c["rate_gross"]) for c in cat_rows + if c["rate_gross"] and float(c["rate_gross"]) > 0] + if headline: + out[d.isoformat()] = {"rate": min(headline), "tariff": "headline rate"} + return out + + loop = asyncio.get_event_loop() + rates = await loop.run_in_executor(None, _run) + return { + "from_date": start.isoformat(), + "to_date": end.isoformat(), + "rates": rates, + } + + @router.post("/parity/check") async def trigger_parity_check( current_user: dict = Depends(get_current_user) diff --git a/frontend/src/pages/MarketView.tsx b/frontend/src/pages/MarketView.tsx index 69903a6..4f486e1 100644 --- a/frontend/src/pages/MarketView.tsx +++ b/frontend/src/pages/MarketView.tsx @@ -1312,6 +1312,18 @@ const RateMatrixTab: React.FC = () => { enabled: showDirect && hotels.length > 0, }) + // Our own direct rate comes from Newbook (best available), not the direct scraper + const { data: ownDirectRates } = useQuery>({ + queryKey: ['own-direct-rates', fromDate, toDate], + queryFn: async () => { + const res = await api.get('/competitors/own-direct-rates', { + params: { from_date: fromDate, to_date: toDate } + }) + return res.data.rates + }, + enabled: showDirect, + }) + if (isLoading) { return (
@@ -1600,6 +1612,25 @@ const RateMatrixTab: React.FC = () => { ) })} + {/* Own hotel direct sub-row — Newbook best available */} + {showDirect && hotel.tier === 'own' && ownDirectRates && + Object.keys(ownDirectRates).length > 0 && ( + + + Direct (Newbook) + + {dates.map(d => { + const own = ownDirectRates[d] + return ( + + {own ? `£${Number(own.rate).toFixed(0)}` : '—'} + + ) + })} + + )} {/* Direct rates sub-row — only when this hotel actually has direct rates */} {showDirect && hotel.tier === 'competitor' && Object.values(directRatesMap?.[hotel.id] || {}).some(v => v != null) && (