1915 lines
73 KiB
Python
1915 lines
73 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
|
||
|
||
import math
|
||
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()
|
||
)
|
||
|
||
|
||
# Industry-default elasticity used when sale_graph regression is not reliable
|
||
# (n<30 or R²<0.1). Negative because higher price ⇒ slower sales.
|
||
FALLBACK_ELASTICITY = -1.5
|
||
|
||
|
||
def _velocity_baseline(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
) -> dict[str, Any]:
|
||
"""Median monthly sales velocity (apartments/month per ЖК) from
|
||
domrf_kn_sale_graph for objects in the same район+class over last 24 mo.
|
||
|
||
Returns dict {realised_per_month_median, realised_per_month_avg,
|
||
objects_count, observations}. All-None means no data → caller falls back.
|
||
"""
|
||
where_class = "AND o.obj_class = :cls" if target_class else ""
|
||
params: dict[str, Any] = {
|
||
"rc": region_code,
|
||
"dn": district_name,
|
||
}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH obj_pool AS (
|
||
SELECT o.obj_id
|
||
FROM domrf_kn_objects o
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name = :dn
|
||
{where_class}
|
||
),
|
||
sg AS (
|
||
SELECT sg.obj_id, sg.realised
|
||
FROM domrf_kn_sale_graph sg
|
||
JOIN obj_pool p ON p.obj_id = sg.obj_id
|
||
WHERE sg.type = 'apartments'
|
||
AND sg.realised IS NOT NULL
|
||
AND sg.report_month >= NOW() - INTERVAL '24 months'
|
||
)
|
||
SELECT
|
||
AVG(realised) AS avg_pm,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY realised) AS median_pm,
|
||
COUNT(DISTINCT obj_id) AS objects,
|
||
COUNT(*) AS observations
|
||
FROM sg
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return {
|
||
"realised_per_month_avg": None,
|
||
"realised_per_month_median": None,
|
||
"objects_count": 0,
|
||
"observations": 0,
|
||
}
|
||
return {
|
||
"realised_per_month_avg": _f(row["avg_pm"]),
|
||
"realised_per_month_median": _f(row["median_pm"]),
|
||
"objects_count": int(row["objects"] or 0),
|
||
"observations": int(row["observations"] or 0),
|
||
}
|
||
|
||
|
||
def _district_market_saturation(db: Session, *, district_name: str) -> tuple[float | None, int]:
|
||
"""Median sold% активных строящихся ЖК в районе. >50% = зрелый рынок
|
||
(конкуренты много продали, новый проект имеет место). <20% = свежий
|
||
(много инвентаря на продажу, сложнее пробиться).
|
||
|
||
Возвращает (median_pct, n_objects). None если <5 ЖК с perc.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median,
|
||
COUNT(*) AS n
|
||
FROM domrf_kn_sales_agg a
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = a.obj_id
|
||
AND o.snapshot_date = a.snapshot_date
|
||
WHERE a.type = 'apartments'
|
||
AND a.perc IS NOT NULL
|
||
AND o.district_name = :dn
|
||
AND o.site_status = 'Строящиеся'
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row or (row["n"] or 0) < 5:
|
||
return None, int(row["n"] or 0) if row else 0
|
||
return _f(row["sold_median"]), int(row["n"])
|
||
|
||
|
||
def _district_velocity_trend(db: Session, *, district_name: str) -> tuple[float | None, int, int]:
|
||
"""Ratio realised: recent_6mo / prior_6mo. >1.5 — рынок горит, <0.7 —
|
||
остывает. Считаем за окно 12 мес: H1 2025 vs H2 2025+.
|
||
|
||
Возвращает (ratio, recent_units, prior_units). None если данных мало.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
SUM(sg.realised) FILTER (WHERE sg.report_month >= DATE '2025-07-01')
|
||
AS recent,
|
||
SUM(sg.realised) FILTER (WHERE sg.report_month BETWEEN DATE '2025-01-01'
|
||
AND DATE '2025-06-30')
|
||
AS prior
|
||
FROM domrf_kn_sale_graph sg
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = sg.obj_id
|
||
AND o.snapshot_date = sg.snapshot_date
|
||
WHERE sg.type = 'apartments'
|
||
AND o.district_name = :dn
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
recent = int(row["recent"] or 0) if row else 0
|
||
prior = int(row["prior"] or 0) if row else 0
|
||
if prior > 0 and recent > 0:
|
||
return recent / prior, recent, prior
|
||
return None, recent, prior
|
||
|
||
|
||
_POI_WEIGHTS = {
|
||
"Транспорт": 1.5,
|
||
"Метро": 2.0,
|
||
"Образование": 1.2,
|
||
"Медицина": 1.3,
|
||
"Спорт": 1.0,
|
||
"Продукты": 0.8,
|
||
"Развлечения": 0.7,
|
||
"Новостройки": 0.0, # сами ЖК — не используем как amenity
|
||
}
|
||
|
||
|
||
def _district_poi_score(db: Session, *, district_name: str) -> float | None:
|
||
"""Среднее по ЖК района: weighted POI count в радиусе 1000м.
|
||
Используем категории-веса (метро/медицина важнее, новостройки игнор).
|
||
|
||
Возвращает None если в районе <3 ЖК с POI.
|
||
"""
|
||
weights_sql = " ".join(
|
||
[f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
|
||
)
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH per_obj AS (
|
||
SELECT i.obj_id,
|
||
SUM(CASE {weights_sql} ELSE 0.5 END)
|
||
FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
|
||
AS weighted_poi
|
||
FROM domrf_kn_infrastructure i
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = i.obj_id
|
||
AND o.snapshot_date = i.snapshot_date
|
||
WHERE o.district_name = :dn
|
||
GROUP BY i.obj_id
|
||
)
|
||
SELECT AVG(weighted_poi) AS avg_score, COUNT(*) AS n
|
||
FROM per_obj
|
||
WHERE weighted_poi > 0
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row or (row["n"] or 0) < 3:
|
||
return None
|
||
return _f(row["avg_score"])
|
||
|
||
|
||
def _city_avg_poi_score(db: Session, *, region_code: int = 66) -> float | None:
|
||
"""Средний POI score по всему ЕКБ — для нормировки district_poi_score."""
|
||
weights_sql = " ".join(
|
||
[f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
|
||
)
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH per_obj AS (
|
||
SELECT i.obj_id,
|
||
SUM(CASE {weights_sql} ELSE 0.5 END)
|
||
FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
|
||
AS weighted_poi
|
||
FROM domrf_kn_infrastructure i
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = i.obj_id
|
||
AND o.snapshot_date = i.snapshot_date
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name IS NOT NULL
|
||
GROUP BY i.obj_id
|
||
)
|
||
SELECT AVG(weighted_poi) AS avg_score
|
||
FROM per_obj
|
||
WHERE weighted_poi > 0
|
||
"""
|
||
),
|
||
{"rc": region_code},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
return _f(row["avg_score"]) if row else None
|
||
|
||
|
||
def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
|
||
"""Последняя средневзвешенная ипотечная ставка из cbr_mortgage_series.
|
||
Возвращает (rate_pct, period_label)."""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT value, period
|
||
FROM cbr_mortgage_series
|
||
WHERE title ILIKE '%ипотечн%жилищн%'
|
||
AND value IS NOT NULL
|
||
ORDER BY period DESC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return None, None
|
||
return _f(row["value"]), row["period"]
|
||
|
||
|
||
def _active_competitors_count(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
) -> tuple[int, str]:
|
||
"""N активно строящихся ЖК для нормировки velocity. Каскадный fallback:
|
||
1) (район + класс) — самый узкий
|
||
2) (район) без класса — если первый дал <2
|
||
3) весь регион — если второй дал <2
|
||
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
||
|
||
def _q(where_extras: str, params: dict[str, Any]) -> int:
|
||
n = db.execute(
|
||
text(
|
||
f"""
|
||
SELECT COUNT(*) FROM domrf_kn_objects
|
||
WHERE region_cd = :rc
|
||
AND site_status = 'Строящиеся'
|
||
{where_extras}
|
||
"""
|
||
),
|
||
params,
|
||
).scalar()
|
||
return int(n or 0)
|
||
|
||
# Tier 1: район + класс (через PostGIS-полигоны district_name)
|
||
if target_class:
|
||
n = _q(
|
||
"AND district_name = :dn AND obj_class = :cls",
|
||
{"rc": region_code, "dn": district_name, "cls": target_class},
|
||
)
|
||
if n >= 2:
|
||
return n, "district+class"
|
||
|
||
# Tier 2: район (без класса — могут быть ЖК где obj_class NULL)
|
||
n = _q(
|
||
"AND district_name = :dn",
|
||
{"rc": region_code, "dn": district_name},
|
||
)
|
||
if n >= 2:
|
||
return n, "district"
|
||
|
||
# Tier 3: весь регион (когда район по сути не покрыт скрапером)
|
||
n = _q("", {"rc": region_code})
|
||
if n >= 1:
|
||
return n, "region"
|
||
|
||
return 1, "fallback_singleton"
|
||
|
||
|
||
def _elasticity_coef(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
) -> dict[str, Any]:
|
||
"""Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph
|
||
observations for the same район+class. Returns elasticity (slope), R²,
|
||
n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak."""
|
||
where_class = "AND o.obj_class = :cls" if target_class else ""
|
||
params: dict[str, Any] = {"rc": region_code, "dn": district_name}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH obj_pool AS (
|
||
SELECT o.obj_id
|
||
FROM domrf_kn_objects o
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name = :dn
|
||
{where_class}
|
||
),
|
||
pts AS (
|
||
SELECT LN(sg.realised)::float8 AS y,
|
||
LN(sg.price_avg)::float8 AS x
|
||
FROM domrf_kn_sale_graph sg
|
||
JOIN obj_pool p ON p.obj_id = sg.obj_id
|
||
WHERE sg.type = 'apartments'
|
||
AND sg.realised IS NOT NULL AND sg.realised > 0
|
||
AND sg.price_avg IS NOT NULL AND sg.price_avg > 0
|
||
AND sg.report_month >= NOW() - INTERVAL '36 months'
|
||
)
|
||
SELECT
|
||
regr_slope(y, x) AS slope,
|
||
regr_r2(y, x) AS r2,
|
||
COUNT(*) AS n
|
||
FROM pts
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
n = int(row["n"]) if row and row["n"] is not None else 0
|
||
slope = _f(row["slope"]) if row else None
|
||
r2 = _f(row["r2"]) if row else None
|
||
if n >= 30 and slope is not None and r2 is not None and r2 >= 0.1 and slope < 0:
|
||
return {
|
||
"elasticity": round(slope, 4),
|
||
"r2": round(r2, 4),
|
||
"n": n,
|
||
"source": "regression",
|
||
}
|
||
return {
|
||
"elasticity": FALLBACK_ELASTICITY,
|
||
"r2": r2 or 0.0,
|
||
"n": n,
|
||
"source": "fallback",
|
||
}
|
||
|
||
|
||
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,
|
||
price_factor: float = 1.0,
|
||
target_months: int | None = None,
|
||
) -> 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 через yandex_realty_zk + Comfort как BASELINE (×1.0).
|
||
# Раньше делили class_avg/overall_avg где overall = смесь по 12 rows
|
||
# → числа абсурдные (Elite ×1.22, Comfort+ ×0.66 < Comfort).
|
||
# Теперь: ratio(class) = class_price_avg / comfort_price_avg.
|
||
# Реалистичные индустриальные значения: Comfort=1.0, Comfort+=1.02,
|
||
# Business=1.86, Elite=4.27 (на основе текущих 12 rows yandex_realty_zk).
|
||
# yandex_realty_class_prices игнорируем — midpoint бессмыслен (ширина
|
||
# диапазонов класса искажает result).
|
||
# UI шлёт 'Comfort'/'Comfort+'/'Business'/'Elite' → realty_zk: 'COMFORT'/
|
||
# 'COMFORT_PLUS'/'BUSINESS'/'ELITE'.
|
||
class_multiplier = 1.0
|
||
class_multiplier_source: str | None = None
|
||
if target_class:
|
||
zk_norm = {
|
||
"Comfort": "COMFORT",
|
||
"Comfort+": "COMFORT_PLUS",
|
||
"Business": "BUSINESS",
|
||
"Elite": "ELITE",
|
||
}.get(target_class)
|
||
if zk_norm:
|
||
r = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg,
|
||
AVG(price_from) FILTER (WHERE obj_class = 'COMFORT') AS comfort_avg
|
||
FROM yandex_realty_zk
|
||
WHERE price_from IS NOT NULL AND price_from > 0
|
||
"""
|
||
),
|
||
{"cls": zk_norm},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
cavg = _f(r["class_avg"]) if r else None
|
||
comfort_avg = _f(r["comfort_avg"]) if r else None
|
||
if cavg and comfort_avg and comfort_avg > 0:
|
||
class_multiplier = cavg / comfort_avg
|
||
class_multiplier_source = "realty_zk_vs_comfort"
|
||
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
|
||
|
||
# 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
|
||
# Both are required for the live "цена↔темп" calculator on the frontend.
|
||
# Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный
|
||
# баг скрейпера). Если в районе нет ни одного НЕ-NULL obj_class —
|
||
# игнорируем target_class фильтр на уровне velocity/elasticity/comparable
|
||
# запросов, иначе obj_pool пустой и всё падает в fallback.
|
||
has_class_data = bool(
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT 1 FROM domrf_kn_objects
|
||
WHERE region_cd = :rc
|
||
AND district_name = :dn
|
||
AND obj_class IS NOT NULL
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"rc": region_code, "dn": district_row["district_name"]},
|
||
).scalar()
|
||
)
|
||
target_class_for_geo = target_class if has_class_data else None
|
||
if target_class and not has_class_data:
|
||
warnings.append(
|
||
f"obj_class не заполнен для ЖК района {district_row['district_name']}"
|
||
f" — фильтр по классу '{target_class}' игнорируется в velocity/comparable"
|
||
" (но class_multiplier из yandex_realty_zk применяется к ценам)."
|
||
)
|
||
vel = _velocity_baseline(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
)
|
||
market_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
|
||
if market_vel_pm is None:
|
||
# Fallback: derive from city-wide rosreestr deals (distribute per bucket
|
||
# by share). Coarser, but lets the calculator work anywhere.
|
||
warnings.append(
|
||
"Нет sale_graph данных для этого района и класса —"
|
||
" темп считается по rosreestr-сделкам (грубее)."
|
||
)
|
||
market_vel_pm = (total_deals / max(effective_window, 1)) if total_deals else 0.0
|
||
velocity_source = "rosreestr_fallback"
|
||
else:
|
||
velocity_source = "sale_graph"
|
||
|
||
elast = _elasticity_coef(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
)
|
||
elasticity = elast["elasticity"]
|
||
if elast["source"] == "fallback":
|
||
warnings.append(
|
||
f"Эластичность цена↔темп взята по умолчанию ({elasticity})"
|
||
f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}"
|
||
" (недостаточно для регрессии)."
|
||
)
|
||
|
||
# 5b-1) N активных конкурентов с каскадным fallback (район+класс →
|
||
# район → регион). Используется для нормировки рыночной velocity.
|
||
competitors, competitors_scope = _active_competitors_count(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
)
|
||
if competitors_scope == "fallback_singleton":
|
||
warnings.append(
|
||
f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
|
||
f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
|
||
)
|
||
elif competitors_scope != "district+class":
|
||
# Информативное сообщение о расширении scope при недостатке локальных данных.
|
||
scope_label = {
|
||
"district": f"районе {district_row['district_name']} (без класса)",
|
||
"region": f"регионе {region_code} (вне района)",
|
||
}.get(competitors_scope, competitors_scope)
|
||
warnings.append(
|
||
f"Конкурентов класса '{target_class or '*'}' в районе мало —"
|
||
f" нормировка по {competitors} ЖК в {scope_label}."
|
||
)
|
||
|
||
# 5b-2) Per-bucket market velocity (сделок/мес для каждого размерного
|
||
# сегмента из rosreestr — НЕ city-wide, а РЕАЛЬНАЯ интенсивность сегмента).
|
||
# Студии/1к — обычно выше, 80+ — ниже.
|
||
bucket_market_velocities = {
|
||
_BUCKET_PRETTY.get(r["bucket"], r["bucket"]): (
|
||
int(r["deals"] or 0) / max(effective_window, 1)
|
||
)
|
||
for r in bucket_rows
|
||
}
|
||
|
||
# 5b-2.5) Дополнительные district-specific signals (Tier 2):
|
||
# sat_factor — насколько зрелый рынок района (median sold% активных
|
||
# ЖК). >50% = зрелый, новый проект имеет место, +bonus.
|
||
# <20% = свежий, много инвентаря, -penalty.
|
||
# trend_factor — recent_6mo / prior_6mo realised. Clamp 0.7..2.0 чтобы
|
||
# экстремум не разрушал расчёты.
|
||
# poi_factor — weighted POI density района / city avg. ±5% на цены.
|
||
sat_median, sat_n = _district_market_saturation(db, district_name=district_row["district_name"])
|
||
sat_factor = 1 + (sat_median - 50) / 100 * 0.3 if sat_median is not None else 1.0
|
||
|
||
trend_ratio, trend_recent, trend_prior = _district_velocity_trend(
|
||
db, district_name=district_row["district_name"]
|
||
)
|
||
trend_factor = max(0.7, min(2.0, trend_ratio)) if trend_ratio else 1.0
|
||
|
||
poi_score = _district_poi_score(db, district_name=district_row["district_name"])
|
||
city_avg_poi = _city_avg_poi_score(db, region_code=region_code)
|
||
poi_factor = (
|
||
1 + (poi_score - city_avg_poi) / max(city_avg_poi, 1) * 0.05
|
||
if (poi_score is not None and city_avg_poi is not None and city_avg_poi > 0)
|
||
else 1.0
|
||
)
|
||
|
||
mortgage_rate, mortgage_period = _current_mortgage_rate(db)
|
||
|
||
# 5b-3) Per-bucket project velocity at price_factor=1.0:
|
||
# bucket_market_v = темп РЫНКА для bucket'а (deals/mo по всему региону)
|
||
# normalisation = sqrt(N_competitors) — power-law эффективные
|
||
# конкуренты (sqrt компромисс между ÷1 и ÷N).
|
||
# project_velocity = bucket_market_v / sqrt(N) × sat_factor × trend_factor
|
||
# sat — зрелый рынок ускоряет; trend — текущая
|
||
# динамика (горит/остывает).
|
||
# adjusted = project_velocity × price_factor^elasticity
|
||
# months_to_sellout = units_planned / adjusted
|
||
# Цена тоже корректируется на poi_factor (развитость района = премиум).
|
||
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
|
||
competitors_norm = math.sqrt(max(competitors, 1))
|
||
macro_velocity_mult = sat_factor * trend_factor
|
||
total_units = 0
|
||
for b in buckets:
|
||
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
|
||
bucket_velocity = round(bucket_market_v / competitors_norm * macro_velocity_mult, 4)
|
||
b["velocity_per_month"] = bucket_velocity
|
||
# POI-корректировка на цену (на ВСЕ p25/median/p75)
|
||
b["price_median_per_m2"] = round(b["price_median_per_m2"] * poi_factor, 2)
|
||
b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * poi_factor, 2)
|
||
b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * poi_factor, 2)
|
||
if b["units_planned"] and bucket_velocity > 0:
|
||
# Revenue тоже пересчитываем после POI-correction (linear scale).
|
||
if b["revenue_planned_rub"] is not None:
|
||
b["revenue_planned_rub"] = round(b["revenue_planned_rub"] * poi_factor, 2)
|
||
adjusted_velocity = bucket_velocity * pf_pow
|
||
b["months_to_sellout"] = (
|
||
round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None
|
||
)
|
||
total_units += b["units_planned"]
|
||
else:
|
||
b["months_to_sellout"] = None
|
||
# Итог revenue + weighted_avg_price после POI-correction (linear scale).
|
||
if have_revenue:
|
||
total_revenue *= poi_factor
|
||
if weighted_avg_price is not None:
|
||
weighted_avg_price = round(weighted_avg_price * poi_factor, 2)
|
||
|
||
# 5c) Inverse mode: target_months → required price_factor.
|
||
# required_velocity = total_units / target_months
|
||
# base_velocity_total = sum(bucket_velocity) (at price_factor=1)
|
||
# required_pf^elasticity = required_velocity / base_velocity_total
|
||
# → required_pf = (required_velocity / base_velocity_total)^(1/elasticity)
|
||
required_price_factor: float | None = None
|
||
if target_months and total_units > 0:
|
||
base_total_velocity = sum(b["velocity_per_month"] or 0 for b in buckets)
|
||
if base_total_velocity > 0 and elasticity != 0:
|
||
required_velocity = total_units / target_months
|
||
ratio = required_velocity / base_total_velocity
|
||
try:
|
||
required_price_factor = round(ratio ** (1.0 / elasticity), 4)
|
||
except Exception:
|
||
required_price_factor = None
|
||
if required_price_factor and required_price_factor < 0.7:
|
||
warnings.append(
|
||
f"Целевой срок {target_months} мес требует скидки"
|
||
f" >{round((1 - required_price_factor) * 100)}% — рассмотри"
|
||
" сдвиг ассортимента в сторону ликвидных бакетов."
|
||
)
|
||
|
||
# 5d) Liquidity score (0-100): % units sold within 24 months.
|
||
liquidity_24mo: float | None = None
|
||
if total_units > 0:
|
||
sold_24mo = 0.0
|
||
for b in buckets:
|
||
mts = b["months_to_sellout"]
|
||
up = b["units_planned"] or 0
|
||
if up <= 0 or mts is None or mts <= 0:
|
||
continue
|
||
frac = min(1.0, 24.0 / mts)
|
||
sold_24mo += frac * up
|
||
liquidity_24mo = round(sold_24mo / total_units * 100, 1)
|
||
|
||
# 5e) Aggregate KPIs
|
||
months_to_sellout_total: float | None = None
|
||
base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
|
||
if total_units > 0 and base_total_v > 0:
|
||
months_to_sellout_total = round(total_units / (base_total_v * pf_pow), 1)
|
||
avg_ticket = (
|
||
round(total_revenue / total_units, 2) if (have_revenue and total_units > 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.district_name = :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_for_geo,
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# 7) Headline для CEO — одна строка с тремя главными цифрами
|
||
headline_parts: list[str] = []
|
||
if have_revenue:
|
||
headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
|
||
if months_to_sellout_total:
|
||
headline_parts.append(f"за ~{months_to_sellout_total:.1f} мес")
|
||
if avg_ticket:
|
||
headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽")
|
||
if base_total_v > 0:
|
||
# Малая velocity — формат с 2 десятыми (0.07 кв/мес для ЖК-доли).
|
||
tempo = base_total_v * pf_pow
|
||
headline_parts.append(
|
||
f"темп {tempo:.2f} кв/мес" if tempo < 1 else f"темп {tempo:.1f} кв/мес"
|
||
)
|
||
if liquidity_24mo is not None:
|
||
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||
headline = " · ".join(headline_parts) if headline_parts else None
|
||
|
||
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),
|
||
"class_multiplier_source": class_multiplier_source,
|
||
"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,
|
||
"market_velocity_per_month": (
|
||
round(market_vel_pm, 3) if market_vel_pm is not None else None
|
||
),
|
||
"velocity_source": velocity_source,
|
||
"velocity_observations": vel["observations"],
|
||
"velocity_objects": vel["objects_count"],
|
||
"competitors_count": competitors,
|
||
"competitors_scope": competitors_scope,
|
||
"saturation_median": sat_median,
|
||
"saturation_n": sat_n,
|
||
"sat_factor": round(sat_factor, 4),
|
||
"velocity_trend_ratio": (round(trend_ratio, 2) if trend_ratio is not None else None),
|
||
"trend_recent_units": trend_recent,
|
||
"trend_prior_units": trend_prior,
|
||
"trend_factor": round(trend_factor, 4),
|
||
"poi_score": round(poi_score, 1) if poi_score is not None else None,
|
||
"poi_score_city_avg": (round(city_avg_poi, 1) if city_avg_poi is not None else None),
|
||
"poi_factor": round(poi_factor, 4),
|
||
"mortgage_rate_pct": mortgage_rate,
|
||
"mortgage_rate_period": mortgage_period,
|
||
"elasticity": elasticity,
|
||
"elasticity_r2": elast["r2"],
|
||
"elasticity_n": elast["n"],
|
||
"elasticity_source": elast["source"],
|
||
"price_factor_applied": round(price_factor, 4),
|
||
"required_price_factor": required_price_factor,
|
||
"target_months": target_months,
|
||
"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,
|
||
"total_units_planned": total_units if total_units > 0 else None,
|
||
"months_to_sellout_total": months_to_sellout_total,
|
||
"avg_ticket_rub": avg_ticket,
|
||
"liquidity_score_24mo": liquidity_24mo,
|
||
"headline": headline,
|
||
"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
|
||
],
|
||
}
|