Hero + 5 KPI cards from GET /api/v1/landing/stats (B4, real DB), Pricing card 30k руб/мес, FAQ accordion, PilotRequestModal -> POST /api/v1/pilot/request (B3).
Round 2 fixes:
- B4 fail-loud: LandingStatsOut.stale flag, zeroed fallback, frontend skeleton
- B3 UUID contract: id:string, display tracking GD-{first8hex}
- PilotRequestModal: replaced manual overlay with <Drawer side="bottom"> (A3 #341) — focus trap, ESC, scroll-lock, focus restore
- Design tokens: all hex literals replaced with var(--*)
- HeadlineBar component used instead of hand-rolled dark div
Closes #82
Supersedes #366
148 lines
5.3 KiB
Python
148 lines
5.3 KiB
Python
"""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 pydantic import BaseModel
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.db import get_db
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Response schema
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class LandingStatsOut(BaseModel):
|
||
zk_total: int
|
||
deals_total: int
|
||
price_coverage_pct: float
|
||
mapping_coverage_pct: float
|
||
last_data_update: str
|
||
paradox: str
|
||
stale: bool = False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Fallback defaults (на случай пустой dev БД или ошибки запроса)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_FALLBACK_DATA: dict[str, Any] = {
|
||
"zk_total": 0,
|
||
"deals_total": 0,
|
||
"price_coverage_pct": 0.0,
|
||
"mapping_coverage_pct": 0.0,
|
||
"last_data_update": "",
|
||
"paradox": "",
|
||
"stale": True,
|
||
}
|
||
|
||
|
||
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 нули — скорее всего пустая БД → stale
|
||
if zk_total == 0 and deals_total == 0:
|
||
logger.warning("landing/stats: all KPIs are zero — returning stale=True")
|
||
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%"
|
||
),
|
||
"stale": False,
|
||
}
|
||
except Exception:
|
||
logger.exception("landing/stats: DB query failed, returning stale=True")
|
||
return None
|
||
|
||
|
||
@router.get("/landing/stats", response_model=LandingStatsOut)
|
||
def landing_stats(
|
||
db: Annotated[Session, Depends(get_db)],
|
||
) -> LandingStatsOut:
|
||
"""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
|
||
- stale: true если данные недоступны (DB ошибка или пустая БД)
|
||
"""
|
||
result = _query_stats(db)
|
||
if result is None:
|
||
return LandingStatsOut(**_FALLBACK_DATA)
|
||
return LandingStatsOut(**result)
|