"""Мониторинг свежести ДАННЫХ СберИндекса (не статуса джобы) — audit п.1. Проблема аудита: estimator._load_sber_index_series (#794/#audit-5a) применяет СберИндекс time-adjustment к ДКП-сделкам и лишь ЛОГИРУЕТ per-estimate warning, когда latest месяц серии старее settings.sber_index_max_age_days (35д). Джоба `sber_index_pull` крутится ежемесячно (enabled), а источник СберИндекса публикует данные с лагом ~1-2 месяца, поэтому `sber_price_index.period_month` дрейфит (на 2026-07-12 latest=2026-05-01, ~72д). Это НЕ silent failure, но staleness видна только в debug-подобном per-estimate warning'е, тонущем в логах оценок. Этот монитор смотрит на `max(period_month)` вторичного сегмента по региону и поднимает per-day WARNING-алерт, когда данные устарели СВЕРХ допустимого лага публикации — так ops видит дрейф на MONITOR-частоте, а не по крупицам в логах. Порог алерта (документирование выбора): Per-estimate guard (estimator): age > settings.sber_index_max_age_days (35д). Монитор: age > sber_index_max_age_days + lag_allowance. lag_allowance (DEFAULT_LAG_ALLOWANCE_DAYS=25) — запас на ИНХЕРЕНТНЫЙ лаг публикации СберИндекса: источник отстаёт на 1-2 месяца, period_month — лейбл ПЕРВОГО числа месяца, а месячный pull ещё не подтянул новейший период. Итог: 35 + 25 = 60д. Ниже 60д latest считается «нормально отстающим» → алерта нет (иначе daily-шум на штатном лаге). Выше 60д данные застряли сверх ~2 месяцев → алерт. Проверено на проде 2026-07-12: max=2026-05-01, age=72д > 60 → alert=1. Задача синхронная (DB-only, один SELECT max(period_month)) — запускается kit-scheduler'ом через product_handlers._job_sber_freshness_monitor в run_in_executor, по образцу deals_freshness_monitor. Вердикт вычисляет ЧИСТАЯ функция evaluate_sber_freshness() (frozen-now тестируется без БД). Прогон НЕ помечается failed при алерте (это МОНИТОР, а не сбой джобы) — WARNING достаточен. mark_failed только если sber_price_index недоступна/пуста (нечего оценивать). """ from __future__ import annotations import logging from dataclasses import dataclass from datetime import UTC, date, datetime from sqlalchemy import text from sqlalchemy.orm import Session from app.core.config import settings from app.services import scrape_runs as runs_mod logger = logging.getLogger(__name__) __all__ = [ "DEFAULT_LAG_ALLOWANCE_DAYS", "SberFreshnessVerdict", "check_sber_freshness", "evaluate_sber_freshness", ] # Запас на инхерентный лаг публикации СберИндекса (дней) СВЕРХ per-estimate # guard'а settings.sber_index_max_age_days. Читается из default_params.lag_allowance_days. DEFAULT_LAG_ALLOWANCE_DAYS = 25 # Регион продукта (Trade-in — Свердловская область). Совпадает с city-значениями # sber_price_index для областного уровня. SBER_MONITOR_CITY = "Свердловская область" _LATEST_SBER_PERIOD_SQL = text(""" SELECT max(period_month) AS latest FROM sber_price_index WHERE city = CAST(:city AS text) -- #R2-H1: только вторичный рынок (эстиматор — вторичка); первичка -- (новостройки) = направленно неверная коррекция. Зеркалит фильтр -- estimator._load_sber_index_series. AND (segment IS NULL OR segment ILIKE '%вторичн%') """) @dataclass(frozen=True) class SberFreshnessVerdict: """Вердикт свежести СберИндекса по max(period_month).""" latest_period: date age_days: int stale: bool def evaluate_sber_freshness( latest_period: date, now: datetime, max_age_days: int, ) -> SberFreshnessVerdict: """Чистая логика: устарел ли latest период СберИндекса. stale = age_days > max_age_days, где age_days = now.date() - latest_period. `max_age_days` — ПОЛНЫЙ порог монитора (per-estimate guard + lag_allowance), вычисляется вызывающим check_sber_freshness. Тестируется с frozen `now` без БД. """ age_days = (now.date() - latest_period).days stale = age_days > max_age_days return SberFreshnessVerdict( latest_period=latest_period, age_days=age_days, stale=stale, ) def check_sber_freshness( db: Session, run_id: int, params: dict | None = None, # type: ignore[type-arg] now: datetime | None = None, ) -> dict[str, int]: """Проверить свежесть СберИндекса по max(period_month) и алертить при staleness. Sync (вызывается scheduler-триггером в executor, как check_deals_freshness). Читает один SELECT max(period_month) вторичного сегмента по региону, считает вердикт чистой функцией, логирует WARNING при stale (per-day surfacing для ops) и финализирует run. Params (default_params jsonb): lag_allowance_days: int — запас сверх sber_index_max_age_days (default 25). `now` инъектируется в тестах (frozen); в проде — None → datetime.now(UTC). Returns counters {latest_year, latest_month, age_days, alert}. mark_failed только если sber_price_index пуста/недоступна (нечего оценивать); при алерте прогон помечается done (это монитор, не сбой джобы). """ params = params or {} now = now or datetime.now(UTC) counters: dict[str, int] = { "latest_year": 0, "latest_month": 0, "age_days": 0, "alert": 0, } try: runs_mod.update_heartbeat(db, run_id, counters) row = db.execute(_LATEST_SBER_PERIOD_SQL, {"city": SBER_MONITOR_CITY}).first() latest: date | None = row.latest if row is not None else None if latest is None: logger.warning( "sber freshness: sber_price_index пуст/недоступен для region=%s " "(вторичка) — оценить свежесть нельзя", SBER_MONITOR_CITY, ) runs_mod.mark_failed(db, run_id, "sber_price_index empty or unavailable", counters) return counters lag_days = int(params.get("lag_allowance_days", DEFAULT_LAG_ALLOWANCE_DAYS)) max_age_days = settings.sber_index_max_age_days + lag_days verdict = evaluate_sber_freshness(latest, now, max_age_days) counters = { "latest_year": latest.year, "latest_month": latest.month, "age_days": verdict.age_days, "alert": int(verdict.stale), } if verdict.stale: logger.warning( "sber freshness: max(period_month)=%s устарел на %d дней " "(> порога %d = sber_index_max_age_days %d + lag %d); " "СберИндекс time-adjustment ДКП-сделок мог отстать — " "проверь sber_index_pull и доступность новых периодов источника", latest, verdict.age_days, max_age_days, settings.sber_index_max_age_days, lag_days, ) else: logger.info( "sber freshness: max(period_month)=%s свежий (age=%d дней ≤ порога %d) " "region=%s — алерта нет", latest, verdict.age_days, max_age_days, SBER_MONITOR_CITY, ) runs_mod.mark_done(db, run_id, counters) logger.info( "check_sber_freshness run_id=%d done: latest=%s alert=%d age_days=%d", run_id, latest, counters["alert"], counters["age_days"], ) return counters except Exception as exc: logger.exception("check_sber_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