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:
jtricerolph 2026-07-06 07:30:41 +00:00
parent 029e3b379f
commit 493e87b2dc
5 changed files with 595 additions and 12 deletions

View file

@ -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<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 queryClient = useQueryClient()
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(() => {
if (!hotels) return { own: [], competitor: [], market: [] }
return {
@ -775,6 +949,22 @@ const HotelsTab: React.FC = () => {
</div>
</div>
<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
value={hotel.tier}
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 }[] = [
{ id: 'matrix', label: 'Rate Matrix' },
{ id: 'hotels', label: 'Hotels' },
{ id: 'parity', label: 'Parity Alerts' },
{ id: 'settings', label: 'Scraper Settings' },
]
@ -1493,6 +1684,7 @@ const CompetitorRates: React.FC = () => {
<div style={styles.tabContent}>
{activeTab === 'matrix' && <RateMatrixTab />}
{activeTab === 'hotels' && <HotelsTab />}
{activeTab === 'parity' && <ParityAlertsTab />}
{activeTab === 'settings' && <SettingsTab />}
</div>
</div>