gendesign/backend/app/api/v1/analytics.py
lekss361 cac06b2ff9 feat(22k): object page full exposure — 9 blocks (sales/quartirography/metro/photos/docs/specs/checks)
Adds backend queries + 4 API endpoints + 5 frontend components to expose all
Wave A+B data on the per-object drill-in page (/analytics/objects/[id]).

Backend (analytics_queries.py + analytics.py):
- object_full_detail(): extended SELECT with all 30 new cols from 113_22begh
  (building specs, yard, OVZ, catalog UI, metro, scores, dev_group_name)
- object_flats_quartirography(): GROUP BY rooms from domrf_kn_flats
- object_obj_checks(): SELECT from domrf_obj_checks (22f)
- object_documents(): SELECT from domrf_kn_documents (22i)
- 4 new endpoints: /object/{id}/full, /quartirography, /checks, /documents

Frontend:
- ObjectSpecsTable.tsx — 30+ spec rows, conditional (nulls hidden)
- ObjectQuartirographyTable.tsx — rooms x count/free/area/price
- ObjectChecksBadges.tsx — 6 badge pills (passed/failed)
- ObjectDocumentsList.tsx — PDF links grouped by doc_type with safeUrl()
- ObjectMetroList.tsx — top3 metro entries with line color dot
- page.tsx: 9 blocks incl. DOM.RF scores KPI row, checks, documents, specs

Closes #297 (Phase 6 — 22k frontend exposure)
2026-05-17 18:50:41 +03:00

