gendesign/backend/app/api/v1/analytics.py
2026-04-27 21:28:02 +03:00

206 lines
7.4 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.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
# ---- 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,
)