"""Landing page stats endpoint — 5 KPI для hero-секции.""" from __future__ import annotations import logging 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() # --------------------------------------------------------------------------- # 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 с ценой row_price = db.execute( text( "SELECT" " COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0" " / NULLIF(COUNT(*), 0)" " FROM objective_lots" ) ).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 @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(db) if result is None: return LandingStatsOut(**_FALLBACK_DATA) return LandingStatsOut(**result)