"""Мониторинг свежести ДАННЫХ 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