feat(sf-b4): GET /landing/stats — 5 KPI + paradox for landing page hero

New router backend/app/api/v1/landing.py with GET /api/v1/landing/stats.
Queries domrf_kn_objects, rosreestr_deals_* (pg_class), objective_lots,
objective_complex_mapping; falls back to hardcoded defaults on empty DB.
This commit is contained in:
lekss361 2026-05-18 00:02:02 +03:00
parent e872b1ebe4
commit 6d66c8b27f
2 changed files with 130 additions and 0 deletions

View file

@ -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

View file

@ -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")