diff --git a/backend/app/api/v1/landing.py b/backend/app/api/v1/landing.py new file mode 100644 index 00000000..01e03c61 --- /dev/null +++ b/backend/app/api/v1/landing.py @@ -0,0 +1,128 @@ +"""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 sqlalchemy import text +from sqlalchemy.orm import Session + +from app.core.db import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# --------------------------------------------------------------------------- +# Fallback defaults (на случай пустой dev БД) +# --------------------------------------------------------------------------- + +_FALLBACK: dict[str, Any] = { + "zk_total": 1500, + "deals_total": 6830000, + "price_coverage_pct": 81.4, + "mapping_coverage_pct": 13.3, + "last_data_update": "2026-05-17", + "paradox": ("из 1500 ЖК у 81% есть цены — но в публичном доступе только 0.3%"), +} + + +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 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(CAST(updated_at AS 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 нули — скорее всего пустая БД, лучше вернуть None → fallback + if zk_total == 0 and deals_total == 0: + logger.warning("landing/stats: all KPIs are zero — using fallback") + 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%" + ), + } + except Exception: + logger.exception("landing/stats: DB query failed, returning fallback") + return None + + +@router.get("/landing/stats") +def landing_stats( + db: Annotated[Session, Depends(get_db)], +) -> dict[str, Any]: + """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 + """ + result = _query_stats(db) + if result is None: + return _FALLBACK + return result diff --git a/backend/app/main.py b/backend/app/main.py index 7874ee48..858e41aa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,6 +23,7 @@ from app.api.v1 import ( analytics, concepts, custom_pois, + landing, parcels, photos, trade_in, @@ -99,6 +100,7 @@ app.include_router( tags=["admin", "etl"], ) app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"]) +app.include_router(landing.router, prefix="/api/v1", tags=["landing"]) @app.get("/health")