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
|
|
@ -7,6 +7,7 @@ import api from '../api'
|
|||
const TABS = [
|
||||
{ id: 'newbook', label: 'Newbook Sync' },
|
||||
{ id: 'proxy', label: 'Scraper Proxy' },
|
||||
{ id: 'parity', label: 'Rate Parity' },
|
||||
{ id: 'system', label: 'System' },
|
||||
]
|
||||
|
||||
|
|
@ -81,6 +82,15 @@ export default function Settings() {
|
|||
<ProxyTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'parity' && (
|
||||
<ParityTab
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
onSave={(key, val) => saveMutation.mutate({ key, value: val })}
|
||||
saving={saveMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'system' && (
|
||||
<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 ────────────────────────────────────────────────────────
|
||||
|
||||
interface ProxyConfigData {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue