Parity markup and tolerance configurable as % or flat £
- config: parity_markup_value/_unit + parity_tolerance_value/_unit (pct|gbp), legacy *_pct keys still read as fallback - expected rate = newbook + £X or newbook × (1 + X%); breach test uses the tolerance in its own unit - Settings parity tab: unit selects + live worked example line - Parity Alerts tab: unit-aware description, badge now shows £ deviation alongside % Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
493e87b2dc
commit
9545fd651d
4 changed files with 131 additions and 53 deletions
|
|
@ -761,8 +761,8 @@ async def get_rate_parity(
|
||||||
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
|
# Expected-markup config: we deliberately price Booking.com higher to
|
||||||
# cover commission, so parity is measured against newbook × (1 + markup%)
|
# cover commission, so parity is measured against Newbook + markup (% or £)
|
||||||
from jobs.check_rate_parity import get_parity_config, deviation_from_expected
|
from jobs.check_rate_parity import get_parity_config, expected_booking_rate, evaluate_parity
|
||||||
from database import SyncSessionLocal
|
from database import SyncSessionLocal
|
||||||
cfg_db = SyncSessionLocal()
|
cfg_db = SyncSessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|
@ -787,18 +787,19 @@ async def get_rate_parity(
|
||||||
if not booking_rate or not newbook_rate:
|
if not booking_rate or not newbook_rate:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
expected_rate = float(newbook_rate) * (1 + cfg["markup_pct"] / 100)
|
result = evaluate_parity(float(booking_rate), float(newbook_rate), cfg)
|
||||||
diff_pct = deviation_from_expected(float(booking_rate), float(newbook_rate), cfg["markup_pct"])
|
if result is None:
|
||||||
if diff_pct is None:
|
|
||||||
continue
|
continue
|
||||||
|
diff_pct, diff_gbp, breach = result
|
||||||
|
|
||||||
if abs(diff_pct) > cfg["tolerance_pct"]:
|
if breach:
|
||||||
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),
|
'expected_rate': round(expected_booking_rate(float(newbook_rate), cfg), 2),
|
||||||
'difference_pct': round(diff_pct, 2),
|
'difference_pct': round(diff_pct, 2),
|
||||||
|
'difference_gbp': round(diff_gbp, 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'),
|
||||||
'availability_status': booking.get('availability_status'),
|
'availability_status': booking.get('availability_status'),
|
||||||
|
|
@ -807,8 +808,10 @@ 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"],
|
'markup_value': cfg["markup_value"],
|
||||||
'tolerance_pct': cfg["tolerance_pct"],
|
'markup_unit': cfg["markup_unit"],
|
||||||
|
'tolerance_value': cfg["tolerance_value"],
|
||||||
|
'tolerance_unit': cfg["tolerance_unit"],
|
||||||
'issues_count': len(parity_issues),
|
'issues_count': len(parity_issues),
|
||||||
'issues': parity_issues
|
'issues': parity_issues
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,12 @@ re-alerting for it until the alert is resolved by the rates coming back
|
||||||
in line.
|
in line.
|
||||||
|
|
||||||
Config (system_config):
|
Config (system_config):
|
||||||
parity_check_enabled true/false (default true)
|
parity_check_enabled true/false (default true)
|
||||||
parity_expected_markup_pct expected Booking.com premium over Newbook (default 0)
|
parity_markup_value expected Booking.com premium over Newbook (default 0)
|
||||||
parity_tolerance_pct allowed deviation from expected before alerting (default 2)
|
parity_markup_unit 'pct' or 'gbp' (default pct)
|
||||||
|
parity_tolerance_value allowed deviation from expected before alerting (default 2)
|
||||||
|
parity_tolerance_unit 'pct' or 'gbp' (default pct)
|
||||||
|
(legacy fallbacks: parity_expected_markup_pct, parity_tolerance_pct)
|
||||||
|
|
||||||
Schedule: daily at 06:45, after the 05:20 Newbook fetch and 05:30 scrape.
|
Schedule: daily at 06:45, after the 05:20 Newbook fetch and 05:30 scrape.
|
||||||
"""
|
"""
|
||||||
|
|
@ -34,32 +37,55 @@ def get_parity_config(db) -> dict:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
text("""SELECT config_key, config_value FROM system_config
|
text("""SELECT config_key, config_value FROM system_config
|
||||||
WHERE config_key IN ('parity_check_enabled',
|
WHERE config_key IN ('parity_check_enabled',
|
||||||
'parity_expected_markup_pct',
|
'parity_markup_value', 'parity_markup_unit',
|
||||||
'parity_tolerance_pct')""")
|
'parity_tolerance_value', 'parity_tolerance_unit',
|
||||||
|
'parity_expected_markup_pct', 'parity_tolerance_pct')""")
|
||||||
).fetchall()
|
).fetchall()
|
||||||
cfg = {r[0]: r[1] for r in rows}
|
cfg = {r[0]: r[1] for r in rows}
|
||||||
|
|
||||||
def num(key: str, default: float) -> float:
|
def num(key: str, default: float, legacy_key: str = None) -> float:
|
||||||
|
raw = cfg.get(key)
|
||||||
|
if raw in (None, '') and legacy_key:
|
||||||
|
raw = cfg.get(legacy_key)
|
||||||
try:
|
try:
|
||||||
return float(cfg.get(key) or default)
|
return float(raw if raw not in (None, '') else default)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
def unit(key: str) -> str:
|
||||||
|
return 'gbp' if (cfg.get(key) or 'pct').lower() in ('gbp', '£', 'abs') else 'pct'
|
||||||
|
|
||||||
enabled_raw = (cfg.get('parity_check_enabled') or 'true').lower()
|
enabled_raw = (cfg.get('parity_check_enabled') or 'true').lower()
|
||||||
return {
|
return {
|
||||||
"enabled": enabled_raw in ('true', '1', 'yes', 'enabled'),
|
"enabled": enabled_raw in ('true', '1', 'yes', 'enabled'),
|
||||||
"markup_pct": num('parity_expected_markup_pct', 0.0),
|
"markup_value": num('parity_markup_value', 0.0, 'parity_expected_markup_pct'),
|
||||||
"tolerance_pct": num('parity_tolerance_pct', 2.0),
|
"markup_unit": unit('parity_markup_unit'),
|
||||||
|
"tolerance_value": num('parity_tolerance_value', 2.0, 'parity_tolerance_pct'),
|
||||||
|
"tolerance_unit": unit('parity_tolerance_unit'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def deviation_from_expected(booking_rate: float, newbook_rate: float, markup_pct: float) -> float | None:
|
def expected_booking_rate(newbook_rate: float, cfg: dict) -> float:
|
||||||
"""% deviation of the actual Booking.com rate from the expected
|
"""Expected Booking.com rate: Newbook + markup (% or flat £)."""
|
||||||
(Newbook × (1 + markup%)) rate. None if expected is not positive."""
|
if cfg["markup_unit"] == 'gbp':
|
||||||
expected = newbook_rate * (1 + markup_pct / 100)
|
return newbook_rate + cfg["markup_value"]
|
||||||
|
return newbook_rate * (1 + cfg["markup_value"] / 100)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_parity(booking_rate: float, newbook_rate: float, cfg: dict):
|
||||||
|
"""Compare actual Booking.com rate against expected.
|
||||||
|
Returns (deviation_pct, deviation_gbp, breach) or None if expected invalid.
|
||||||
|
The breach test uses the tolerance in its own unit (% or £)."""
|
||||||
|
expected = expected_booking_rate(newbook_rate, cfg)
|
||||||
if expected <= 0:
|
if expected <= 0:
|
||||||
return None
|
return None
|
||||||
return (booking_rate - expected) / expected * 100
|
dev_gbp = booking_rate - expected
|
||||||
|
dev_pct = dev_gbp / expected * 100
|
||||||
|
if cfg["tolerance_unit"] == 'gbp':
|
||||||
|
breach = abs(dev_gbp) > cfg["tolerance_value"]
|
||||||
|
else:
|
||||||
|
breach = abs(dev_pct) > cfg["tolerance_value"]
|
||||||
|
return dev_pct, dev_gbp, breach
|
||||||
|
|
||||||
|
|
||||||
def run_parity_check() -> dict:
|
def run_parity_check() -> dict:
|
||||||
|
|
@ -113,12 +139,13 @@ def run_parity_check() -> dict:
|
||||||
created = updated = resolved = 0
|
created = updated = resolved = 0
|
||||||
|
|
||||||
for d in common_dates:
|
for d in common_dates:
|
||||||
dev = deviation_from_expected(booking[d]["rate"], newbook[d], cfg["markup_pct"])
|
result = evaluate_parity(booking[d]["rate"], newbook[d], cfg)
|
||||||
if dev is None:
|
if result is None:
|
||||||
continue
|
continue
|
||||||
|
dev, _dev_gbp, breach = result
|
||||||
alert = latest_alert.get(d)
|
alert = latest_alert.get(d)
|
||||||
|
|
||||||
if abs(dev) > cfg["tolerance_pct"]:
|
if breach:
|
||||||
if alert and alert["status"] == "active":
|
if alert and alert["status"] == "active":
|
||||||
if round(dev, 2) != round(alert["diff"], 2):
|
if round(dev, 2) != round(alert["diff"], 2):
|
||||||
db.execute(text("""
|
db.execute(text("""
|
||||||
|
|
@ -164,8 +191,10 @@ def run_parity_check() -> dict:
|
||||||
"created": created,
|
"created": created,
|
||||||
"updated": updated,
|
"updated": updated,
|
||||||
"resolved": resolved,
|
"resolved": resolved,
|
||||||
"markup_pct": cfg["markup_pct"],
|
"markup_value": cfg["markup_value"],
|
||||||
"tolerance_pct": cfg["tolerance_pct"],
|
"markup_unit": cfg["markup_unit"],
|
||||||
|
"tolerance_value": cfg["tolerance_value"],
|
||||||
|
"tolerance_unit": cfg["tolerance_unit"],
|
||||||
}
|
}
|
||||||
logger.info(f"Parity check: {summary}")
|
logger.info(f"Parity check: {summary}")
|
||||||
return summary
|
return summary
|
||||||
|
|
|
||||||
|
|
@ -777,8 +777,12 @@ const ParityAlertsTab: React.FC = () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const markup = parityConfig?.['parity_expected_markup_pct'] ?? '0'
|
const markupUnit = parityConfig?.['parity_markup_unit'] ?? 'pct'
|
||||||
const tolerance = parityConfig?.['parity_tolerance_pct'] ?? '2'
|
const toleranceUnit = parityConfig?.['parity_tolerance_unit'] ?? 'pct'
|
||||||
|
const markupVal = parityConfig?.['parity_markup_value'] ?? parityConfig?.['parity_expected_markup_pct'] ?? '0'
|
||||||
|
const toleranceVal = parityConfig?.['parity_tolerance_value'] ?? parityConfig?.['parity_tolerance_pct'] ?? '2'
|
||||||
|
const markupLabel = markupUnit === 'gbp' ? `£${markupVal}` : `${markupVal}%`
|
||||||
|
const toleranceLabel = toleranceUnit === 'gbp' ? `£${toleranceVal}` : `${toleranceVal}%`
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
const thStyle: React.CSSProperties = {
|
||||||
textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600,
|
textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600,
|
||||||
|
|
@ -793,7 +797,7 @@ const ParityAlertsTab: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-mid)', marginBottom: 14, maxWidth: 720 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-mid)', marginBottom: 14, maxWidth: 720 }}>
|
||||||
Dates where our Booking.com rate deviates from the expected level
|
Dates where our Booking.com rate deviates from the expected level
|
||||||
(Newbook rate + {markup}% markup, ±{tolerance}% tolerance). Checked daily at 06:45 —
|
(Newbook rate + {markupLabel} markup, ±{toleranceLabel} tolerance). Checked daily at 06:45 —
|
||||||
adjust the markup and tolerance in Settings → Rate Parity. Acknowledge a date once
|
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.
|
dealt with; alerts auto-resolve when the rates come back in line.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -850,11 +854,21 @@ const ParityAlertsTab: React.FC = () => {
|
||||||
<td style={tdStyle}>{a.newbook_rate != null ? `£${a.newbook_rate.toFixed(2)}` : '—'}</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.booking_com_rate != null ? `£${a.booking_com_rate.toFixed(2)}` : '—'}</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
{a.difference_pct != null && (
|
{a.difference_pct != null && (() => {
|
||||||
<span style={badgeStyle(a.alert_type === 'higher' ? 'warning' : 'error')}>
|
// expected = booking / (1 + dev%); £ deviation derived from that
|
||||||
{a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}% vs expected
|
const expected = a.booking_com_rate != null
|
||||||
</span>
|
? a.booking_com_rate / (1 + a.difference_pct / 100)
|
||||||
)}
|
: null
|
||||||
|
const devGbp = expected != null && a.booking_com_rate != null
|
||||||
|
? a.booking_com_rate - expected
|
||||||
|
: null
|
||||||
|
return (
|
||||||
|
<span style={badgeStyle(a.alert_type === 'higher' ? 'warning' : 'error')}>
|
||||||
|
{a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}%
|
||||||
|
{devGbp != null ? ` (${devGbp > 0 ? '+' : '−'}£${Math.abs(devGbp).toFixed(2)})` : ''} vs expected
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td style={mergeStyles(tdStyle, { color: 'var(--text-mid)', fontSize: 12 })}>{a.room_category || '—'}</td>
|
<td style={mergeStyles(tdStyle, { color: 'var(--text-mid)', fontSize: 12 })}>{a.room_category || '—'}</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
|
|
|
||||||
|
|
@ -115,14 +115,18 @@ function ParityTab({ config, isLoading, onSave, saving }: {
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}) {
|
}) {
|
||||||
const [markup, setMarkup] = useState('')
|
const [markup, setMarkup] = useState('')
|
||||||
|
const [markupUnit, setMarkupUnit] = useState('pct')
|
||||||
const [tolerance, setTolerance] = useState('')
|
const [tolerance, setTolerance] = useState('')
|
||||||
|
const [toleranceUnit, setToleranceUnit] = useState('pct')
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [checkResult, setCheckResult] = useState<ParityCheckResult | null>(null)
|
const [checkResult, setCheckResult] = useState<ParityCheckResult | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config && !loaded) {
|
if (config && !loaded) {
|
||||||
setMarkup(config['parity_expected_markup_pct'] ?? '0')
|
setMarkup(config['parity_markup_value'] ?? config['parity_expected_markup_pct'] ?? '0')
|
||||||
setTolerance(config['parity_tolerance_pct'] ?? '2')
|
setMarkupUnit(config['parity_markup_unit'] ?? 'pct')
|
||||||
|
setTolerance(config['parity_tolerance_value'] ?? config['parity_tolerance_pct'] ?? '2')
|
||||||
|
setToleranceUnit(config['parity_tolerance_unit'] ?? 'pct')
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}
|
}
|
||||||
}, [config, loaded])
|
}, [config, loaded])
|
||||||
|
|
@ -166,39 +170,67 @@ function ParityTab({ config, isLoading, onSave, saving }: {
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||||
Expected Booking.com markup (%)
|
Expected Booking.com markup
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
type="number" step="0.5" style={{ width: 140 }}
|
<input
|
||||||
value={markup} onChange={e => setMarkup(e.target.value)}
|
type="number" step="0.5" style={{ width: 110 }}
|
||||||
placeholder="e.g. 15"
|
value={markup} onChange={e => setMarkup(e.target.value)}
|
||||||
/>
|
placeholder={markupUnit === 'gbp' ? 'e.g. 20' : 'e.g. 15'}
|
||||||
|
/>
|
||||||
|
<select style={{ width: 64 }} value={markupUnit} onChange={e => setMarkupUnit(e.target.value)}>
|
||||||
|
<option value="pct">%</option>
|
||||||
|
<option value="gbp">£</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||||
How much higher Booking.com should be than Newbook.
|
How much higher Booking.com should be than Newbook
|
||||||
|
{markupUnit === 'gbp' ? ' (flat £ per night)' : ' (percentage)'}.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||||
Tolerance (± %)
|
Tolerance (±)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
type="number" step="0.5" style={{ width: 140 }}
|
<input
|
||||||
value={tolerance} onChange={e => setTolerance(e.target.value)}
|
type="number" step="0.5" style={{ width: 110 }}
|
||||||
placeholder="e.g. 2"
|
value={tolerance} onChange={e => setTolerance(e.target.value)}
|
||||||
/>
|
placeholder={toleranceUnit === 'gbp' ? 'e.g. 5' : 'e.g. 2'}
|
||||||
|
/>
|
||||||
|
<select style={{ width: 64 }} value={toleranceUnit} onChange={e => setToleranceUnit(e.target.value)}>
|
||||||
|
<option value="pct">%</option>
|
||||||
|
<option value="gbp">£</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||||||
Allowed deviation from expected before alerting.
|
Allowed deviation from expected before alerting.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 6, fontSize: 12, color: 'var(--text-mid)' }}>
|
||||||
|
Rule: alert when Booking.com ≠ Newbook {markupUnit === 'gbp' ? `+ £${markup || '0'}` : `+ ${markup || '0'}%`}
|
||||||
|
{' '}beyond ±{toleranceUnit === 'gbp' ? `£${tolerance || '0'}` : `${tolerance || '0'}%`}.
|
||||||
|
{' '}Example: Newbook £100 → expect £{(markupUnit === 'gbp'
|
||||||
|
? 100 + (parseFloat(markup) || 0)
|
||||||
|
: 100 * (1 + (parseFloat(markup) || 0) / 100)).toFixed(0)},
|
||||||
|
{' '}alert outside £{(() => {
|
||||||
|
const exp = markupUnit === 'gbp' ? 100 + (parseFloat(markup) || 0) : 100 * (1 + (parseFloat(markup) || 0) / 100)
|
||||||
|
const tol = toleranceUnit === 'gbp' ? (parseFloat(tolerance) || 0) : exp * (parseFloat(tolerance) || 0) / 100
|
||||||
|
return `${(exp - tol).toFixed(0)}–£${(exp + tol).toFixed(0)}`
|
||||||
|
})()}.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSave('parity_expected_markup_pct', markup || '0')
|
onSave('parity_markup_value', markup || '0')
|
||||||
onSave('parity_tolerance_pct', tolerance || '2')
|
onSave('parity_markup_unit', markupUnit)
|
||||||
|
onSave('parity_tolerance_value', tolerance || '2')
|
||||||
|
onSave('parity_tolerance_unit', toleranceUnit)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Save size={13} strokeWidth={1.75} />
|
<Save size={13} strokeWidth={1.75} />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue