objective_lots — current-state UPSERT (UNIQUE objective_lot_id, 5 snapshot_date), но Объектив присваивает ОДНОМУ физлоту (project,corpus,section,floor,lot_number) несколько lot_id за пере-листинги → таблица раздута ~2.91× (прод: 1.75M квартир- строк vs 603k физлотов). История — в отдельной objective_lots_history (не трогаем). STEP 1: миграция 175 — VIEW v_objective_lots_latest (DISTINCT ON physflat-ключ, последний снапшот). ol.* стабилен (51==51 колонок). DRY-RUN на проде: 603 049 квартир vs 1 753 283 raw. STEP 2: репойнт current-state консьюмеров objective_lots → v_objective_lots_latest: - supply_layers._L1_OPEN_SQL (L1 открытое предложение → дефицит-форсайт; прод: Юго-Западный комфорт 58 606 → 12 620) - competitors._SOLD_COUNT_SQL (+ комментарий: COUNT(DISTINCT lot_id) СОХРАНЁН для fan-out-защиты маппинга, не COUNT(*) — view гарантирует physflat-дедуп, DISTINCT гарантирует mapping-fan-out-safety) - parcels.py obj_pricing CTE (карточка конкурента units_sold/available) - special_indices._ARTIFICIAL_DEMAND_SQL - parcels.py district price block + geo-radius median (sample_size/n) - concepts._OBJECTIVE_MEDIAN_SQL (гейт n≥10) - landing KPI3 (% квартир с ценой) - admin_scrape coverage: `lots` оставлен сырым (ETL-fidelity vs SQLite), добавлен `lots_physflat` #1959 inline-дедуп в market_metrics НЕ рефакторим — добавлен комментарий об общем physflat-ключе с view. STEP 3: report_assembler._deal_count теперь = unit_velocity × window_months (оконные продажи), НЕ кумулятивный n_sold. confidence_engine помечает фактор «за 6 мес» и гейтит порогами окна (high≥50) — кумулятив (прод EKB ~380 921) делал гейт бессмысленным и подпись лживой; оконное (~24 876 за 6 мес) честно. Тесты: +guard'ы (L1/sold-count читают view, deal_count оконный). 963 passed.
150 lines
5.6 KiB
Python
150 lines
5.6 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 с ценой
|
||
# #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не
|
||
# по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот).
|
||
row_price = db.execute(
|
||
text(
|
||
"SELECT"
|
||
" COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0"
|
||
" / NULLIF(COUNT(*), 0)"
|
||
" FROM v_objective_lots_latest"
|
||
)
|
||
).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)
|