gendesign/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py
lekss361 ac19c4f7b3
All checks were successful
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 8s
Deploy Trade-In / test (push) Successful in 1m35s
Deploy Trade-In / build-backend (push) Successful in 56s
Deploy Trade-In / deploy (push) Successful in 1m2s
feat(tradein/monitor): алерт на staleness данных deals по max(deal_date) (#2212) (#2226)
2026-07-02 20:50:37 +00:00

196 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Мониторинг свежести ДАННЫХ deals (не статуса джобы) — #2212.
Проблема аудита: rosreestr_quarter_poll / rosreestr_dkp_import честно рапортуют
`done` (rows_fetched>0, inserted=0 == «всё уже импортировано»), но это МАСКИРУЕТ
staleness самих данных. `deals.deal_date` — лейбл начала квартала (2026-01-01 = 2026Q1);
Росреестр публикует квартальными дампами, скачивание дампа — РУЧНОЙ операторский шаг
(poll лишь алертит о доступности). Если оператор пропустил импорт нового квартала,
джоба-статус остаётся зелёным, а данные тихо устаревают.
Этот монитор смотрит на `max(deal_date)` и поднимает ERROR-алерт (→ GlitchTip через
sentry logging integration), когда следующий квартал ПРОСРОЧЕН публикацией.
Арифметика просрочки (проверена на проде 2026-07-02, max=2026Q1, q_end=2026-03-31):
latest_q_start = max(deal_date) (2026-01-01)
latest_q_end = latest_q_start + 3 months (2026-04-01, старт Q2)
overdue_threshold = latest_q_end + 3 months + LAG_DAYS (2026-07-01 + 45д = 2026-08-15)
overdue = now() > overdue_threshold
Смысл: следующий квартал (Q2) заканчивается через 3 мес после конца имеющегося; даём
LAG_ALLOWANCE_DAYS (по умолчанию 45) на публикацию дампа Росреестром. Сейчас (2026-07-02)
до порога 2026-08-15 ещё далеко → алерта НЕТ. Если к 2026-08-16 Q2 не импортирован → алерт.
Задача синхронная (DB-only, один SELECT max(deal_date)) — запускается in-app scheduler'ом
через trigger_deals_freshness_monitor_run() в run_in_executor, по образцу
listing_source_snapshot / rosreestr_dkp_import. Вердикт вычисляет ЧИСТАЯ функция
evaluate_deals_freshness() (frozen-now тестируется без БД).
Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — ERROR-лог
достаточен. mark_failed только если deals недоступна/пуста (нечего оценивать).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services import scrape_runs as runs_mod
logger = logging.getLogger(__name__)
__all__ = [
"DEFAULT_LAG_ALLOWANCE_DAYS",
"FreshnessVerdict",
"check_deals_freshness",
"evaluate_deals_freshness",
]
# Лаг публикации дампа Росреестром: сколько дней после конца следующего квартала
# считаем допустимой задержкой до алерта. Читается из default_params.lag_allowance_days.
DEFAULT_LAG_ALLOWANCE_DAYS = 45
_LATEST_DEAL_DATE_SQL = text("SELECT max(deal_date) AS latest FROM deals")
@dataclass(frozen=True)
class FreshnessVerdict:
"""Вердикт свежести deals по max(deal_date)."""
latest_q_start: date
latest_q_end: date
overdue_threshold: date
overdue: bool
days_overdue: int
def _add_months(d: date, months: int) -> date:
"""Прибавить `months` к дате. deal_date всегда старт квартала (день=1), но
хелпер устойчив к произвольному дню — при переполнении зажимает на конец месяца.
"""
total = (d.month - 1) + months
year = d.year + total // 12
month = total % 12 + 1
# Зажим дня на последний день целевого месяца (для дней 29-31; для day=1 no-op).
if month == 12:
next_month_first = date(year + 1, 1, 1)
else:
next_month_first = date(year, month + 1, 1)
last_day = (next_month_first - timedelta(days=1)).day
return date(year, month, min(d.day, last_day))
def evaluate_deals_freshness(
latest_q_start: date,
now: datetime,
lag_allowance_days: int = DEFAULT_LAG_ALLOWANCE_DAYS,
) -> FreshnessVerdict:
"""Чистая логика: просрочен ли следующий квартал относительно max(deal_date).
overdue = now() > (latest_q_start + 6 месяцев + lag_allowance_days).
days_overdue = max(0, дни сверх порога) — 0 когда не просрочено.
Тестируется с frozen `now` без БД.
"""
latest_q_end = _add_months(latest_q_start, 3)
overdue_threshold = _add_months(latest_q_start, 6) + timedelta(days=lag_allowance_days)
now_date = now.date()
overdue = now_date > overdue_threshold
days_overdue = max(0, (now_date - overdue_threshold).days)
return FreshnessVerdict(
latest_q_start=latest_q_start,
latest_q_end=latest_q_end,
overdue_threshold=overdue_threshold,
overdue=overdue,
days_overdue=days_overdue,
)
def check_deals_freshness(
db: Session,
run_id: int,
params: dict | None = None, # type: ignore[type-arg]
now: datetime | None = None,
) -> dict[str, int]:
"""Проверить свежесть deals по max(deal_date) и поднять алерт при просрочке.
Sync (вызывается scheduler-триггером в executor, как snapshot_listing_sources).
Читает один SELECT max(deal_date), считает вердикт чистой функцией, логирует
ERROR при overdue (→ GlitchTip) и финализирует run.
Params (default_params jsonb):
lag_allowance_days: int — допустимый лаг публикации дампа (default 45).
`now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC).
Returns counters {latest_year, latest_quarter, days_overdue, alert}.
mark_failed только если deals пуста/недоступна (нечего оценивать); при алерте
прогон помечается done (это монитор, не сбой джобы).
"""
params = params or {}
now = now or datetime.now(UTC)
counters: dict[str, int] = {
"latest_year": 0,
"latest_quarter": 0,
"days_overdue": 0,
"alert": 0,
}
try:
runs_mod.update_heartbeat(db, run_id, counters)
row = db.execute(_LATEST_DEAL_DATE_SQL).first()
latest: date | None = row.latest if row is not None else None
if latest is None:
logger.warning(
"deals freshness: таблица deals пуста/недоступна — оценить свежесть нельзя"
)
runs_mod.mark_failed(db, run_id, "deals empty or unavailable", counters)
return counters
lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS))
verdict = evaluate_deals_freshness(latest, now, lag_days)
quarter = (latest.month - 1) // 3 + 1
counters = {
"latest_year": latest.year,
"latest_quarter": quarter,
"days_overdue": verdict.days_overdue,
"alert": int(verdict.overdue),
}
if verdict.overdue:
logger.error(
"deals freshness: max(deal_date)=%s — новый квартал просрочен на %d дней; "
"проверь rosreestr_quarter_poll + операторский импорт "
"(data/sql/02_load_all_quarters.sh)",
latest,
verdict.days_overdue,
)
else:
logger.info(
"deals freshness: max(deal_date)=%s (Q%d %d) свежий — порог просрочки %s "
"ещё не наступил, алерта нет",
latest,
quarter,
latest.year,
verdict.overdue_threshold,
)
runs_mod.mark_done(db, run_id, counters)
logger.info(
"check_deals_freshness run_id=%d done: latest=%s alert=%d days_overdue=%d",
run_id,
latest,
counters["alert"],
counters["days_overdue"],
)
return counters
except Exception as exc:
logger.exception("check_deals_freshness run_id=%d failed", run_id)
try:
db.rollback()
except Exception:
pass
runs_mod.mark_failed(db, run_id, str(exc)[:1000], counters)
raise