Rate parity: expected-markup config, alert writer job, alerts UI + direct-link UI
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 <noreply@anthropic.com>
This commit is contained in:
parent
029e3b379f
commit
493e87b2dc
5 changed files with 595 additions and 12 deletions
|
|
@ -551,6 +551,39 @@ async def update_hotel_notes(
|
||||||
return {"status": "success"}
|
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
|
# 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()}
|
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(
|
newbook_rates_result = await db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT DISTINCT ON (rate_date)
|
SELECT rate_date, MIN(rate_gross) AS newbook_rate FROM (
|
||||||
rate_date,
|
SELECT DISTINCT ON (rate_date, category_id)
|
||||||
rate_gross as newbook_rate,
|
rate_date, rate_gross
|
||||||
category_id
|
FROM newbook_current_rates
|
||||||
FROM newbook_current_rates
|
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||||
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
AND rate_gross IS NOT NULL AND rate_gross > 0
|
||||||
ORDER BY rate_date, valid_from DESC
|
ORDER BY rate_date, category_id, valid_from DESC
|
||||||
|
) t GROUP BY rate_date
|
||||||
"""),
|
"""),
|
||||||
{'from_date': start, 'to_date': end}
|
{'from_date': start, 'to_date': end}
|
||||||
)
|
)
|
||||||
newbook_rates = {row.rate_date: dict(row._mapping) for row in newbook_rates_result.fetchall()}
|
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
|
# Compare rates
|
||||||
parity_issues = []
|
parity_issues = []
|
||||||
all_dates = set(booking_rates.keys()) | set(newbook_rates.keys())
|
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:
|
if not booking_rate or not newbook_rate:
|
||||||
continue
|
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({
|
parity_issues.append({
|
||||||
'rate_date': rate_date.isoformat(),
|
'rate_date': rate_date.isoformat(),
|
||||||
'booking_rate': float(booking_rate),
|
'booking_rate': float(booking_rate),
|
||||||
'newbook_rate': float(newbook_rate),
|
'newbook_rate': float(newbook_rate),
|
||||||
|
'expected_rate': round(expected_rate, 2),
|
||||||
'difference_pct': round(diff_pct, 2),
|
'difference_pct': round(diff_pct, 2),
|
||||||
'alert_type': 'higher' if diff_pct > 0 else 'lower',
|
'alert_type': 'higher' if diff_pct > 0 else 'lower',
|
||||||
'booking_room_type': booking.get('booking_room_type'),
|
'booking_room_type': booking.get('booking_room_type'),
|
||||||
|
|
@ -759,11 +807,24 @@ async def get_rate_parity(
|
||||||
return {
|
return {
|
||||||
'from_date': start.isoformat(),
|
'from_date': start.isoformat(),
|
||||||
'to_date': end.isoformat(),
|
'to_date': end.isoformat(),
|
||||||
|
'expected_markup_pct': cfg["markup_pct"],
|
||||||
|
'tolerance_pct': cfg["tolerance_pct"],
|
||||||
'issues_count': len(parity_issues),
|
'issues_count': len(parity_issues),
|
||||||
'issues': 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
|
# PARITY ALERTS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
|
||||||
176
backend/jobs/check_rate_parity.py
Normal file
176
backend/jobs/check_rate_parity.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -61,6 +61,13 @@ async def run_scheduled_direct_scrape():
|
||||||
await loop.run_in_executor(None, run_scrape_all_direct)
|
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():
|
async def run_scheduled_booking_scrape_async():
|
||||||
from jobs.scrape_booking_rates import run_scheduled_booking_scrape
|
from jobs.scrape_booking_rates import run_scheduled_booking_scrape
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -116,8 +123,16 @@ def start_scheduler():
|
||||||
replace_existing=True,
|
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()
|
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():
|
def shutdown_scheduler():
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,7 @@ const inputLabelStyle: React.CSSProperties = {
|
||||||
// TABS
|
// TABS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
type TabId = 'matrix' | 'hotels' | 'settings'
|
type TabId = 'matrix' | 'hotels' | 'parity' | 'settings'
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// STATUS PANEL
|
// STATUS PANEL
|
||||||
|
|
@ -733,6 +733,162 @@ const CoverageGrid: React.FC<{ coverage: CoverageResponse }> = ({ coverage }) =>
|
||||||
// HOTELS TAB
|
// 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<string>('active')
|
||||||
|
|
||||||
|
const { data: alerts, isLoading } = useQuery<ParityAlert[]>({
|
||||||
|
queryKey: ['parity-alerts', statusFilter],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = statusFilter ? `?status=${statusFilter}` : ''
|
||||||
|
return (await api.get(`/competitors/parity/alerts${params}`)).data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: parityConfig } = useQuery<Record<string, string | null>>({
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-mid)', marginBottom: 14, maxWidth: 720 }}>
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 14, alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-mid)' }}>Status:</span>
|
||||||
|
{['active', 'acknowledged', 'resolved', ''].map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
style={mergeStyles(
|
||||||
|
buttonStyle(statusFilter === s ? 'secondary' : 'outline', 'small'),
|
||||||
|
statusFilter === s ? {} : { opacity: 0.7 }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s || 'All'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div style={styles.loading}><div style={styles.spinner} /><span>Loading alerts...</span></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && (!alerts || alerts.length === 0) && (
|
||||||
|
<div style={styles.emptyState}>
|
||||||
|
<h3 style={{ margin: 0, color: 'var(--text-dark)' }}>No {statusFilter || ''} parity alerts</h3>
|
||||||
|
<p style={{ color: 'var(--text-mid)', margin: '8px 0 0' }}>
|
||||||
|
{statusFilter === 'active'
|
||||||
|
? 'Booking.com is pricing within the expected band of Newbook.'
|
||||||
|
: 'Nothing here yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && alerts && alerts.length > 0 && (
|
||||||
|
<div style={{ background: 'var(--white, #fff)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={thStyle}>Date</th>
|
||||||
|
<th style={thStyle}>Newbook</th>
|
||||||
|
<th style={thStyle}>Booking.com</th>
|
||||||
|
<th style={thStyle}>Deviation</th>
|
||||||
|
<th style={thStyle}>Room</th>
|
||||||
|
<th style={thStyle}>Status</th>
|
||||||
|
<th style={thStyle}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{alerts.map(a => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td style={mergeStyles(tdStyle, { fontWeight: 600 })}>{a.rate_date}</td>
|
||||||
|
<td style={tdStyle}>{a.newbook_rate != null ? `£${a.newbook_rate.toFixed(2)}` : '—'}</td>
|
||||||
|
<td style={tdStyle}>{a.booking_com_rate != null ? `£${a.booking_com_rate.toFixed(2)}` : '—'}</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
{a.difference_pct != null && (
|
||||||
|
<span style={badgeStyle(a.alert_type === 'higher' ? 'warning' : 'error')}>
|
||||||
|
{a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}% vs expected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={mergeStyles(tdStyle, { color: 'var(--text-mid)', fontSize: 12 })}>{a.room_category || '—'}</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<span style={badgeStyle(
|
||||||
|
a.alert_status === 'active' ? 'error'
|
||||||
|
: a.alert_status === 'acknowledged' ? 'info' : 'success'
|
||||||
|
)}>
|
||||||
|
{a.alert_status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
{a.alert_status === 'active' && (
|
||||||
|
<button
|
||||||
|
style={buttonStyle('outline', 'small')}
|
||||||
|
disabled={ackMutation.isPending}
|
||||||
|
onClick={() => ackMutation.mutate(a.id)}
|
||||||
|
>
|
||||||
|
Acknowledge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{a.alert_status === 'acknowledged' && a.acknowledged_by && (
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-mid)' }}>by {a.acknowledged_by}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const HotelsTab: React.FC = () => {
|
const HotelsTab: React.FC = () => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [tierFilter, setTierFilter] = useState<string>('')
|
const [tierFilter, setTierFilter] = useState<string>('')
|
||||||
|
|
@ -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(() => {
|
const grouped = useMemo(() => {
|
||||||
if (!hotels) return { own: [], competitor: [], market: [] }
|
if (!hotels) return { own: [], competitor: [], market: [] }
|
||||||
return {
|
return {
|
||||||
|
|
@ -775,6 +949,22 @@ const HotelsTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.hotelActions}>
|
<div style={styles.hotelActions}>
|
||||||
|
{hotel.tier === 'competitor' && directHotels && directHotels.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={hotel.direct_hotel_id ?? ''}
|
||||||
|
onChange={e => linkMutation.mutate({
|
||||||
|
hotelId: hotel.id,
|
||||||
|
directId: e.target.value ? parseInt(e.target.value) : null,
|
||||||
|
})}
|
||||||
|
title="Link to a direct booking-engine competitor to show their direct rates in the matrix"
|
||||||
|
style={mergeStyles(styles.tierSelect, { maxWidth: 190 })}
|
||||||
|
>
|
||||||
|
<option value="">No direct link</option>
|
||||||
|
{directHotels.map(d => (
|
||||||
|
<option key={d.id} value={d.id}>Direct: {d.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<select
|
<select
|
||||||
value={hotel.tier}
|
value={hotel.tier}
|
||||||
onChange={e => tierMutation.mutate({ hotelId: hotel.id, tier: e.target.value })}
|
onChange={e => tierMutation.mutate({ hotelId: hotel.id, tier: e.target.value })}
|
||||||
|
|
@ -1455,6 +1645,7 @@ const CompetitorRates: React.FC = () => {
|
||||||
const tabs: { id: TabId; label: string }[] = [
|
const tabs: { id: TabId; label: string }[] = [
|
||||||
{ id: 'matrix', label: 'Rate Matrix' },
|
{ id: 'matrix', label: 'Rate Matrix' },
|
||||||
{ id: 'hotels', label: 'Hotels' },
|
{ id: 'hotels', label: 'Hotels' },
|
||||||
|
{ id: 'parity', label: 'Parity Alerts' },
|
||||||
{ id: 'settings', label: 'Scraper Settings' },
|
{ id: 'settings', label: 'Scraper Settings' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1493,6 +1684,7 @@ const CompetitorRates: React.FC = () => {
|
||||||
<div style={styles.tabContent}>
|
<div style={styles.tabContent}>
|
||||||
{activeTab === 'matrix' && <RateMatrixTab />}
|
{activeTab === 'matrix' && <RateMatrixTab />}
|
||||||
{activeTab === 'hotels' && <HotelsTab />}
|
{activeTab === 'hotels' && <HotelsTab />}
|
||||||
|
{activeTab === 'parity' && <ParityAlertsTab />}
|
||||||
{activeTab === 'settings' && <SettingsTab />}
|
{activeTab === 'settings' && <SettingsTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import api from '../api'
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'newbook', label: 'Newbook Sync' },
|
{ id: 'newbook', label: 'Newbook Sync' },
|
||||||
{ id: 'proxy', label: 'Scraper Proxy' },
|
{ id: 'proxy', label: 'Scraper Proxy' },
|
||||||
|
{ id: 'parity', label: 'Rate Parity' },
|
||||||
{ id: 'system', label: 'System' },
|
{ id: 'system', label: 'System' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -81,6 +82,15 @@ export default function Settings() {
|
||||||
<ProxyTab />
|
<ProxyTab />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'parity' && (
|
||||||
|
<ParityTab
|
||||||
|
config={config}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onSave={(key, val) => saveMutation.mutate({ key, value: val })}
|
||||||
|
saving={saveMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'system' && (
|
{activeTab === 'system' && (
|
||||||
<SystemTab config={config} isLoading={isLoading} />
|
<SystemTab config={config} isLoading={isLoading} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -88,6 +98,135 @@ export default function Settings() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Rate Parity Tab ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ParityCheckResult {
|
||||||
|
status: string
|
||||||
|
dates_compared?: number
|
||||||
|
created?: number
|
||||||
|
updated?: number
|
||||||
|
resolved?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParityTab({ config, isLoading, onSave, saving }: {
|
||||||
|
config: SystemConfig | undefined
|
||||||
|
isLoading: boolean
|
||||||
|
onSave: (key: string, value: string) => void
|
||||||
|
saving: boolean
|
||||||
|
}) {
|
||||||
|
const [markup, setMarkup] = useState('')
|
||||||
|
const [tolerance, setTolerance] = useState('')
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [checkResult, setCheckResult] = useState<ParityCheckResult | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && !loaded) {
|
||||||
|
setMarkup(config['parity_expected_markup_pct'] ?? '0')
|
||||||
|
setTolerance(config['parity_tolerance_pct'] ?? '2')
|
||||||
|
setLoaded(true)
|
||||||
|
}
|
||||||
|
}, [config, loaded])
|
||||||
|
|
||||||
|
const enabled = (config?.['parity_check_enabled'] ?? 'true').toLowerCase() !== 'false'
|
||||||
|
|
||||||
|
const runCheck = useMutation({
|
||||||
|
mutationFn: () => api.post('/competitors/parity/check').then(r => r.data),
|
||||||
|
onSuccess: (data) => setCheckResult(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <div className="loading-state"><div className="spinner" />Loading…</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 720 }}>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<ShieldCheck size={15} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 6 }} />
|
||||||
|
Rate Parity Check
|
||||||
|
</div>
|
||||||
|
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-mid)' }}>
|
||||||
|
Compares our own hotel's Booking.com rate against the Newbook rate each morning (06:45).
|
||||||
|
We deliberately price Booking.com higher to cover commission, so the check measures against
|
||||||
|
an <strong>expected markup</strong> rather than raw equality: alert when Booking.com deviates
|
||||||
|
from Newbook × (1 + markup) by more than the tolerance. Alerts appear on the Market View
|
||||||
|
badge; acknowledging a date suppresses re-alerts for it, and dates that come back in line
|
||||||
|
auto-resolve.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={e => onSave('parity_check_enabled', e.target.checked ? 'true' : 'false')}
|
||||||
|
style={{ width: 15, height: 15, accentColor: 'var(--gold)' }}
|
||||||
|
/>
|
||||||
|
Run daily parity check
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||||
|
Expected Booking.com markup (%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number" step="0.5" style={{ width: 140 }}
|
||||||
|
value={markup} onChange={e => setMarkup(e.target.value)}
|
||||||
|
placeholder="e.g. 15"
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||||
|
How much higher Booking.com should be than Newbook.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||||
|
Tolerance (± %)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number" step="0.5" style={{ width: 140 }}
|
||||||
|
value={tolerance} onChange={e => setTolerance(e.target.value)}
|
||||||
|
placeholder="e.g. 2"
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||||
|
Allowed deviation from expected before alerting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => {
|
||||||
|
onSave('parity_expected_markup_pct', markup || '0')
|
||||||
|
onSave('parity_tolerance_pct', tolerance || '2')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Save size={13} strokeWidth={1.75} />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline"
|
||||||
|
disabled={runCheck.isPending}
|
||||||
|
onClick={() => runCheck.mutate()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={13} strokeWidth={1.75} />
|
||||||
|
{runCheck.isPending ? 'Checking…' : 'Run check now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checkResult && (
|
||||||
|
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, border: '1px solid #bbf7d0', fontSize: 13 }}>
|
||||||
|
{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.`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Scraper Proxy Tab ────────────────────────────────────────────────────────
|
// ─── Scraper Proxy Tab ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ProxyConfigData {
|
interface ProxyConfigData {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue