"""Landing page stats endpoint — 5 KPI для hero-секции.""" from __future__ import annotations import logging import threading import time from datetime import date from typing import Annotated, Any from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session from app.core.db import get_db logger = logging.getLogger(__name__) router = APIRouter() # --------------------------------------------------------------------------- # In-process TTL-кэш результата _query_stats (перф-батч #2, #1953-followup) # --------------------------------------------------------------------------- # /landing/stats бьётся на КАЖДЫЙ load хоумпейджа и НЕ кэширован. KPI 3 (доля квартир # с ценой) делает полный агрегат по physflat-дедупу objective_lots: price_per_m2_rub # нет в covering-INCLUDE и фильтра нет → ~6.7 s heap-скан 603k физлотов на каждый # запрос. Данные дневной гранулярности (snapshot_date) → 1 ч staleness приемлем. # # Кэшируем ВЕСЬ dict (все 5 KPI), не только KPI 3 — там ещё pg_class deals + 2 # MAX(snapshot_date). Только УСПЕШНЫЙ результат (None/fallback НЕ кэшируем — пустая # dev-БД / сбой запроса не должны залипать на час). Потокобезопасно (threading.Lock, # удерживается только на check/store); analyze идёт в threadpool под Uvicorn. Стиль — # зеркало app.services.weather_cache. time.monotonic устойчив к скачкам системных часов. _STATS_TTL_S: float = 3600.0 # 1 час — дневная гранулярность данных # (result_dict, expires_at_monotonic_seconds). Только успешный результат. _STATS_CACHE: dict[str, Any] | None = None _STATS_EXPIRES_AT: float = 0.0 _STATS_LOCK = threading.Lock() def _now() -> float: """Монотонный таймер (тесты подменяют этот helper, а не сам `time`).""" return time.monotonic() # --------------------------------------------------------------------------- # Response schema # --------------------------------------------------------------------------- class LandingStatsOut(BaseModel): zk_total: int deals_total: int price_coverage_pct: float mapping_coverage_pct: float last_data_update: str paradox: str stale: bool = False # --------------------------------------------------------------------------- # Fallback defaults (на случай пустой dev БД или ошибки запроса) # --------------------------------------------------------------------------- _FALLBACK_DATA: dict[str, Any] = { "zk_total": 0, "deals_total": 0, "price_coverage_pct": 0.0, "mapping_coverage_pct": 0.0, "last_data_update": "", "paradox": "", "stale": True, } def _query_stats(db: Session) -> dict[str, Any] | None: """Выполняет агрегирующие запросы к БД. Возвращает None при любой ошибке.""" try: # KPI 1: COUNT ЖК DOM.РФ (ЕКБ) row_zk = db.execute( text("SELECT COUNT(*) FROM domrf_kn_objects WHERE is_ekb = TRUE") ).scalar() zk_total = int(row_zk or 0) # KPI 2: COUNT ДДУ-сделок (через pg_class для партицированной таблицы) row_deals = db.execute( text( "SELECT COALESCE(SUM(CAST(reltuples AS bigint)), 0)" " FROM pg_class" " WHERE relname LIKE 'rosreestr_deals_%'" " AND relkind = 'r'" ) ).scalar() deals_total = int(row_deals or 0) # KPI 3: % objective_lots с ценой # #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не # по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот). row_price = db.execute( text( "SELECT" " COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0" " / NULLIF(COUNT(*), 0)" " FROM v_objective_lots_latest" ) ).scalar() price_coverage_pct = round(float(row_price or 0.0), 1) # KPI 4: % mapping (objective_complex_mapping / domrf_kn_objects ekb) row_mapping = db.execute( text( "SELECT" " (SELECT COUNT(*) FROM objective_complex_mapping) * 100.0" " / NULLIF(" " (SELECT COUNT(*) FROM domrf_kn_objects WHERE is_ekb = TRUE)," " 0" " )" ) ).scalar() mapping_coverage_pct = round(float(row_mapping or 0.0), 1) # KPI 5: дата последнего обновления (MAX snapshot_date из objective_lots + domrf) row_date_obj = db.execute(text("SELECT MAX(snapshot_date) FROM objective_lots")).scalar() row_date_domrf = db.execute( text("SELECT MAX(snapshot_date) FROM domrf_kn_objects") ).scalar() dates = [d for d in (row_date_obj, row_date_domrf) if d is not None] last_data_update: str if dates: last_data_update = str(max(dates)) else: last_data_update = str(date.today()) # Если все KPI нули — скорее всего пустая БД → stale if zk_total == 0 and deals_total == 0: logger.warning("landing/stats: all KPIs are zero — returning stale=True") return None return { "zk_total": zk_total, "deals_total": deals_total, "price_coverage_pct": price_coverage_pct, "mapping_coverage_pct": mapping_coverage_pct, "last_data_update": last_data_update, "paradox": ( f"из {zk_total} ЖК у {price_coverage_pct}% есть цены" " — но в публичном доступе только 0.3%" ), "stale": False, } except Exception: logger.exception("landing/stats: DB query failed, returning stale=True") return None def _query_stats_cached(db: Session) -> dict[str, Any] | None: """`_query_stats` с in-process TTL-кэшем (TTL=1 ч). См. блок-комментарий вверху. Кэширует ТОЛЬКО успешный dict; None (сбой/пустая БД) не кэшируется — следующий запрос попробует снова. Lock удерживается лишь на check/store; сам DB-запрос идёт ВНЕ lock'а (не сериализуем одновременные cold-start, last-write wins — запрос идемпотентен, TTL длинный). """ global _STATS_CACHE, _STATS_EXPIRES_AT now = _now() with _STATS_LOCK: if _STATS_CACHE is not None and _STATS_EXPIRES_AT > now: return _STATS_CACHE # MISS / истёк → DB-запрос ВНЕ lock'а. result = _query_stats(db) if result is None: # Не кэшируем fallback — пусть следующий запрос повторит попытку. return None with _STATS_LOCK: _STATS_CACHE = result _STATS_EXPIRES_AT = _now() + _STATS_TTL_S return result @router.get("/landing/stats", response_model=LandingStatsOut) def landing_stats( db: Annotated[Session, Depends(get_db)], ) -> LandingStatsOut: """5 KPI для hero-секции лендинга. Поля ответа: - zk_total: количество ЖК DOM.РФ в ЕКБ - deals_total: суммарное кол-во ДДУ-сделок в индексе Росреестра - price_coverage_pct: % объектов objective_lots с ценой - mapping_coverage_pct: % маппинга objective → domrf_kn_objects (ЕКБ) - last_data_update: дата последнего обновления данных (YYYY-MM-DD) - paradox: строка-парадокс портфеля для hero CTA - stale: true если данные недоступны (DB ошибка или пустая БД) """ result = _query_stats_cached(db) if result is None: return LandingStatsOut(**_FALLBACK_DATA) return LandingStatsOut(**result)