257 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Analytics endpoints for the Sverdlovsk dashboard."""
from __future__ import annotations
import re
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.complex_buildings import ComplexBuilding
from app.schemas.recommend import RecommendMixInput, RecommendMixOutput
from app.services import analytics_queries as q
router = APIRouter()
DEV_ID_RE = re.compile(r"^\d+_\d+$")
def _validate_dev(dev_id: str) -> str:
if not DEV_ID_RE.match(dev_id):
raise HTTPException(status_code=400, detail="developer_id must match \\d+_\\d+")
return dev_id
# ---- Sverdlovsk region ------------------------------------------------------
@router.get("/sverdl/market-pulse")
def sverdl_market_pulse(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""Monthly series Jan 2025 → present: total square (тыс м²), sold%, avg price."""
return q.market_pulse(db, region_code=66)
@router.get("/sverdl/quartirography")
def sverdl_quartirography(
db: Annotated[Session, Depends(get_db)],
source: Literal["portfolio", "deals"] = "portfolio",
) -> list[dict[str, Any]]:
"""Что строится (portfolio) vs что покупают (deals) по сегментам."""
return q.quartirography(db, source=source, region_id=66)
@router.get("/sverdl/pipeline")
def sverdl_pipeline(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""По году ввода: total / sold% / unsold% / unopened%."""
return q.pipeline_by_year(db, region_code=66)
@router.get("/sverdl/districts")
def sverdl_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""ЕКБ-районы: ЖК-count, flat-count, area, цены."""
return q.districts(db)
@router.get("/sverdl/yandex-listings")
def sverdl_yandex_listings(db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
"""Снимок активных новостроек Яндекс.Недвижимости."""
return q.yandex_listings(db)
# ---- Developers -------------------------------------------------------------
@router.get("/developers/top")
def developers_top(
db: Annotated[Session, Depends(get_db)],
limit: Annotated[int, Query(ge=1, le=50)] = 15,
) -> list[dict[str, Any]]:
"""Топ-девелоперы Свердл по объёму с Δ sold% за всю доступную историю."""
return q.top_developers(db, region_code=66, limit=limit)
@router.get("/developers/history")
def developers_history(
db: Annotated[Session, Depends(get_db)],
ids: Annotated[str, Query(min_length=1)],
) -> list[dict[str, Any]]:
"""Comma-separated developer_ids → time series sold_perc."""
dev_ids = [_validate_dev(i.strip()) for i in ids.split(",") if i.strip()]
if not dev_ids:
raise HTTPException(status_code=400, detail="ids must contain at least one developer_id")
return q.developer_history(db, developer_ids=dev_ids, region_code=66)
@router.get("/developers/{developer_id}")
def developer_detail(
db: Annotated[Session, Depends(get_db)],
developer_id: str,
) -> dict[str, Any]:
dev = _validate_dev(developer_id)
result = q.developer_detail(db, developer_id=dev)
if result is None:
raise HTTPException(status_code=404, detail="developer not found")
return result
@router.get("/developers/{developer_id}/portfolio")
def developer_portfolio(
db: Annotated[Session, Depends(get_db)],
developer_id: str,
) -> list[dict[str, Any]]:
dev = _validate_dev(developer_id)
return q.developer_portfolio(db, developer_id=dev)
# ---- Per-object drill-in (sale_graph / sales_agg / infra / photos) ----------
@router.get("/object/{obj_id}/sale_graph")
def object_sale_graph(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
type_filter: Annotated[str | None, Query(alias="type")] = None,
) -> list[dict[str, Any]]:
"""Time-series продаж per-ЖК. type=apartments|parking, без фильтра — обе серии."""
return q.object_sale_graph(db, obj_id=obj_id, type_filter=type_filter)
@router.get("/object/{obj_id}/sales_agg")
def object_sales_agg(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> list[dict[str, Any]]:
"""3 строки: apartments + nonliv + parking (total / realised / sold%)."""
return q.object_sales_agg(db, obj_id=obj_id)
@router.get("/object/{obj_id}/infrastructure")
def object_infrastructure(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
category: Annotated[str | None, Query()] = None,
max_distance: Annotated[int, Query(ge=0, le=10000)] = 5000,
) -> list[dict[str, Any]]:
"""POI вокруг ЖК. Фильтр по category (Спорт/Образование/...) и radius."""
return q.object_infrastructure(db, obj_id=obj_id, category=category, max_distance=max_distance)
@router.get("/object/{obj_id}/photos")
def object_photos(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
limit: Annotated[int, Query(ge=1, le=500)] = 100,
) -> list[dict[str, Any]]:
"""Фото-метаданные с photo_url, отсортированные по period_dt DESC."""
return q.object_photos(db, obj_id=obj_id, limit=limit)
@router.get("/object/{obj_id}")
def object_detail(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> dict[str, Any]:
"""Базовая инфа объекта из domrf_kn_objects (имя, адрес, координаты, девелопер)."""
result = q.object_detail(db, obj_id=obj_id)
if result is None:
raise HTTPException(status_code=404, detail="object not found")
return result
@router.get("/object/{obj_id}/full")
def object_full_detail(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> dict[str, Any]:
"""Extended object info — все 30+ новых полей (22begh: specs/accessibility/metro/scores)."""
result = q.object_full_detail(db, obj_id=obj_id)
if result is None:
raise HTTPException(status_code=404, detail="object not found")
return result
@router.get("/object/{obj_id}/quartirography")
def object_quartirography(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> list[dict[str, Any]]:
"""Квартирография ЖК: группировка flats по комнатности с count/area/price."""
return q.object_flats_quartirography(db, obj_id=obj_id)
@router.get("/object/{obj_id}/checks")
def object_checks(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> list[dict[str, Any]]:
"""6 проверок «Проверено на наш.дом.рф» (22f)."""
return q.object_obj_checks(db, obj_id=obj_id)
@router.get("/object/{obj_id}/documents")
def object_documents(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> list[dict[str, Any]]:
"""PDF документы из domrf_kn_documents (22i): декларации, разрешения, отчётность."""
return q.object_documents(db, obj_id=obj_id)
@router.get("/object/{obj_id}/buildings", response_model=list[ComplexBuilding])
def object_buildings(
db: Annotated[Session, Depends(get_db)],
obj_id: int,
) -> list[dict[str, Any]]:
"""Список кадастровых зданий ЖК из cad_buildings."""
return q.complex_buildings(db, obj_id=obj_id)
# ---- PRINZIP-specific -------------------------------------------------------
@router.get("/prinzip/insights")
def prinzip_insights() -> dict[str, Any]:
"""Static recommendations + benchmarks (from PRINZIP_Strategy_Apr27)."""
return q.prinzip_insights()
@router.get("/prinzip/districts")
def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
return q.prinzip_district_distribution(db, developer_id="6208_0")
@router.get("/prinzip/objects")
def prinzip_objects(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""Список 28 ЖК PRINZIP с агрегатами sales_agg + sparkline-данными для UI таблицы."""
return q.prinzip_objects_with_velocity(db)
# Funnel-эндпойнты (CRM-данные) перенесены в backend/app/api/v1/admin_leads.py:
# /api/v1/admin/leads/funnel/{monthly,by-source,by-object} с X-Admin-Token.
# ---- Recommender (Уровень 1, rule-based) -----------------------------------
@router.post("/recommend/mix", response_model=RecommendMixOutput)
def recommend_mix(
payload: RecommendMixInput,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Rule-based квартирография для участка в указанном районе ЕКБ.
На вход — district_name (8 районов ЕКБ), опционально area_total_m2 и
target_class. На выход — распределение по 5 бакетам комнатности с
ожидаемыми ценами/площадями/выручкой и список comparable ЖК.
"""
return q.recommend_mix(
db,
district_name=payload.district_name,
area_total_m2=payload.area_total_m2,
target_class=payload.target_class,
months_window=payload.months_window,
price_factor=payload.price_factor,
target_months=payload.target_months,
)