feat(sf-b4): GET /landing/stats — 5 KPI + paradox for landing page hero (#331)
This commit is contained in:
parent
ea558b241c
commit
bb7fbd5bc0
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,
|
||||
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"])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue