Show own hotel's direct rate (Newbook best available) in the matrix direct sub-row
New GET /competitors/own-direct-rates: cheapest bookable non-dinner Newbook tariff per date (same selection rules as the parity check). 'Show direct rates' now renders a 'Direct (Newbook)' sub-row under the own-hotel row alongside the competitors' scraped direct rows; cell tooltip names the tariff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ad0e82465c
commit
2abc2b0297
2 changed files with 91 additions and 0 deletions
|
|
@ -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")
|
@router.post("/parity/check")
|
||||||
async def trigger_parity_check(
|
async def trigger_parity_check(
|
||||||
current_user: dict = Depends(get_current_user)
|
current_user: dict = Depends(get_current_user)
|
||||||
|
|
|
||||||
|
|
@ -1312,6 +1312,18 @@ const RateMatrixTab: React.FC = () => {
|
||||||
enabled: showDirect && hotels.length > 0,
|
enabled: showDirect && hotels.length > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Our own direct rate comes from Newbook (best available), not the direct scraper
|
||||||
|
const { data: ownDirectRates } = useQuery<Record<string, { rate: number; tariff: string }>>({
|
||||||
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div style={styles.loading}>
|
<div style={styles.loading}>
|
||||||
|
|
@ -1600,6 +1612,25 @@ const RateMatrixTab: React.FC = () => {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tr>
|
</tr>
|
||||||
|
{/* Own hotel direct sub-row — Newbook best available */}
|
||||||
|
{showDirect && hotel.tier === 'own' && ownDirectRates &&
|
||||||
|
Object.keys(ownDirectRates).length > 0 && (
|
||||||
|
<tr style={{ background: '#fafbfc' }}>
|
||||||
|
<td style={{ ...styles.matrixTd, ...styles.stickyCol, paddingLeft: 28, fontSize: 11, color: 'var(--text-mid)', fontStyle: 'italic' }}>
|
||||||
|
Direct (Newbook)
|
||||||
|
</td>
|
||||||
|
{dates.map(d => {
|
||||||
|
const own = ownDirectRates[d]
|
||||||
|
return (
|
||||||
|
<td key={d} title={own?.tariff}
|
||||||
|
style={{ ...styles.matrixTd, fontSize: 11, color: own ? 'var(--text-dark)' : 'var(--text-mid)',
|
||||||
|
background: isWeekend(d) ? '#fdf8f0' : undefined }}>
|
||||||
|
{own ? `£${Number(own.rate).toFixed(0)}` : '—'}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{/* Direct rates sub-row — only when this hotel actually has direct rates */}
|
{/* Direct rates sub-row — only when this hotel actually has direct rates */}
|
||||||
{showDirect && hotel.tier === 'competitor' &&
|
{showDirect && hotel.tier === 'competitor' &&
|
||||||
Object.values(directRatesMap?.[hotel.id] || {}).some(v => v != null) && (
|
Object.values(directRatesMap?.[hotel.id] || {}).some(v => v != null) && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue