gendesign/tradein-mvp/backend/app/tasks/deals_freshness_monitor.py
bot-backend 30d91be796
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
CI / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein/monitor): алерт на staleness данных deals по max(deal_date) (#2212)
Джоба-статус rosreestr_* остаётся done при quarter-stale данных (inserted=0
== всё импортировано), маскируя пропущенный операторский импорт нового
квартала. Новый daily-монитор смотрит max(deal_date) и поднимает ERROR-алерт
(→ GlitchTip), когда следующий квартал просрочен публикацией
(порог = конец имеющегося квартала + 3 мес + 45д лага).

Расследование (2026-07-02): импорт НЕ сломан — deal_date = лейбл начала
квартала, 2026Q1 = последний опубликованный Росреестром (Q2 закончился
30.06, публикация ожидается ~август; исторические кварталы загружены
разово 26.04). Монитор ловит именно пропуск ОПЕРАТОРСКОГО шага
(data/sql/02_load_all_quarters.sh), когда quarter_poll задетектит Q2.

- app/tasks/deals_freshness_monitor.py — чистая evaluate_deals_freshness
  (frozen-now testable) + DB-обёртка; overdue → ERROR-лог, не failed
- scheduler.py + product_handlers.py — оба dispatch-пути (kit боевой + legacy)
- data/sql/162 — seed (daily, 08:00-09:00 UTC, lag_allowance_days=45)
- 23 теста: арифметика порога (год-переход, clamp), migration props, parity

Refs #2212
2026-07-02 23:31:36 +03: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