Auto-recheck parity after scrape + manual Recheck button + faster badge

Parity logic:
- Extract _fetch_latest_alerts() and _upsert_alerts() helpers so the alert
  upsert loop is no longer duplicated between the daily job and per-date runs
- Add run_parity_check_for_date(date) which runs the full comparison +
  alert upsert for a single date

Scraper integration:
- _safe_parity_recheck(date) wrapper (never raises) called after each
  successful date in both search-results and hotel-page workers; hotel-page
  mode waits until all hotels for the date are done before rechecking

API:
- POST /competitors/parity/check-date?rate_date=YYYY-MM-DD for manual recheck

Frontend:
- Recheck button on every parity alert row (all statuses); invalidates
  alerts list and badge count on success
- parity-alert-count badge polls every 15s (was 60s) so new alerts from
  the scheduled job or post-scrape rechecks appear quickly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-15 09:33:33 +00:00
parent 8a80c66a3b
commit cab1a39503
5 changed files with 146 additions and 66 deletions

View file

@ -244,6 +244,93 @@ def gather_comparisons(db, start: date, end: date, cfg: dict) -> list:
return out
def _fetch_latest_alerts(db, start: date, end: date) -> dict:
"""Return {rate_date: {id, status, diff}} for the most-recent alert per date in [start, end]."""
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()
return {r[1]: {"id": r[0], "status": r[2], "diff": float(r[3] or 0)} for r in rows}
def _upsert_alerts(db, comparisons: list, latest_alert: dict) -> tuple:
"""Apply parity comparison results to the alerts table.
Returns (created, updated, resolved)."""
created = updated = resolved = 0
for c in comparisons:
alert = latest_alert.get(c["date"])
category_label = f"{c['newbook_tariff']} vs BC {c['booking_basis']}"[:255]
if c["breach"]:
if alert and alert["status"] == "active":
if round(c["dev_pct"], 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": c["newbook_rate"], "bc": c["booking_rate"],
"diff": round(c["dev_pct"], 2),
"atype": "higher" if c["dev_pct"] > 0 else "lower",
"room": category_label,
})
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": c["date"], "room": category_label,
"nb": c["newbook_rate"], "bc": c["booking_rate"],
"diff": round(c["dev_pct"], 2),
"atype": "higher" if c["dev_pct"] > 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
return created, updated, resolved
def run_parity_check_for_date(rate_date: date) -> dict:
"""Run the parity check for a single date.
Called after a date is re-scraped, or triggered manually from the UI."""
db = SyncSessionLocal()
try:
cfg = get_parity_config(db)
if not cfg["enabled"]:
return {"status": "disabled"}
comparisons = gather_comparisons(db, rate_date, rate_date, cfg)
if not comparisons:
return {"status": "ok", "date": str(rate_date), "no_data": True}
latest_alert = _fetch_latest_alerts(db, rate_date, rate_date)
created, updated, resolved = _upsert_alerts(db, comparisons, latest_alert)
db.commit()
return {"status": "ok", "date": str(rate_date),
"created": created, "updated": updated, "resolved": resolved}
except Exception:
db.rollback()
raise
finally:
db.close()
def run_parity_check() -> dict:
db = SyncSessionLocal()
try:
@ -265,61 +352,8 @@ def run_parity_check() -> dict:
"""), {"today": today})
comparisons = gather_comparisons(db, start, end, cfg)
# 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}
created = updated = resolved = 0
for c in comparisons:
alert = latest_alert.get(c["date"])
# e.g. "DIRECT B&B PPAY vs BC prepaid/B&B"
category_label = f"{c['newbook_tariff']} vs BC {c['booking_basis']}"[:255]
if c["breach"]:
if alert and alert["status"] == "active":
if round(c["dev_pct"], 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": c["newbook_rate"], "bc": c["booking_rate"],
"diff": round(c["dev_pct"], 2),
"atype": "higher" if c["dev_pct"] > 0 else "lower",
"room": category_label,
})
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": c["date"], "room": category_label,
"nb": c["newbook_rate"], "bc": c["booking_rate"],
"diff": round(c["dev_pct"], 2),
"atype": "higher" if c["dev_pct"] > 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
latest_alert = _fetch_latest_alerts(db, start, end)
created, updated, resolved = _upsert_alerts(db, comparisons, latest_alert)
db.commit()
summary = {