feat(sf-b4): GET /landing/stats — 5 KPI + paradox for landing page hero #331
2 changed files with 130 additions and 0 deletions
128
backend/app/api/v1/landing.py
Normal file
128
backend/app/api/v1/landing.py
Normal 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
|
||||||
|
|
@ -23,6 +23,7 @@ from app.api.v1 import (
|
||||||
analytics,
|
analytics,
|
||||||
concepts,
|
concepts,
|
||||||
custom_pois,
|
custom_pois,
|
||||||
|
landing,
|
||||||
parcels,
|
parcels,
|
||||||
photos,
|
photos,
|
||||||
pilot,
|
pilot,
|
||||||
|
|
@ -101,6 +102,7 @@ app.include_router(
|
||||||
tags=["admin", "etl"],
|
tags=["admin", "etl"],
|
||||||
)
|
)
|
||||||
app.include_router(trade_in.router, prefix="/api/v1/trade-in", tags=["trade-in"])
|
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(pilot.router, prefix="/api/v1/pilot", tags=["pilot"])
|
||||||
app.include_router(users.router, prefix="/api/v1", tags=["users"])
|
app.include_router(users.router, prefix="/api/v1", tags=["users"])
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue