Scraper resume: - /admin/scrape/queue autoclean уменьшен с 60min started_at до 10min COALESCE(heartbeat_at, started_at). Не режет валидные long sweeps. - Phase A пишет heartbeat после каждого objStatus fetch (3min Phase A больше не рискует быть отмеченной как zombie при новом 10min threshold). Recommend (Уровень 1 калькулятор): - POST /api/v1/analytics/recommend/mix с velocity baseline (sale_graph), price elasticity (regr_slope/r2 на sale_graph, fallback -1.5), inverse mode (target_months → required price_factor), liquidity score 24mo, headline. - /analytics/recommend: RecommendVelocityPanel (price slider 0.85..1.15 + 4 KPI Чек/Срок/Темп/Ликвидность + методология эластичности), RecommendLiquidityChart (cumulative 0..36 mo с пунктиром на 24). - BucketsTable: +колонки Темп и Срок, цены/выручка масштабируются по priceFactor live. - Все слайдеры и target_months считаются клиентски — никаких round-trip. Fix: SQLAlchemy не парсит :cls::text — заменено на CAST(:cls AS TEXT).
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
"""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,
|
||
price_factor=payload.price_factor,
|
||
target_months=payload.target_months,
|
||
)
|