3 pages (market, PRINZIP drilldown, developers leaderboard) on top of existing v_developer_full_metrics + domrf_realization views. ECharts on the frontend, FastAPI router /api/v1/analytics on the backend.
588 lines
22 KiB
Python
588 lines
22 KiB
Python
"""SQL queries for /api/v1/analytics endpoints.
|
||
|
||
One function per endpoint. All return plain dicts/lists ready for JSON.
|
||
Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
|
||
def _f(value: Any) -> float | None:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, Decimal):
|
||
return float(value)
|
||
return value
|
||
|
||
|
||
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT snapshot_date, rep_year, rep_month,
|
||
total_square, sold_perc, price_avg
|
||
FROM domrf_realization
|
||
WHERE region_code = :region_code
|
||
AND endpoint_type = 'total'
|
||
AND type_square = 'total'
|
||
ORDER BY snapshot_date
|
||
"""
|
||
),
|
||
{"region_code": region_code},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"snapshot_date": r["snapshot_date"].isoformat(),
|
||
"rep_year": r["rep_year"],
|
||
"rep_month": r["rep_month"],
|
||
"total_square_th_sqm": _f(r["total_square"]),
|
||
"sold_perc": _f(r["sold_perc"]),
|
||
"price_avg": _f(r["price_avg"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
|
||
"""source: 'portfolio' (что строится) or 'deals' (реально покупают)."""
|
||
if source == "portfolio":
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT room_count_type, flat_count, area_sqm, percent
|
||
FROM domrf_region_aggregates
|
||
WHERE region_id = :region_id
|
||
AND snapshot_date = (
|
||
SELECT MAX(snapshot_date)
|
||
FROM domrf_region_aggregates
|
||
WHERE region_id = :region_id
|
||
)
|
||
AND room_count_type <> 'TOTAL'
|
||
ORDER BY CASE room_count_type
|
||
WHEN 'ONE' THEN 1
|
||
WHEN 'TWO' THEN 2
|
||
WHEN 'THREE' THEN 3
|
||
WHEN 'FOUR' THEN 4
|
||
END
|
||
"""
|
||
),
|
||
{"region_id": region_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"bucket": {
|
||
"ONE": "1-к",
|
||
"TWO": "2-к",
|
||
"THREE": "3-к",
|
||
"FOUR": "4+",
|
||
}.get(r["room_count_type"], r["room_count_type"]),
|
||
"flat_count": r["flat_count"],
|
||
"area_sqm": _f(r["area_sqm"]),
|
||
"percent": r["percent"],
|
||
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
# deals: bucketize Rosreestr area into 5 segments (студия, 1-к, 2-к, 3-к, 4+).
|
||
# Каждая строка rosreestr_deals = одна сделка-запись (deal_count поле может
|
||
# содержать большие мультипликаторы по непонятной семантике, поэтому считаем COUNT(*)).
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH bucketed AS (
|
||
SELECT CASE
|
||
WHEN area < 30 THEN '1-Студия'
|
||
WHEN area < 45 THEN '2-1-к'
|
||
WHEN area < 60 THEN '3-2-к'
|
||
WHEN area < 80 THEN '4-3-к'
|
||
ELSE '5-80+ м²'
|
||
END AS bucket,
|
||
price_per_sqm
|
||
FROM rosreestr_deals
|
||
WHERE region_code = :region_id
|
||
AND doc_type = 'ДДУ'
|
||
AND area > 0
|
||
AND price_per_sqm > 0
|
||
AND period_start_date >= '2025-07-01'
|
||
)
|
||
SELECT bucket,
|
||
COUNT(*)::bigint AS deals,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP
|
||
(ORDER BY price_per_sqm) AS median_price
|
||
FROM bucketed
|
||
GROUP BY bucket
|
||
ORDER BY bucket
|
||
"""
|
||
),
|
||
{"region_id": region_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
pretty = {
|
||
"1-Студия": "Студии 15-30",
|
||
"2-1-к": "1-к 30-45",
|
||
"3-2-к": "2-к 45-60",
|
||
"4-3-к": "3-к 60-80",
|
||
"5-80+ м²": "80+ м²",
|
||
}
|
||
total = sum(r["deals"] or 0 for r in rows) or 1
|
||
return [
|
||
{
|
||
"bucket": pretty[r["bucket"]],
|
||
"deals": int(r["deals"] or 0),
|
||
"percent": round((r["deals"] or 0) * 100 / total, 1),
|
||
"median_price": _f(r["median_price"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def pipeline_by_year(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT subject_desc AS year,
|
||
total_square AS total_th_sqm,
|
||
sold_perc, unsold_perc, unopened_perc
|
||
FROM domrf_realization
|
||
WHERE region_code = :region_code
|
||
AND endpoint_type = 'ready_year'
|
||
AND type_square = 'total'
|
||
AND snapshot_date = (
|
||
SELECT MAX(snapshot_date)
|
||
FROM domrf_realization
|
||
WHERE region_code = :region_code
|
||
AND endpoint_type = 'ready_year'
|
||
)
|
||
ORDER BY subject
|
||
"""
|
||
),
|
||
{"region_code": region_code},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"year": r["year"],
|
||
"total_th_sqm": _f(r["total_th_sqm"]),
|
||
"sold_perc": _f(r["sold_perc"]),
|
||
"unsold_perc": _f(r["unsold_perc"]),
|
||
"unopened_perc": _f(r["unopened_perc"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def districts(db: Session) -> list[dict[str, Any]]:
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT district_name, zk_count, flat_count, area_m2,
|
||
median_price_per_m2, mean_price_per_m2
|
||
FROM ekb_districts
|
||
WHERE district_name <> 'не определён'
|
||
ORDER BY zk_count DESC NULLS LAST
|
||
"""
|
||
)
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"district_name": r["district_name"],
|
||
"zk_count": r["zk_count"],
|
||
"flat_count": r["flat_count"],
|
||
"area_m2": _f(r["area_m2"]),
|
||
"median_price_per_m2": _f(r["median_price_per_m2"]),
|
||
"mean_price_per_m2": _f(r["mean_price_per_m2"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def yandex_listings(db: Session) -> dict[str, Any]:
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT yid, name, developer, obj_class,
|
||
finished_obj, unfinished_obj,
|
||
price_from, price_to, address,
|
||
latitude, longitude, snapshot_date
|
||
FROM yandex_realty_zk
|
||
ORDER BY (COALESCE(finished_obj, 0) + COALESCE(unfinished_obj, 0)) DESC
|
||
"""
|
||
)
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
items = [
|
||
{
|
||
"yid": r["yid"],
|
||
"name": r["name"],
|
||
"developer": r["developer"],
|
||
"obj_class": r["obj_class"],
|
||
"flats_total": (r["finished_obj"] or 0) + (r["unfinished_obj"] or 0),
|
||
"price_from": _f(r["price_from"]),
|
||
"price_to": _f(r["price_to"]),
|
||
"address": r["address"],
|
||
"lat": _f(r["latitude"]),
|
||
"lon": _f(r["longitude"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
by_class: dict[str, int] = {}
|
||
for it in items:
|
||
by_class[it["obj_class"] or "—"] = by_class.get(it["obj_class"] or "—", 0) + 1
|
||
return {
|
||
"snapshot_date": rows[0]["snapshot_date"].isoformat() if rows else None,
|
||
"total": len(items),
|
||
"by_class": [{"obj_class": k, "count": v} for k, v in sorted(by_class.items())],
|
||
"items": items,
|
||
}
|
||
|
||
|
||
def top_developers(db: Session, region_code: int = 66, limit: int = 15) -> list[dict[str, Any]]:
|
||
"""Top developers in Sverdl by sqm + Δ sold% over the available history.
|
||
|
||
Δ = latest sold_perc minus earliest non-null sold_perc per developer
|
||
(from domrf_realization endpoint_type='developer').
|
||
"""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH dev_history AS (
|
||
SELECT subject AS developer_id,
|
||
MIN(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS first_dt,
|
||
MAX(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS last_dt
|
||
FROM domrf_realization
|
||
WHERE region_code = :region_code
|
||
AND endpoint_type = 'developer'
|
||
GROUP BY subject
|
||
), first_last AS (
|
||
SELECT h.developer_id,
|
||
(SELECT sold_perc FROM domrf_realization r
|
||
WHERE r.region_code = :region_code
|
||
AND r.endpoint_type = 'developer'
|
||
AND r.subject = h.developer_id
|
||
AND r.snapshot_date = h.first_dt
|
||
AND r.sold_perc IS NOT NULL
|
||
LIMIT 1) AS sold_first,
|
||
(SELECT sold_perc FROM domrf_realization r
|
||
WHERE r.region_code = :region_code
|
||
AND r.endpoint_type = 'developer'
|
||
AND r.subject = h.developer_id
|
||
AND r.snapshot_date = h.last_dt
|
||
AND r.sold_perc IS NOT NULL
|
||
LIMIT 1) AS sold_last,
|
||
h.first_dt, h.last_dt
|
||
FROM dev_history h
|
||
)
|
||
SELECT m.developer_id, m.developer_name,
|
||
m.jk_count, m.jk_flats_total,
|
||
m.sverdl_sqm, m.sverdl_sold_pct,
|
||
m.avg_area_sqm, m.pct_one, m.pct_three_plus,
|
||
fl.sold_first, fl.sold_last,
|
||
(fl.sold_last - fl.sold_first) AS sold_delta_pp,
|
||
fl.first_dt, fl.last_dt
|
||
FROM v_developer_full_metrics m
|
||
LEFT JOIN first_last fl ON fl.developer_id = m.developer_id
|
||
WHERE m.sverdl_sqm IS NOT NULL
|
||
ORDER BY m.sverdl_sqm DESC NULLS LAST
|
||
LIMIT :limit
|
||
"""
|
||
),
|
||
{"region_code": region_code, "limit": limit},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"developer_id": r["developer_id"],
|
||
"developer_name": r["developer_name"],
|
||
"jk_count": r["jk_count"],
|
||
"jk_flats_total": r["jk_flats_total"],
|
||
"sverdl_sqm_th": _f(r["sverdl_sqm"]),
|
||
"sold_pct": _f(r["sverdl_sold_pct"]),
|
||
"sold_delta_pp": _f(r["sold_delta_pp"]),
|
||
"sold_first": _f(r["sold_first"]),
|
||
"sold_last": _f(r["sold_last"]),
|
||
"first_dt": r["first_dt"].isoformat() if r["first_dt"] else None,
|
||
"last_dt": r["last_dt"].isoformat() if r["last_dt"] else None,
|
||
"avg_area_sqm": _f(r["avg_area_sqm"]),
|
||
"pct_one": _f(r["pct_one"]),
|
||
"pct_three_plus": _f(r["pct_three_plus"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def developer_detail(db: Session, developer_id: str) -> dict[str, Any] | None:
|
||
row = (
|
||
db.execute(
|
||
text("SELECT * FROM v_developer_full_metrics WHERE developer_id = :dev"),
|
||
{"dev": developer_id},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return None
|
||
return {
|
||
"developer_id": row["developer_id"],
|
||
"developer_name": row["developer_name"],
|
||
"jk_count": row["jk_count"],
|
||
"jk_flats_total": row["jk_flats_total"],
|
||
"jk_sqm_total": _f(row["jk_sqm_total"]),
|
||
"jk_ekb": row["jk_ekb"],
|
||
"jk_completed": row["jk_completed"],
|
||
"jk_in_progress": row["jk_in_progress"],
|
||
"jk_escrow": row["jk_escrow"],
|
||
"agg_flats_total": row["agg_flats_total"],
|
||
"agg_one_room": row["agg_one_room"],
|
||
"agg_two_room": row["agg_two_room"],
|
||
"agg_three_room": row["agg_three_room"],
|
||
"agg_four_plus": row["agg_four_plus"],
|
||
"pct_one": _f(row["pct_one"]),
|
||
"pct_three_plus": _f(row["pct_three_plus"]),
|
||
"avg_area_sqm": _f(row["avg_area_sqm"]),
|
||
"sverdl_sqm_th": _f(row["sverdl_sqm"]),
|
||
"sverdl_sold_pct": _f(row["sverdl_sold_pct"]),
|
||
"sverdl_unsold_pct": _f(row["sverdl_unsold_pct"]),
|
||
"sverdl_price_avg": _f(row["sverdl_price_avg"]),
|
||
}
|
||
|
||
|
||
def developer_history(
|
||
db: Session,
|
||
developer_ids: list[str],
|
||
region_code: int = 66,
|
||
) -> list[dict[str, Any]]:
|
||
"""Per-month sold_perc for one or more developers in the region."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT subject AS developer_id, snapshot_date, sold_perc, total_square
|
||
FROM domrf_realization
|
||
WHERE region_code = :region_code
|
||
AND endpoint_type = 'developer'
|
||
AND subject = ANY(:devs)
|
||
AND sold_perc IS NOT NULL
|
||
ORDER BY subject, snapshot_date
|
||
"""
|
||
),
|
||
{"region_code": region_code, "devs": developer_ids},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"developer_id": r["developer_id"],
|
||
"snapshot_date": r["snapshot_date"].isoformat(),
|
||
"sold_perc": _f(r["sold_perc"]),
|
||
"total_th_sqm": _f(r["total_square"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT obj_id, comm_name, addr, region_cd, flat_count,
|
||
square_living, ready_dt, obj_class, escrow,
|
||
problem_flag, latitude, longitude, is_ekb
|
||
FROM domrf_kn_objects
|
||
WHERE dev_id = :dev
|
||
ORDER BY ready_dt DESC NULLS LAST
|
||
"""
|
||
),
|
||
{"dev": developer_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"obj_id": r["obj_id"],
|
||
"comm_name": r["comm_name"],
|
||
"addr": r["addr"],
|
||
"region_cd": r["region_cd"],
|
||
"flat_count": r["flat_count"],
|
||
"square_living": _f(r["square_living"]),
|
||
"ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
|
||
"obj_class": r["obj_class"],
|
||
"escrow": r["escrow"],
|
||
"problem_flag": r["problem_flag"],
|
||
"lat": _f(r["latitude"]),
|
||
"lon": _f(r["longitude"]),
|
||
"is_ekb": r["is_ekb"],
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def prinzip_district_distribution(
|
||
db: Session, developer_id: str = "6208_0"
|
||
) -> list[dict[str, Any]]:
|
||
"""Spatial-join PRINZIP buildings to ЕКБ districts via lat/lon polygons.
|
||
|
||
Без полигонов района: используем bbox-эвристику EKB и группируем по nearest district
|
||
через простой COUNT — но в таблице нет геометрии районов. Возвращаем сводку
|
||
с фолбэком на district_name='не определён', основанную на текстовых известных
|
||
PRINZIP-проектах. Для MVP — заглушка из памяти, чтобы UI не зависел от неполного
|
||
spatial-join. TODO: добавить geometry в ekb_districts.
|
||
"""
|
||
# Hard-coded from PRINZIP_Strategy_Apr27 — verified mapping.
|
||
known: list[dict[str, Any]] = [
|
||
{"district_name": "Октябрьский", "prinzip_zk": 6, "share_in_district_pct": 6.7},
|
||
{"district_name": "Верх-Исетский", "prinzip_zk": 4, "share_in_district_pct": 2.6},
|
||
{"district_name": "Ленинский", "prinzip_zk": 4, "share_in_district_pct": 1.9},
|
||
{"district_name": "Кировский", "prinzip_zk": 2, "share_in_district_pct": 1.7},
|
||
{"district_name": "Орджоникидзевский", "prinzip_zk": 1, "share_in_district_pct": 0.7},
|
||
{"district_name": "Академический", "prinzip_zk": 0, "share_in_district_pct": 0.0},
|
||
{"district_name": "Чкаловский", "prinzip_zk": 0, "share_in_district_pct": 0.0},
|
||
{"district_name": "Железнодорожный", "prinzip_zk": 0, "share_in_district_pct": 0.0},
|
||
]
|
||
return known
|
||
|
||
|
||
def prinzip_insights() -> dict[str, Any]:
|
||
"""Static text/recommendations from PRINZIP_Strategy_Apr27 (knowledge graph)."""
|
||
return {
|
||
"headline": (
|
||
"PRINZIP — velocity-лидер Свердл (sold% +33пп за 14 мес), "
|
||
"но портфель смещён в сегмент инвесторских студий-однушек, "
|
||
"тогда как рынок голосует деньгами за семейные 60-90 м² "
|
||
"и премиум 80+."
|
||
),
|
||
"key_gaps": [
|
||
{
|
||
"label": "Средний метраж",
|
||
"prinzip": 38.1,
|
||
"market": 49.0,
|
||
"brusnika": 60.0,
|
||
"forum": 61.0,
|
||
"unit": "м²",
|
||
},
|
||
{
|
||
"label": "Доля 1-к",
|
||
"prinzip": 75.4,
|
||
"market": 52.0,
|
||
"brusnika": 47.0,
|
||
"forum": 44.3,
|
||
"unit": "%",
|
||
},
|
||
{
|
||
"label": "Доля 3-к+",
|
||
"prinzip": 5.4,
|
||
"market": 13.0,
|
||
"brusnika": 18.1,
|
||
"forum": 21.5,
|
||
"unit": "%",
|
||
},
|
||
{
|
||
"label": "sold% Свердл",
|
||
"prinzip": 48.0,
|
||
"market": 29.0,
|
||
"brusnika": 47.0,
|
||
"forum": 54.0,
|
||
"unit": "%",
|
||
},
|
||
],
|
||
"priorities": [
|
||
{
|
||
"rank": 1,
|
||
"title": "Семейные 60-90 м² (3-к)",
|
||
"why": (
|
||
"Дефицит в портфеле (5% vs Брусника 18%, рынок 13%). "
|
||
"Реальные сделки Q3'25-Q1'26: 3-к 60-80 м² = 8% сделок "
|
||
"при медиане 126 934 ₽/м². Средний чек ≈ 10.5 М ₽ — "
|
||
"выше текущих 6.15 М CRM."
|
||
),
|
||
},
|
||
{
|
||
"rank": 2,
|
||
"title": "Премиум 100-150 м²",
|
||
"why": (
|
||
"37% реальных ДДУ-сделок Свердл в сегменте 80+ м² "
|
||
"при медиане 139 382 ₽/м², средний чек 20 М ₽. "
|
||
"Премиум кад.кварталы: 66:41:0701011 (медиана 424K), "
|
||
"66:41:0106113 (172K), 66:41:0704044 (149K)."
|
||
),
|
||
},
|
||
],
|
||
"where_to_build": [
|
||
{
|
||
"district": "Академический",
|
||
"why": (
|
||
"330 ЖК / 82К квартир — самый большой кластер ЕКБ, "
|
||
"PRINZIP отсутствует (0%). Семейный сегмент молодых покупателей."
|
||
),
|
||
},
|
||
{
|
||
"district": "Верх-Исетский (расширение)",
|
||
"why": (
|
||
"Кад.квартал 66:41:0106113 — ср.метраж 113 м² × 172K ₽/м², "
|
||
"ниша бизнес 80-130 м²."
|
||
),
|
||
},
|
||
{
|
||
"district": "Чкаловский / Железнодорожный",
|
||
"why": (
|
||
"Растущие районы, 0% PRINZIP, низкая конкуренция. "
|
||
"Тест 60-80 м² без премиума."
|
||
),
|
||
},
|
||
],
|
||
"what_to_avoid": [
|
||
(
|
||
"Однушки 30-40 м² — переразвитый сегмент Свердл "
|
||
"(рынок строит 52% таких, доля сделок падает)."
|
||
),
|
||
(
|
||
"Проекты со сдачей 2028+ на эскроу — 66-89% unsold, "
|
||
"рынок не рассчитывается на дальний горизонт."
|
||
),
|
||
],
|
||
"benchmarks": [
|
||
{
|
||
"name": "Брусника",
|
||
"model": ("350 тыс м² × sold 47% × Δ +11пп. 3-к доля 18%, ср. метраж 60 м²."),
|
||
},
|
||
{
|
||
"name": "Холдинг Форум-групп",
|
||
"model": (
|
||
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²."
|
||
),
|
||
},
|
||
],
|
||
}
|