1268 lines
45 KiB
Python
1268 lines
45 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 м²."
|
||
),
|
||
},
|
||
],
|
||
}
|
||
|
||
|
||
# ── Per-object drill-in (поверх extras-таблиц из 51_schema_kn_extras.sql) ────
|
||
|
||
|
||
def object_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
|
||
"""Базовая инфа объекта из domrf_kn_objects (последний snapshot)."""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT obj_id, hobj_id, comm_name, addr, short_addr, region_cd,
|
||
dev_id, dev_name, floor_min, floor_max, flat_count, square_living,
|
||
ready_dt, site_status, escrow, obj_class, latitude, longitude,
|
||
obj_status, snapshot_date
|
||
FROM domrf_kn_objects
|
||
WHERE obj_id = :obj
|
||
ORDER BY snapshot_date DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"obj": obj_id},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return None
|
||
return {
|
||
"obj_id": row["obj_id"],
|
||
"hobj_id": row["hobj_id"],
|
||
"comm_name": row["comm_name"],
|
||
"addr": row["addr"],
|
||
"short_addr": row["short_addr"],
|
||
"region_cd": row["region_cd"],
|
||
"dev_id": row["dev_id"],
|
||
"dev_name": row["dev_name"],
|
||
"floor_min": row["floor_min"],
|
||
"floor_max": row["floor_max"],
|
||
"flat_count": row["flat_count"],
|
||
"square_living": _f(row["square_living"]),
|
||
"ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None,
|
||
"site_status": row["site_status"],
|
||
"escrow": row["escrow"],
|
||
"obj_class": row["obj_class"],
|
||
"latitude": _f(row["latitude"]),
|
||
"longitude": _f(row["longitude"]),
|
||
"obj_status": row["obj_status"],
|
||
"snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None,
|
||
}
|
||
|
||
|
||
def object_sale_graph(
|
||
db: Session, obj_id: int, type_filter: str | None = None
|
||
) -> list[dict[str, Any]]:
|
||
"""Time-series продаж per-ЖК. Latest snapshot."""
|
||
where_type = ""
|
||
params: dict[str, Any] = {"obj": obj_id}
|
||
if type_filter:
|
||
where_type = "AND type = :type_filter"
|
||
params["type_filter"] = type_filter
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
SELECT obj_id, report_month, type, realised, contracted,
|
||
area_sq, price_avg, snapshot_date
|
||
FROM domrf_kn_sale_graph
|
||
WHERE obj_id = :obj
|
||
{where_type}
|
||
AND snapshot_date = (
|
||
SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph
|
||
WHERE obj_id = :obj {where_type}
|
||
)
|
||
ORDER BY type, report_month
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"report_month": r["report_month"].isoformat() if r["report_month"] else None,
|
||
"type": r["type"],
|
||
"realised": r["realised"],
|
||
"contracted": r["contracted"],
|
||
"area_sq": _f(r["area_sq"]),
|
||
"price_avg": _f(r["price_avg"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def object_sales_agg(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||
"""3 строки текущих агрегатов: apartments / nonliv / parking."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT type, name, total, realised, perc, snapshot_date
|
||
FROM domrf_kn_sales_agg
|
||
WHERE obj_id = :obj
|
||
AND snapshot_date = (
|
||
SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg WHERE obj_id = :obj
|
||
)
|
||
ORDER BY CASE type
|
||
WHEN 'apartments' THEN 1
|
||
WHEN 'nonliv' THEN 2
|
||
ELSE 3
|
||
END
|
||
"""
|
||
),
|
||
{"obj": obj_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"type": r["type"],
|
||
"name": r["name"],
|
||
"total": r["total"],
|
||
"realised": r["realised"],
|
||
"perc": _f(r["perc"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def object_infrastructure(
|
||
db: Session,
|
||
obj_id: int,
|
||
category: str | None = None,
|
||
max_distance: int = 5000,
|
||
) -> list[dict[str, Any]]:
|
||
"""POI вокруг ЖК с фильтром по категории и радиусу."""
|
||
where_cat = "AND poi_category = :cat" if category else ""
|
||
params: dict[str, Any] = {"obj": obj_id, "dist": max_distance}
|
||
if category:
|
||
params["cat"] = category
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
SELECT poi_name, poi_subtitle, poi_category, poi_address,
|
||
poi_lat, poi_lon, distance_m
|
||
FROM domrf_kn_infrastructure
|
||
WHERE obj_id = :obj
|
||
AND distance_m <= :dist
|
||
{where_cat}
|
||
AND snapshot_date = (
|
||
SELECT MAX(snapshot_date) FROM domrf_kn_infrastructure WHERE obj_id = :obj
|
||
)
|
||
ORDER BY distance_m ASC
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"poi_name": r["poi_name"],
|
||
"poi_subtitle": r["poi_subtitle"],
|
||
"poi_category": r["poi_category"],
|
||
"poi_address": r["poi_address"],
|
||
"lat": _f(r["poi_lat"]),
|
||
"lon": _f(r["poi_lon"]),
|
||
"distance_m": _f(r["distance_m"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str, Any]]:
|
||
"""Фото-метаданные, последние сверху."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT obj_file_id, ord_num, photo_url, photo_dttm, period_dt,
|
||
size_bytes, photo_name, ready_desc, build_type, hidden, local_path
|
||
FROM domrf_kn_photos
|
||
WHERE obj_id = :obj AND COALESCE(hidden, FALSE) = FALSE
|
||
ORDER BY period_dt DESC NULLS LAST, ord_num DESC NULLS LAST
|
||
LIMIT :lim
|
||
"""
|
||
),
|
||
{"obj": obj_id, "lim": limit},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"obj_file_id": r["obj_file_id"],
|
||
"ord_num": r["ord_num"],
|
||
"photo_url": r["photo_url"],
|
||
# Always serve thumbs through our backend — cached WebP, no upstream
|
||
# latency, no Next.js dev-mode optimizer cold-hit cost.
|
||
"thumb_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=thumb",
|
||
"full_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=full",
|
||
"photo_dttm": r["photo_dttm"].isoformat() if r["photo_dttm"] else None,
|
||
"period_dt": r["period_dt"].isoformat() if r["period_dt"] else None,
|
||
"size_bytes": r["size_bytes"],
|
||
"photo_name": r["photo_name"],
|
||
"ready_desc": r["ready_desc"],
|
||
"build_type": r["build_type"],
|
||
"local_path": r["local_path"],
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]:
|
||
"""Воронка по месяцам из materialized view."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT month, source, leads, engaged, converted, conv_pct
|
||
FROM prinzip_funnel_monthly
|
||
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
|
||
ORDER BY month DESC, leads DESC
|
||
"""
|
||
),
|
||
{"months": months},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"month": r["month"].isoformat() if r["month"] else None,
|
||
"source": r["source"],
|
||
"leads": r["leads"],
|
||
"engaged": r["engaged"],
|
||
"converted": r["converted"],
|
||
"conv_pct": _f(r["conv_pct"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, Any]]:
|
||
"""Агрегаты по source за последние N месяцев."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT source,
|
||
SUM(leads) AS leads,
|
||
SUM(engaged) AS engaged,
|
||
SUM(converted) AS converted,
|
||
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
|
||
FROM prinzip_funnel_monthly
|
||
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
|
||
GROUP BY source
|
||
ORDER BY leads DESC
|
||
"""
|
||
),
|
||
{"months": months},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"source": r["source"],
|
||
"leads": int(r["leads"] or 0),
|
||
"engaged": int(r["engaged"] or 0),
|
||
"converted": int(r["converted"] or 0),
|
||
"conv_pct": _f(r["conv_pct"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def prinzip_funnel_by_object(db: Session) -> list[dict[str, Any]]:
|
||
"""Conversion per ЖК."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT obj_id, comm_name, leads_count, deals_count, conv_pct,
|
||
total_revenue, avg_deal_price
|
||
FROM prinzip_funnel_by_object
|
||
ORDER BY total_revenue DESC NULLS LAST
|
||
"""
|
||
),
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"obj_id": r["obj_id"],
|
||
"comm_name": r["comm_name"],
|
||
"leads_count": r["leads_count"],
|
||
"deals_count": r["deals_count"],
|
||
"conv_pct": _f(r["conv_pct"]),
|
||
"total_revenue": _f(r["total_revenue"]),
|
||
"avg_deal_price": _f(r["avg_deal_price"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def prinzip_objects_with_velocity(db: Session) -> list[dict[str, Any]]:
|
||
"""Список 28 PRINZIP-ЖК с агрегатами + apartments-velocity sparkline data."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH agg AS (
|
||
SELECT obj_id, total, realised, perc
|
||
FROM domrf_kn_sales_agg
|
||
WHERE type = 'apartments'
|
||
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg)
|
||
),
|
||
velocity AS (
|
||
SELECT obj_id,
|
||
ARRAY_AGG(realised ORDER BY report_month) AS sparkline_realised,
|
||
ARRAY_AGG(report_month::text ORDER BY report_month) AS months
|
||
FROM domrf_kn_sale_graph
|
||
WHERE type = 'apartments'
|
||
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph)
|
||
GROUP BY obj_id
|
||
)
|
||
SELECT o.obj_id, o.comm_name, o.addr, o.flat_count, o.square_living, o.ready_dt,
|
||
o.site_status,
|
||
a.total, a.realised, a.perc,
|
||
v.sparkline_realised, v.months
|
||
FROM domrf_kn_objects o
|
||
LEFT JOIN agg a ON a.obj_id = o.obj_id
|
||
LEFT JOIN velocity v ON v.obj_id = o.obj_id
|
||
WHERE o.dev_id = '6208_0'
|
||
AND o.snapshot_date = (
|
||
SELECT MAX(snapshot_date) FROM domrf_kn_objects WHERE dev_id = '6208_0'
|
||
)
|
||
ORDER BY a.total DESC NULLS LAST
|
||
"""
|
||
),
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"obj_id": r["obj_id"],
|
||
"comm_name": r["comm_name"],
|
||
"addr": r["addr"],
|
||
"flat_count": r["flat_count"],
|
||
"square_living": _f(r["square_living"]),
|
||
"ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
|
||
"site_status": r["site_status"],
|
||
"total": r["total"],
|
||
"realised": r["realised"],
|
||
"perc": _f(r["perc"]),
|
||
"sparkline_realised": (
|
||
list(r["sparkline_realised"]) if r["sparkline_realised"] else []
|
||
),
|
||
"months": list(r["months"]) if r["months"] else [],
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
# ── Rule-based recommender (Уровень 1) ────────────────────────────────────────
|
||
|
||
# Pretty-name map shared with quartirography_deals(). Keep IDs sortable so
|
||
# bucket ordering is deterministic in the response.
|
||
_BUCKET_PRETTY: dict[str, str] = {
|
||
"1-Студия": "Студии 15-30",
|
||
"2-1-к": "1-к 30-45",
|
||
"3-2-к": "2-к 45-60",
|
||
"4-3-к": "3-к 60-80",
|
||
"5-80+ м²": "80+ м²",
|
||
}
|
||
|
||
|
||
_BUCKET_SQL = 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,
|
||
area,
|
||
price_per_sqm
|
||
FROM rosreestr_deals
|
||
WHERE region_code = :rc
|
||
AND doc_type = 'ДДУ'
|
||
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
|
||
-- 001 = земельные участки, 002 = нежилые помещения.
|
||
AND realestate_type_code = '002001003000'
|
||
AND area > 10
|
||
AND area <= 200 -- отсечь выбросы (коммерческие площади)
|
||
AND price_per_sqm BETWEEN 30000 AND 1000000
|
||
AND period_start_date >= NOW()
|
||
- (:months_window || ' months')::INTERVAL
|
||
)
|
||
SELECT bucket,
|
||
COUNT(*)::bigint AS deals,
|
||
AVG(area) AS area_avg,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP
|
||
(ORDER BY price_per_sqm) AS price_median,
|
||
PERCENTILE_CONT(0.25) WITHIN GROUP
|
||
(ORDER BY price_per_sqm) AS price_p25,
|
||
PERCENTILE_CONT(0.75) WITHIN GROUP
|
||
(ORDER BY price_per_sqm) AS price_p75
|
||
FROM bucketed
|
||
GROUP BY bucket
|
||
ORDER BY bucket
|
||
"""
|
||
)
|
||
|
||
|
||
def _bucket_distribution(db: Session, region_code: int, months_window: int) -> list[Any]:
|
||
return list(
|
||
db.execute(
|
||
_BUCKET_SQL,
|
||
{"rc": region_code, "months_window": months_window},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
|
||
def recommend_mix(
|
||
db: Session,
|
||
*,
|
||
district_name: str,
|
||
area_total_m2: float | None = None,
|
||
target_class: str | None = None,
|
||
months_window: int = 12,
|
||
region_code: int = 66,
|
||
) -> dict[str, Any]:
|
||
"""Rule-based квартирография recommender.
|
||
|
||
City-wide bucket distribution from rosreestr_deals (последние N месяцев),
|
||
скорректированная на район (через ekb_districts.median_price_per_m2) и
|
||
класс (через yandex_realty_zk price-агрегаты per-class).
|
||
|
||
See plan: C:/Users/user/.claude/plans/crispy-swinging-gadget.md
|
||
"""
|
||
warnings: list[str] = []
|
||
|
||
# 1) District lookup
|
||
district_row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT district_name, zk_count, flat_count,
|
||
median_price_per_m2, mean_price_per_m2
|
||
FROM ekb_districts
|
||
WHERE district_name ILIKE :dn
|
||
AND district_name <> 'не определён'
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"dn": district_name.strip()},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not district_row:
|
||
return {
|
||
"scope": {"district": district_name, "error": "district unknown"},
|
||
"buckets": [],
|
||
"summary": {
|
||
"total_revenue_rub": None,
|
||
"weighted_avg_price_per_m2": None,
|
||
"warnings": [f"Район '{district_name}' не найден в ekb_districts"],
|
||
},
|
||
"comparables": [],
|
||
}
|
||
|
||
district_median = _f(district_row["median_price_per_m2"])
|
||
if district_median is None:
|
||
warnings.append(
|
||
f"В ekb_districts нет median_price_per_m2 для '{district_row['district_name']}',"
|
||
" коэффициент района = 1.0"
|
||
)
|
||
|
||
# 2) City-wide median baseline
|
||
city_median = _f(
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY median_price_per_m2)
|
||
FROM ekb_districts
|
||
WHERE median_price_per_m2 IS NOT NULL
|
||
"""
|
||
)
|
||
).scalar()
|
||
)
|
||
|
||
district_factor = (
|
||
district_median / city_median
|
||
if (district_median and city_median and city_median > 0)
|
||
else 1.0
|
||
)
|
||
|
||
# 3) Class multiplier from yandex_realty_zk price ranges (price_from)
|
||
class_multiplier = 1.0
|
||
if target_class:
|
||
cls_row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg,
|
||
AVG(price_from) AS overall_avg
|
||
FROM yandex_realty_zk
|
||
WHERE price_from IS NOT NULL AND price_from > 0
|
||
"""
|
||
),
|
||
{"cls": target_class},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
cavg = _f(cls_row["class_avg"]) if cls_row else None
|
||
oavg = _f(cls_row["overall_avg"]) if cls_row else None
|
||
if cavg and oavg and oavg > 0:
|
||
class_multiplier = cavg / oavg
|
||
else:
|
||
warnings.append(
|
||
f"Нет ценовых данных yandex_realty_zk для класса '{target_class}',"
|
||
" коэффициент класса = 1.0"
|
||
)
|
||
|
||
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
|
||
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
|
||
# для всех бакетов и проставляем warning. Это даёт более устойчивые медианы.
|
||
bucket_rows = _bucket_distribution(db, region_code, months_window)
|
||
effective_window = months_window
|
||
if months_window < 24 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
|
||
bucket_rows_24 = _bucket_distribution(db, region_code, 24)
|
||
if bucket_rows_24:
|
||
bucket_rows = bucket_rows_24
|
||
effective_window = 24
|
||
warnings.append(
|
||
f"Окно расширено до 24 мес: при {months_window} мес хотя бы один"
|
||
" бакет имел <30 сделок — оценка была бы шумной"
|
||
)
|
||
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
|
||
|
||
# 5) Build buckets with adjusted prices + optional allocation
|
||
buckets: list[dict[str, Any]] = []
|
||
weighted_num = 0.0 # Σ area_avg × share × price
|
||
weighted_den = 0.0 # Σ area_avg × share
|
||
total_revenue = 0.0
|
||
have_revenue = False
|
||
for r in bucket_rows:
|
||
bid = r["bucket"]
|
||
deals = int(r["deals"] or 0)
|
||
share = round(deals * 100 / total_deals, 1)
|
||
area_avg = _f(r["area_avg"]) or 0.0
|
||
area_med = _f(r["area_median"]) or 0.0
|
||
p_med_city = _f(r["price_median"]) or 0.0
|
||
p25_city = _f(r["price_p25"]) or 0.0
|
||
p75_city = _f(r["price_p75"]) or 0.0
|
||
|
||
adj = district_factor * class_multiplier
|
||
p_med = round(p_med_city * adj, 2)
|
||
p25 = round(p25_city * adj, 2)
|
||
p75 = round(p75_city * adj, 2)
|
||
|
||
units_planned: int | None = None
|
||
revenue_planned: float | None = None
|
||
if area_total_m2 and area_avg > 0:
|
||
allocated = area_total_m2 * (share / 100.0)
|
||
units_planned = max(1, round(allocated / area_avg))
|
||
revenue_planned = round(units_planned * area_avg * p_med, 2)
|
||
total_revenue += revenue_planned
|
||
have_revenue = True
|
||
|
||
weighted_num += area_avg * share * p_med
|
||
weighted_den += area_avg * share
|
||
|
||
if deals < 30:
|
||
warnings.append(
|
||
f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок"
|
||
f" за {effective_window} мес — оценка с большой погрешностью"
|
||
)
|
||
|
||
buckets.append(
|
||
{
|
||
"bucket": _BUCKET_PRETTY.get(bid, bid),
|
||
"share_pct": share,
|
||
"deal_count": deals,
|
||
"area_avg_m2": round(area_avg, 1),
|
||
"area_median_m2": round(area_med, 1),
|
||
"price_median_per_m2": p_med,
|
||
"price_p25_per_m2": p25,
|
||
"price_p75_per_m2": p75,
|
||
"units_planned": units_planned,
|
||
"revenue_planned_rub": revenue_planned,
|
||
}
|
||
)
|
||
|
||
weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None
|
||
|
||
# 6) Comparable ЖК — same district (parsed from addr) and class
|
||
cmp_rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH latest_agg AS (
|
||
SELECT obj_id, MAX(snapshot_date) AS snap
|
||
FROM domrf_kn_sales_agg
|
||
WHERE type = 'apartments'
|
||
GROUP BY obj_id
|
||
)
|
||
SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.flat_count,
|
||
a.perc AS sold_perc
|
||
FROM domrf_kn_objects o
|
||
LEFT JOIN latest_agg la ON la.obj_id = o.obj_id
|
||
LEFT JOIN domrf_kn_sales_agg a
|
||
ON a.obj_id = la.obj_id
|
||
AND a.snapshot_date = la.snap
|
||
AND a.type = 'apartments'
|
||
WHERE o.region_cd = :rc
|
||
AND o.addr ILIKE '%' || :dn || '%'
|
||
AND (CAST(:cls AS TEXT) IS NULL OR o.obj_class = :cls)
|
||
ORDER BY o.flat_count DESC NULLS LAST
|
||
LIMIT 5
|
||
"""
|
||
),
|
||
{"rc": region_code, "dn": district_row["district_name"], "cls": target_class},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
return {
|
||
"scope": {
|
||
"district": district_row["district_name"],
|
||
"district_zk_count": district_row["zk_count"],
|
||
"district_median_price_per_m2": district_median,
|
||
"district_factor": round(district_factor, 4),
|
||
"class_multiplier": round(class_multiplier, 4),
|
||
"target_class": target_class,
|
||
"months_window": months_window,
|
||
"effective_window_months": effective_window,
|
||
"region_code": region_code,
|
||
"total_deals": total_deals if bucket_rows else 0,
|
||
"data_caveat": (
|
||
"MVP: bucket-распределение город-wide (регион 66). Район влияет"
|
||
" только на ценовой коэффициент. v2 добавит per-district demand"
|
||
" при заведении PostGIS-полигонов."
|
||
),
|
||
},
|
||
"buckets": buckets,
|
||
"summary": {
|
||
"total_revenue_rub": round(total_revenue, 2) if have_revenue else None,
|
||
"weighted_avg_price_per_m2": weighted_avg_price,
|
||
"warnings": warnings,
|
||
},
|
||
"comparables": [
|
||
{
|
||
"obj_id": r["obj_id"],
|
||
"comm_name": r["comm_name"],
|
||
"dev_name": r["dev_name"],
|
||
"obj_class": r["obj_class"],
|
||
"flat_count": r["flat_count"],
|
||
"sold_perc": _f(r["sold_perc"]),
|
||
}
|
||
for r in cmp_rows
|
||
],
|
||
}
|