feat(sf-b4): GET /landing/stats — 5 KPI + paradox for landing page hero (#331)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m25s
Deploy / build-worker (push) Successful in 2m28s
Deploy / deploy (push) Successful in 51s

This commit is contained in:
lekss361 2026-05-17 21:47:10 +00:00
parent ea558b241c
commit bb7fbd5bc0
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_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 нули — скорее всего пустая БД, лучше вернуть 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,
pilot,
@ -101,6 +102,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.include_router(pilot.router, prefix="/api/v1/pilot", tags=["pilot"])
app.include_router(users.router, prefix="/api/v1", tags=["users"])