Some checks failed
CI / changes (push) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (push) Successful in 1m1s
CI / openapi-codegen-check (push) Successful in 1m50s
CI / backend-tests (push) Failing after 9m8s
CI / frontend-tests (pull_request) Successful in 54s
CI / openapi-codegen-check (pull_request) Successful in 1m59s
CI / backend-tests (pull_request) Failing after 8m56s
Switch portfolio source from domrf_region_aggregates (room_count_type ONE/TWO/THREE/FOUR) to domrf_flat_area_distribution (area_bucket FROM_0_TO_25…FROM_100), aggregated into the same 5 area buckets as the deals series. Both series now share the same axis and are directly comparable. Frontend portfolioMap simplified to direct bucket key lookup.
3224 lines
138 KiB
Python
3224 lines
138 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 logging
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _f(value: Any) -> float | None:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, Decimal):
|
||
return float(value)
|
||
return value
|
||
|
||
|
||
# UI шлёт английские классы (ClassLiteral = "Comfort"/"Comfort+"/"Business"/
|
||
# "Elite"), а domrf_kn_objects.obj_class и objective_corpus_room_month.class
|
||
# хранят русские названия из скрейпера (domrf_kn.py: "Элит"/"Бизнес"/"Комфорт"/
|
||
# "Стандарт"/"Типовой"). Без перевода точный матч obj_class = :cls в comparables/
|
||
# competitors/velocity/elasticity молча даёт ноль строк ("Comfort" != "Комфорт").
|
||
_CLASS_EN_TO_RU = {
|
||
"Comfort": "Комфорт",
|
||
"Comfort+": "Комфорт",
|
||
"Business": "Бизнес",
|
||
"Elite": "Элит",
|
||
}
|
||
|
||
|
||
def _class_to_db_vocab(target_class: str | None) -> str | None:
|
||
"""Перевод английского класса из UI в русский словарь БД (obj_class /
|
||
objective class). Неизвестные значения возвращаем как есть (уже могут быть
|
||
русскими, либо новый класс — пусть фильтр решает естественно)."""
|
||
if target_class is None:
|
||
return None
|
||
return _CLASS_EN_TO_RU.get(target_class, target_class)
|
||
|
||
|
||
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' (реально покупают).
|
||
|
||
Оба источника возвращают одни и те же 5 area-бакетов, выровненных с осью
|
||
QuartirographyChart ("Студии 15-30" / "1-к 30-45" / "2-к 45-60" /
|
||
"3-к 60-80" / "80+ м²").
|
||
|
||
Портфель: domrf_flat_area_distribution (RF-wide snapshot, region_id=0 т.к.
|
||
API DOM.РФ игнорирует ?regionId на этом эндпоинте). Бакеты DOM.РФ (FROM_0_TO_25
|
||
… FROM_100) маппируются на chart-бакеты по средней площади:
|
||
FROM_0_TO_25 (~22 м²) → "Студии 15-30"
|
||
FROM_25_TO_35 (~31 м², мелкие однушки) → "1-к 30-45"
|
||
FROM_35_TO_45 (~40 м²) → "1-к 30-45"
|
||
FROM_45_TO_55 (~50 м²) → "2-к 45-60"
|
||
FROM_55_TO_70 (~62 м²) → "3-к 60-80"
|
||
FROM_70_TO_85 (~77 м²) → "3-к 60-80"
|
||
FROM_85_TO_100 (~92 м²) → "80+ м²"
|
||
FROM_100 (100+ м²) → "80+ м²"
|
||
"""
|
||
if source == "portfolio":
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH latest AS (
|
||
SELECT MAX(snapshot_date) AS snap
|
||
FROM domrf_flat_area_distribution
|
||
WHERE region_id = 0
|
||
),
|
||
mapped AS (
|
||
SELECT
|
||
CASE area_bucket
|
||
WHEN 'FROM_0_TO_25' THEN '1-Студия'
|
||
WHEN 'FROM_25_TO_35' THEN '2-1-к'
|
||
WHEN 'FROM_35_TO_45' THEN '2-1-к'
|
||
WHEN 'FROM_45_TO_55' THEN '3-2-к'
|
||
WHEN 'FROM_55_TO_70' THEN '4-3-к'
|
||
WHEN 'FROM_70_TO_85' THEN '4-3-к'
|
||
WHEN 'FROM_85_TO_100' THEN '5-80+ м²'
|
||
WHEN 'FROM_100' THEN '5-80+ м²'
|
||
END AS chart_bucket,
|
||
flat_count,
|
||
area_sqm
|
||
FROM domrf_flat_area_distribution
|
||
CROSS JOIN latest
|
||
WHERE region_id = 0
|
||
AND room_count_type = 'TOTAL'
|
||
AND snapshot_date = latest.snap
|
||
)
|
||
SELECT chart_bucket,
|
||
SUM(flat_count)::bigint AS flat_count,
|
||
SUM(area_sqm) AS area_sqm
|
||
FROM mapped
|
||
WHERE chart_bucket IS NOT NULL
|
||
GROUP BY chart_bucket
|
||
ORDER BY chart_bucket
|
||
"""
|
||
),
|
||
{},
|
||
)
|
||
.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_flats = sum(r["flat_count"] or 0 for r in rows) or 1
|
||
return [
|
||
{
|
||
"bucket": _pretty[r["chart_bucket"]],
|
||
"flat_count": int(r["flat_count"] or 0),
|
||
"area_sqm": _f(r["area_sqm"]),
|
||
"percent": round((r["flat_count"] or 0) * 100 / total_flats, 1),
|
||
"avg_area": (_f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
# deals: bucketize по area_per_unit = area / deal_count (rosreestr
|
||
# с 2025Q1 публикует пакетные ДДУ одной строкой с суммарной area).
|
||
# deal_count — это число квартир в строке; bucket по сырой area без
|
||
# деления попадал в 80+ м² для большинства аггрегаций → перекошенный
|
||
# «парадокс портфеля» (70% 80+ вместо реальных 5%).
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH per_unit AS (
|
||
SELECT (area / deal_count) AS area_per_unit,
|
||
price_per_sqm,
|
||
deal_count
|
||
FROM rosreestr_deals
|
||
WHERE region_code = :region_id
|
||
AND doc_type = 'ДДУ'
|
||
AND realestate_type_code = '002001003000'
|
||
AND area > 0 AND deal_count > 0
|
||
AND (area / deal_count) BETWEEN 15 AND 200
|
||
AND price_per_sqm > 0
|
||
-- #1384: скользящее окно вместо захардкоженной даты-пола
|
||
-- ('2025-07-01' расширял «recent»-окно каждую неделю по мере
|
||
-- доливки ETL новых report_months → перекос в сторону всё
|
||
-- более длинной истории). Тот же фикс, что #1203 и _BUCKET_SQL.
|
||
AND period_start_date >= NOW()
|
||
- (:months_window || ' months')::INTERVAL
|
||
),
|
||
bucketed AS (
|
||
SELECT CASE
|
||
WHEN area_per_unit < 30 THEN '1-Студия'
|
||
WHEN area_per_unit < 45 THEN '2-1-к'
|
||
WHEN area_per_unit < 60 THEN '3-2-к'
|
||
WHEN area_per_unit < 80 THEN '4-3-к'
|
||
ELSE '5-80+ м²'
|
||
END AS bucket,
|
||
price_per_sqm,
|
||
deal_count
|
||
FROM per_unit
|
||
)
|
||
SELECT bucket,
|
||
SUM(deal_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
|
||
"""
|
||
),
|
||
# 12 мес — размер окна, эквивалентный исходному '2025-07-01' на момент
|
||
# написания (см. #1384); теперь окно скользит и не растёт со временем.
|
||
{"region_id": region_id, "months_window": 12},
|
||
)
|
||
.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 d.district_name, d.zk_count, d.flat_count, d.area_m2,
|
||
d.median_price_per_m2, d.mean_price_per_m2,
|
||
COALESCE(cq.cad_quarter_count, 0) AS cad_quarter_count
|
||
FROM ekb_districts d
|
||
LEFT JOIN (
|
||
SELECT district_name,
|
||
COUNT(*) FILTER (WHERE cad_quarter IS NOT NULL) AS cad_quarter_count
|
||
FROM v_complex_full
|
||
WHERE district_name IS NOT NULL
|
||
GROUP BY district_name
|
||
) cq ON cq.district_name = d.district_name
|
||
WHERE d.district_name <> 'не определён'
|
||
ORDER BY d.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"]),
|
||
"cad_quarter_count": int(r["cad_quarter_count"]),
|
||
}
|
||
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,
|
||
(SELECT COUNT(*) FROM complexes c
|
||
WHERE c.developer_id = m.developer_id) AS complexes_count
|
||
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"]),
|
||
"complexes_count": int(r["complexes_count"] or 0),
|
||
}
|
||
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).
|
||
|
||
Также возвращает buildings_count из v_complex_buildings (0 если зданий нет).
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
|
||
o.dev_id, o.dev_name, o.floor_min, o.floor_max, o.flat_count,
|
||
o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
|
||
o.latitude, o.longitude, o.obj_status, o.snapshot_date,
|
||
COALESCE(cb.buildings_count, 0) AS buildings_count
|
||
FROM domrf_kn_objects o
|
||
LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
|
||
WHERE o.obj_id = :obj
|
||
ORDER BY o.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,
|
||
"buildings_count": int(row["buildings_count"]),
|
||
}
|
||
|
||
|
||
def object_sale_graph(
|
||
db: Session, obj_id: int, type_filter: str | None = None
|
||
) -> list[dict[str, Any]]:
|
||
"""Time-series продаж per-ЖК. Latest snapshot.
|
||
|
||
NOTE: намеренно оставлен на domrf_kn_sale_graph — это внутренний
|
||
per-object detail view для PRINZIP-аналитики (/api/v1/analytics/object/*).
|
||
Миграция на objective_corpus_room_month требует отдельного pr: там другая
|
||
гранулярность (corpus × room_bucket), а не obj_id.
|
||
Данные stale (newest 2026-01) — приемлемо для исторического графика.
|
||
"""
|
||
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 object_full_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
|
||
"""Extended object detail — adds all Wave A+B columns (22begh).
|
||
|
||
Returns the same base fields as object_detail() PLUS the 30 new columns
|
||
from 113_22begh_kn_schema_extension.sql. Falls back gracefully: columns
|
||
that haven't been scraped yet return NULL.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
|
||
o.dev_id, o.dev_name, o.dev_group_name,
|
||
o.floor_min, o.floor_max, o.flat_count,
|
||
o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
|
||
o.latitude, o.longitude, o.obj_status, o.snapshot_date,
|
||
o.energy_eff, o.wall_type,
|
||
-- Building specs (22e)
|
||
o.first_floor_type, o.section_count,
|
||
o.elevators_passenger_count, o.elevators_cargo_count,
|
||
o.parking_total_slots, o.guest_parking_inside_count,
|
||
o.guest_parking_outside_count, o.ceiling_height_m,
|
||
-- Apartment summary (22e)
|
||
o.finishing_variants_count, o.has_free_planning, o.avg_flat_area_m2,
|
||
-- Yard (22e)
|
||
o.playground_kids_count, o.playground_sport_count,
|
||
o.has_bike_paths, o.trash_areas_count,
|
||
-- OVZ (22e)
|
||
o.has_ramp, o.has_low_platforms, o.has_wheelchair_lift,
|
||
-- Catalog/UI (22e)
|
||
o.flat_area_min, o.flat_area_max,
|
||
o.price_min_rub, o.price_max_rub,
|
||
o.price_per_m2_min, o.price_per_m2_max,
|
||
o.parking_provision_pct, o.project_published_at,
|
||
o.project_declaration_num,
|
||
-- Metro & scores (22e/22h)
|
||
o.metro_nearest_name, o.metro_nearest_walk_minutes, o.metro_top3,
|
||
o.domrf_score_location, o.domrf_score_transport,
|
||
o.domrf_score_infrastructure,
|
||
COALESCE(cb.buildings_count, 0) AS buildings_count
|
||
FROM domrf_kn_objects o
|
||
LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
|
||
WHERE o.obj_id = :obj
|
||
ORDER BY o.snapshot_date DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"obj": obj_id},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return None
|
||
|
||
metro_top3 = row["metro_top3"]
|
||
|
||
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"],
|
||
"dev_group_name": row["dev_group_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,
|
||
"energy_eff": row["energy_eff"],
|
||
"wall_type": row["wall_type"],
|
||
# Building specs
|
||
"first_floor_type": row["first_floor_type"],
|
||
"section_count": row["section_count"],
|
||
"elevators_passenger_count": row["elevators_passenger_count"],
|
||
"elevators_cargo_count": row["elevators_cargo_count"],
|
||
"parking_total_slots": row["parking_total_slots"],
|
||
"guest_parking_inside_count": row["guest_parking_inside_count"],
|
||
"guest_parking_outside_count": row["guest_parking_outside_count"],
|
||
"ceiling_height_m": _f(row["ceiling_height_m"]),
|
||
# Apartment summary
|
||
"finishing_variants_count": row["finishing_variants_count"],
|
||
"has_free_planning": row["has_free_planning"],
|
||
"avg_flat_area_m2": _f(row["avg_flat_area_m2"]),
|
||
# Yard
|
||
"playground_kids_count": row["playground_kids_count"],
|
||
"playground_sport_count": row["playground_sport_count"],
|
||
"has_bike_paths": row["has_bike_paths"],
|
||
"trash_areas_count": row["trash_areas_count"],
|
||
# OVZ
|
||
"has_ramp": row["has_ramp"],
|
||
"has_low_platforms": row["has_low_platforms"],
|
||
"has_wheelchair_lift": row["has_wheelchair_lift"],
|
||
# Catalog/UI
|
||
"flat_area_min": _f(row["flat_area_min"]),
|
||
"flat_area_max": _f(row["flat_area_max"]),
|
||
"price_min_rub": row["price_min_rub"],
|
||
"price_max_rub": row["price_max_rub"],
|
||
"price_per_m2_min": _f(row["price_per_m2_min"]),
|
||
"price_per_m2_max": _f(row["price_per_m2_max"]),
|
||
"parking_provision_pct": _f(row["parking_provision_pct"]),
|
||
"project_published_at": (
|
||
row["project_published_at"].isoformat() if row["project_published_at"] else None
|
||
),
|
||
"project_declaration_num": row["project_declaration_num"],
|
||
# Metro & scores
|
||
"metro_nearest_name": row["metro_nearest_name"],
|
||
"metro_nearest_walk_minutes": row["metro_nearest_walk_minutes"],
|
||
"metro_top3": metro_top3, # already jsonb → dict/list from psycopg3
|
||
"domrf_score_location": row["domrf_score_location"],
|
||
"domrf_score_transport": row["domrf_score_transport"],
|
||
"domrf_score_infrastructure": row["domrf_score_infrastructure"],
|
||
"buildings_count": int(row["buildings_count"]),
|
||
}
|
||
|
||
|
||
def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||
"""Per-rooms aggregation из domrf_kn_flats для объекта.
|
||
|
||
Группирует по rooms: 1/2/3/Нежилые (rooms IS NULL).
|
||
Возвращает count total, count 'free', min/max area, min/max price.
|
||
"""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH latest AS (
|
||
SELECT MAX(snapshot_date) AS snap
|
||
FROM domrf_kn_flats
|
||
WHERE obj_id = :obj
|
||
)
|
||
SELECT
|
||
CASE
|
||
WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%'
|
||
OR LOWER(f.flat_type) LIKE '%nonliv%' THEN 'Нежилые'
|
||
WHEN f.rooms = 0 THEN 'Студия'
|
||
WHEN f.rooms = 1 THEN '1-комн.'
|
||
WHEN f.rooms = 2 THEN '2-комн.'
|
||
WHEN f.rooms = 3 THEN '3-комн.'
|
||
ELSE (f.rooms::text || '-комн.')
|
||
END AS room_label,
|
||
-- #1358: sort_key должен совпадать с логическим room_label, а
|
||
-- не с сырым rooms. Нежилой юнит с flat_type LIKE '%нежил%' но
|
||
-- ненулевым rooms (напр. коммерция rooms=2) иначе уходил в
|
||
-- отдельную группу sort_key=2, дробя бакет 'Нежилые' на дубли.
|
||
CASE
|
||
WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%'
|
||
OR LOWER(f.flat_type) LIKE '%nonliv%' THEN -1
|
||
ELSE f.rooms
|
||
END AS sort_key,
|
||
COUNT(*) AS total_count,
|
||
COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
|
||
OR LOWER(f.status) LIKE '%свобод%')
|
||
AS free_count,
|
||
MIN(f.total_area) AS area_min,
|
||
MAX(f.total_area) AS area_max,
|
||
MIN(f.price_rub) FILTER (WHERE f.price_rub > 0)
|
||
AS price_min,
|
||
MAX(f.price_rub) FILTER (WHERE f.price_rub > 0)
|
||
AS price_max
|
||
FROM domrf_kn_flats f
|
||
CROSS JOIN latest l
|
||
WHERE f.obj_id = :obj
|
||
AND f.snapshot_date = l.snap
|
||
GROUP BY room_label, sort_key
|
||
ORDER BY sort_key
|
||
"""
|
||
),
|
||
{"obj": obj_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"room_label": r["room_label"],
|
||
"total_count": r["total_count"],
|
||
"free_count": r["free_count"],
|
||
"area_min": _f(r["area_min"]),
|
||
"area_max": _f(r["area_max"]),
|
||
"price_min": r["price_min"],
|
||
"price_max": r["price_max"],
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def object_obj_checks(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||
"""6 «Проверено на наш.дом.рф» checks из domrf_obj_checks (22f)."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT check_type, passed, checked_at
|
||
FROM domrf_obj_checks
|
||
WHERE obj_id = :obj
|
||
ORDER BY check_type
|
||
"""
|
||
),
|
||
{"obj": obj_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"check_type": r["check_type"],
|
||
"passed": r["passed"],
|
||
"checked_at": r["checked_at"].isoformat() if r["checked_at"] else None,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def object_documents(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||
"""PDF documents из domrf_kn_documents (22i), сортировка по doc_type + posted_at."""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT doc_type, doc_num, posted_at, file_url, size_bytes
|
||
FROM domrf_kn_documents
|
||
WHERE obj_id = :obj
|
||
ORDER BY doc_type, posted_at DESC NULLS LAST
|
||
"""
|
||
),
|
||
{"obj": obj_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"doc_type": r["doc_type"],
|
||
"doc_num": r["doc_num"],
|
||
"posted_at": r["posted_at"].isoformat() if r["posted_at"] else None,
|
||
"file_url": r["file_url"],
|
||
"size_bytes": r["size_bytes"],
|
||
}
|
||
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.
|
||
|
||
NOTE: sparkline (velocity CTE) намеренно оставлен на domrf_kn_sale_graph.
|
||
objective_corpus_room_month не имеет obj_id — требует JOIN через
|
||
objective_complex_mapping по project_name. Это отдельная задача рефакторинга
|
||
admin-view. Данные stale (newest 2026-01), sparkline визуально OK для тренда.
|
||
"""
|
||
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(
|
||
"""
|
||
-- ВАЖНО: rosreestr агрегирует пакетные ДДУ-сделки в одну строку.
|
||
-- Например, 5 квартир по 40 м² одного покупателя → row с
|
||
-- area=200, deal_count=5. Если bucket'ить по сырой area, такая
|
||
-- запись попадает в «80+ м²» хотя реально это 5 квартир «1-к».
|
||
-- Поэтому:
|
||
-- * area_per_unit = area / deal_count (площадь одной квартиры)
|
||
-- * COUNT через SUM(deal_count) — реальное число единиц жилья
|
||
-- * Медианы взвешиваем по deal_count (PERCENTILE_DISC по разворачиванию
|
||
-- не PostgreSQL-friendly; используем PERCENTILE_CONT — приближение,
|
||
-- для редких outliers с deal_count >>1 расхождение <2%)
|
||
WITH per_unit AS (
|
||
SELECT (area / deal_count) AS area_per_unit,
|
||
price_per_sqm,
|
||
deal_count
|
||
FROM rosreestr_deals
|
||
WHERE region_code = :rc
|
||
AND doc_type = 'ДДУ'
|
||
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
|
||
-- 001 = земельные участки, 002 = нежилые помещения.
|
||
AND realestate_type_code = '002001003000'
|
||
AND area > 10
|
||
-- ВНИМАНИЕ: с 2025Q1 rosreestr резко увеличил агрегацию строк
|
||
-- (1 row = 30+ сделок, area = SUM по пакету). Фильтр по сырой
|
||
-- area отрезает 95% свежих данных. Используем только per-unit
|
||
-- фильтр (15..200 м² — реалистичный диапазон одной квартиры).
|
||
AND deal_count > 0
|
||
AND (area / deal_count) BETWEEN 15 AND 200
|
||
AND price_per_sqm BETWEEN 30000 AND 1000000
|
||
AND period_start_date >= NOW()
|
||
- (:months_window || ' months')::INTERVAL
|
||
),
|
||
bucketed AS (
|
||
SELECT CASE
|
||
WHEN area_per_unit < 30 THEN '1-Студия'
|
||
WHEN area_per_unit < 45 THEN '2-1-к'
|
||
WHEN area_per_unit < 60 THEN '3-2-к'
|
||
WHEN area_per_unit < 80 THEN '4-3-к'
|
||
ELSE '5-80+ м²'
|
||
END AS bucket,
|
||
area_per_unit,
|
||
price_per_sqm,
|
||
deal_count
|
||
FROM per_unit
|
||
)
|
||
SELECT bucket,
|
||
SUM(deal_count)::bigint AS deals,
|
||
SUM(area_per_unit * deal_count) / SUM(deal_count) AS area_avg,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area_per_unit) 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
|
||
objective_corpus_room_month for objects in the same район+class over last 24 mo.
|
||
|
||
Migrated from domrf_kn_sale_graph (stale since 2026-01) to
|
||
objective_corpus_room_month (updated weekly via Objective API).
|
||
objective_corpus_room_month.district matches domrf_kn_objects.district_name.
|
||
class filter uses 'class' column (Комфорт/Бизнес/Стандарт).
|
||
|
||
Returns dict {realised_per_month_median, realised_per_month_avg,
|
||
objects_count, observations}. All-None means no data → caller falls back.
|
||
"""
|
||
# Objective class naming: "Комфорт", "Бизнес", "Стандарт" — capitalised.
|
||
# domrf obj_class may differ in case; apply ILIKE for robustness.
|
||
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
|
||
params: dict[str, Any] = {"dn": district_name}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
# region_code not used — objective_corpus_room_month covers only EKB (region 66).
|
||
# district filter is sufficient for locality. If district returns no rows,
|
||
# caller falls back to rosreestr_fallback path (unchanged behaviour).
|
||
_ = region_code # retained in signature for backward compat
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH per_project_month AS (
|
||
SELECT project_name,
|
||
report_month,
|
||
SUM(deals_total_count) AS month_units
|
||
FROM objective_corpus_room_month crm
|
||
WHERE crm.district = :dn
|
||
{where_class}
|
||
AND crm.deals_total_count > 0
|
||
AND crm.report_month >= NOW() - INTERVAL '24 months'
|
||
GROUP BY project_name, report_month
|
||
)
|
||
SELECT
|
||
AVG(month_units) AS avg_pm,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm,
|
||
COUNT(DISTINCT project_name) AS objects,
|
||
COUNT(*) AS observations
|
||
FROM per_project_month
|
||
"""
|
||
),
|
||
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.
|
||
"""
|
||
# #1221: domrf_kn_objects/domrf_kn_sales_agg хранят ~3 snapshot'а на obj_id
|
||
# (UNIQUE(obj_id,[type,]snapshot_date), weekly UPSERT, ретенции нет). Без
|
||
# latest-фильтра один ЖК входил в медиану N раз → sold_median тянется старыми
|
||
# perc (sold% растёт во времени → sat_factor занижен), гейт n>=5 проходил уже
|
||
# при 2 ЖК, sat_n завышен ~×N. Фильтруем оба JOIN-партнёра MAX(snapshot_date)
|
||
# (зеркало #1210/#1212, ср. строки 1200/1208 и latest_obj-CTE стр. ~2780
|
||
# «иначе comparables дублируются»). COUNT(DISTINCT a.obj_id) — defensive:
|
||
# после latest-snapshot пары (obj_id, type='apartments') уникальны, но явный
|
||
# DISTINCT исключает регрессию, если кто-то ослабит UNIQUE-constraint.
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median,
|
||
COUNT(DISTINCT a.obj_id) 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 = 'Строящиеся'
|
||
AND a.snapshot_date = (
|
||
SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg
|
||
)
|
||
AND o.snapshot_date = (
|
||
SELECT MAX(snapshot_date) FROM domrf_kn_objects
|
||
)
|
||
"""
|
||
),
|
||
{"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+.
|
||
|
||
Мигрировано с domrf_kn_sale_graph (stale 2026-01) на
|
||
objective_corpus_room_month (обновляется еженедельно).
|
||
deals_total_count заменяет realised (DDU + DKP всего по корпусу).
|
||
|
||
Возвращает (ratio, recent_units, prior_units). None если данных мало.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
-- #1203: rolling 6mo/6mo окна относительно NOW(), а не
|
||
-- захардкоженных дат 2025. С хардкодом recent-окно росло
|
||
-- безгранично (ETL еженедельно доливает report_month) → на
|
||
-- июнь 2026 recent ≈ 11-12мес vs prior=6мес → ratio ≈ 2.0
|
||
-- → trend_factor навсегда упирался в кап 2.0 → bucket_velocity
|
||
-- ×2 → months_to_sellout занижен ×2 в живом recommend_mix.
|
||
SELECT
|
||
SUM(crm.deals_total_count)
|
||
FILTER (WHERE crm.report_month >= NOW() - INTERVAL '6 months')
|
||
AS recent,
|
||
SUM(crm.deals_total_count)
|
||
FILTER (WHERE crm.report_month >= NOW() - INTERVAL '12 months'
|
||
AND crm.report_month < NOW() - INTERVAL '6 months')
|
||
AS prior
|
||
FROM objective_corpus_room_month crm
|
||
WHERE crm.district = :dn
|
||
AND crm.deals_total_count > 0
|
||
"""
|
||
),
|
||
{"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 _district_cadastre_baseline(db: Session, *, district_name: str) -> dict[str, Any]:
|
||
"""Медианная кадастровая стоимость ₽/м² жилых строений в районе через
|
||
spatial-join cad_buildings → ekb_districts_geom. Возвращает None полей,
|
||
если в районе нет cad_buildings со cost_value.
|
||
|
||
Используется как cross-check для market price из rosreestr_deals:
|
||
cadastre_vs_market_pct > +50% (рынок сильно дороже кадастра, переоценка)
|
||
или < -30% (рынок дешевле кадастра, аномалия) → warning badge на UI.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH district_geom AS (
|
||
SELECT geom
|
||
FROM ekb_districts_geom
|
||
WHERE district_name = :dn
|
||
LIMIT 1
|
||
),
|
||
buildings_in AS (
|
||
SELECT
|
||
cb.cost_value / NULLIF(cb.area, 0) AS price_per_m2
|
||
FROM cad_buildings cb
|
||
JOIN district_geom dg
|
||
ON ST_Intersects(dg.geom, cb.geom)
|
||
WHERE cb.cost_value IS NOT NULL
|
||
AND cb.area IS NOT NULL
|
||
AND cb.area >= 100
|
||
-- floors INTEGER (Rosreestr ETL приводит к int); NULL = unknown.
|
||
-- Считаем МКД если floors ≥3 или purpose содержит «многокв».
|
||
AND (cb.floors >= 3 OR cb.purpose ILIKE '%многокв%')
|
||
AND (cb.cost_value / NULLIF(cb.area, 0))
|
||
BETWEEN 5000 AND 500000
|
||
)
|
||
SELECT
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2)
|
||
AS median_per_m2,
|
||
COUNT(*)::bigint AS n
|
||
FROM buildings_in
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row or row["n"] == 0:
|
||
return {"median_per_m2": None, "buildings_n": 0}
|
||
return {
|
||
"median_per_m2": _f(row["median_per_m2"]),
|
||
"buildings_n": int(row["n"]),
|
||
}
|
||
|
||
|
||
def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
|
||
"""Последняя средневзвешенная ставка ИЖК из cbr_mortgage_series.
|
||
|
||
ВАЖНО: возвращаем СРЕДНЕВЗВЕШЕННУЮ С льготами (семейная/IT/ДВ-ипотека) —
|
||
это ~7-8%. РЫНОЧНАЯ ставка без льгот в БД отсутствует (она ~20% по
|
||
публикациям ЦБ Янв 2026, но в наших cbr_mortgage_series этой серии нет).
|
||
|
||
Старый ILIKE '%ипотечн%жилищн%' случайно матчил «долю ипотечных кредитов
|
||
на ИЖС» (5.57% на ИЖС — НЕ ставка). Теперь строго matchим
|
||
'Средневзвешенная ставка по ипотечным жилищным' + 'в рублях, %'.
|
||
|
||
#236: period — ТЕКСТ вида 'Февраль 2026', поэтому `ORDER BY period DESC`
|
||
сортировал лексикографически по названию месяца ('Январь' > 'Февраль',
|
||
т.к. «Я» > «Ф») и возвращал устаревшую точку. Сортируем по разобранной
|
||
дате (год DESC, затем порядковый номер месяца DESC). Regex-guard
|
||
`'^[А-Яа-я]+ [0-9]{4}$'` отсекает мусорные строки, где в period лежит
|
||
число (доля ИЖС вида '5.57'), иначе split_part(...)::int упал бы.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT value, period
|
||
FROM cbr_mortgage_series
|
||
WHERE title ILIKE 'Средневзвешенная ставка по ипотечным жилищным%'
|
||
AND title ILIKE '%в рублях, %'
|
||
AND value IS NOT NULL
|
||
AND value BETWEEN 1 AND 30 -- защита от мусорных
|
||
AND period ~ '^[А-Яа-я]+ [0-9]{4}$' -- 'Месяц YYYY', иначе skip
|
||
ORDER BY
|
||
split_part(period, ' ', 2)::int DESC, -- год
|
||
CASE split_part(period, ' ', 1) -- порядковый № месяца
|
||
WHEN 'Январь' THEN 1 WHEN 'Февраль' THEN 2 WHEN 'Март' THEN 3
|
||
WHEN 'Апрель' THEN 4 WHEN 'Май' THEN 5 WHEN 'Июнь' THEN 6
|
||
WHEN 'Июль' THEN 7 WHEN 'Август' THEN 8 WHEN 'Сентябрь' THEN 9
|
||
WHEN 'Октябрь' THEN 10 WHEN 'Ноябрь' THEN 11 WHEN 'Декабрь' THEN 12
|
||
END DESC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return None, None
|
||
return _f(row["value"]), row["period"]
|
||
|
||
|
||
def _recommend_data_last_updated(db: Session, *, region_code: int) -> str | None:
|
||
"""#237: дата актуальности данных recommend — MAX(snapshot_date) по основному
|
||
источнику (domrf_kn_objects, ЖК региона). ISO 'YYYY-MM-DD' для UI-индикатора
|
||
свежести; None если в регионе нет объектов с непустым snapshot_date.
|
||
"""
|
||
val = db.execute(
|
||
text(
|
||
"""
|
||
SELECT MAX(snapshot_date)
|
||
FROM domrf_kn_objects
|
||
WHERE region_cd = :rc
|
||
AND snapshot_date IS NOT NULL
|
||
"""
|
||
),
|
||
{"rc": region_code},
|
||
).scalar()
|
||
if val is None:
|
||
return None
|
||
return val.isoformat()
|
||
|
||
|
||
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 _velocity_baseline_per_bucket(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
) -> dict[str, float] | None:
|
||
"""Per-bucket median velocity (units/month per ЖК) из objective_corpus_room_month.
|
||
|
||
Группирует по room_bucket → для каждого бакета вычисляет median(month_units)
|
||
по проектам района/класса за последние 24 месяца.
|
||
|
||
Маппинг room_bucket → _BUCKET_PRETTY ключи:
|
||
студия/studio/0 → '1-Студия'
|
||
1 → '2-1-к'
|
||
2 → '3-2-к'
|
||
3 → '4-3-к'
|
||
4/5+ → '5-80+ м²'
|
||
|
||
Возвращает dict {bucket_id → median velocity} только для бакетов с данными,
|
||
или None если нет данных совсем (caller переходит на rosreestr-fallback).
|
||
_ region_code retained for backward compat; objective data covers EKB only.
|
||
"""
|
||
_ = region_code
|
||
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
|
||
params: dict[str, Any] = {"dn": district_name}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH bucket_mapped AS (
|
||
SELECT
|
||
CASE
|
||
WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0')
|
||
THEN '1-Студия'
|
||
WHEN crm.room_bucket = '1' THEN '2-1-к'
|
||
WHEN crm.room_bucket = '2' THEN '3-2-к'
|
||
WHEN crm.room_bucket = '3' THEN '4-3-к'
|
||
WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²'
|
||
ELSE NULL
|
||
END AS bucket_id,
|
||
crm.project_name,
|
||
crm.report_month,
|
||
crm.deals_total_count
|
||
FROM objective_corpus_room_month crm
|
||
WHERE crm.district = :dn
|
||
{where_class}
|
||
AND crm.deals_total_count > 0
|
||
AND crm.report_month >= NOW() - INTERVAL '24 months'
|
||
),
|
||
per_project_bucket_month AS (
|
||
SELECT bucket_id,
|
||
project_name,
|
||
report_month,
|
||
SUM(deals_total_count) AS month_units
|
||
FROM bucket_mapped
|
||
WHERE bucket_id IS NOT NULL
|
||
GROUP BY bucket_id, project_name, report_month
|
||
)
|
||
SELECT
|
||
bucket_id,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm,
|
||
COUNT(DISTINCT project_name) AS objects,
|
||
COUNT(*) AS observations
|
||
FROM per_project_bucket_month
|
||
GROUP BY bucket_id
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
if not rows:
|
||
return None
|
||
|
||
result: dict[str, float] = {}
|
||
for r in rows:
|
||
v = _f(r["median_pm"])
|
||
if v is not None and int(r["observations"] or 0) >= 3:
|
||
result[r["bucket_id"]] = v
|
||
|
||
return result if result else None
|
||
|
||
|
||
def _elasticity_coef(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
elasticity_window_months: int = 24,
|
||
districts: list[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on
|
||
objective_corpus_room_month observations for the same район+class.
|
||
Returns elasticity (slope), R², n.
|
||
Falls back to FALLBACK_ELASTICITY if data thin or regression weak.
|
||
|
||
Мигрировано с domrf_kn_sale_graph (stale 2026-01) на
|
||
objective_corpus_room_month (обновляется еженедельно).
|
||
Маппинг: realised → deals_total_count,
|
||
price_avg → deals_total_avg_price_thousand_rub_per_m2.
|
||
LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope
|
||
не зависит от единиц (аддитивный сдвиг в LN пространстве).
|
||
|
||
District filter (#1211):
|
||
• ``districts is None`` → legacy: фильтр ``crm.district = :dn`` по
|
||
``district_name`` (back-compat для caller'ов с admin-vocab источником —
|
||
напр. ``analytics_queries`` использует ``domrf_kn_objects.district_name``;
|
||
отдельный bug class).
|
||
• ``districts=[]`` → EKB-wide (без district-фильтра). Используется когда
|
||
резолвер вернул None (вход = 'не определён' / нет чистых алиасов).
|
||
• ``districts=[m1, m2, ...]`` → ``crm.district = ANY(CAST(:districts AS text[]))``.
|
||
``district_name`` тогда используется только для logging.
|
||
|
||
``objective_corpus_room_month.district`` — МИКРО-вокабуляр ЕКБ (тот же, что
|
||
``objective_lots.district``: 'Втузгородок', 'ЖБИ', …). Передача сюда
|
||
admin-имени ('Кировский') даёт 0 точек → всегда FALLBACK (silent-correctness
|
||
bug #1211). Резолв admin→micros — обязанность вызывающего.
|
||
"""
|
||
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
|
||
params: dict[str, Any] = {
|
||
"dn": district_name,
|
||
"ew": elasticity_window_months,
|
||
}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
_ = region_code # retained for backward compat; objective data covers EKB only
|
||
|
||
if districts is None:
|
||
# Legacy path: один district_name (admin-vocab caller'ы остаются как были).
|
||
where_district = "AND crm.district = :dn"
|
||
elif districts:
|
||
# Резолвнутый набор МИКРО-районов — bind через CAST(:x AS text[]).
|
||
where_district = "AND crm.district = ANY(CAST(:districts AS text[]))"
|
||
params["districts"] = list(districts)
|
||
else:
|
||
# Пустой список — EKB-wide fallback (резолвер вернул None → нечего сужать).
|
||
where_district = ""
|
||
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH pts AS (
|
||
SELECT
|
||
LN(crm.deals_total_count::float8) AS y,
|
||
LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x
|
||
FROM objective_corpus_room_month crm
|
||
WHERE crm.deals_total_count > 0
|
||
{where_district}
|
||
{where_class}
|
||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
|
||
AND crm.report_month >= NOW() - (:ew || ' months')::interval
|
||
)
|
||
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 _elasticity_per_bucket_coef(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
fallback: dict[str, Any],
|
||
elasticity_window_months: int = 24,
|
||
) -> dict[str, dict[str, Any]]:
|
||
"""Per-bucket эластичность (Tier 3): группируем objective_corpus_room_month
|
||
по room_bucket — регрессия log-log для каждой группы.
|
||
Студии vs 80+ м² реагируют на цену по-разному.
|
||
|
||
Мигрировано с domrf_kn_sale_graph + domrf_kn_flats (stale 2026-01) на
|
||
objective_corpus_room_month (обновляется еженедельно).
|
||
objective_corpus_room_month уже содержит room_bucket напрямую — нет
|
||
необходимости в MODE-агрегации domrf_kn_flats.
|
||
|
||
Маппинг room_bucket → _BUCKET_PRETTY ключи:
|
||
'студия' → '1-Студия'
|
||
'1' → '2-1-к'
|
||
'2' → '3-2-к'
|
||
'3' → '4-3-к'
|
||
'4'/'5+' → '5-80+ м²'
|
||
|
||
Returns: dict[bucket_pretty → {elasticity, r2, n, source}]. Если в bucket'е
|
||
меньше 30 точек или регрессия слабая (R²<0.05 либо positive slope) — берём
|
||
общую эластичность из `fallback` со source='fallback_global'.
|
||
"""
|
||
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
|
||
params: dict[str, Any] = {
|
||
"dn": district_name,
|
||
"ew": elasticity_window_months,
|
||
}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
_ = region_code # retained for backward compat; objective data covers EKB only
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH pts AS (
|
||
SELECT
|
||
CASE
|
||
WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0')
|
||
THEN '1-Студия'
|
||
WHEN crm.room_bucket = '1' THEN '2-1-к'
|
||
WHEN crm.room_bucket = '2' THEN '3-2-к'
|
||
WHEN crm.room_bucket = '3' THEN '4-3-к'
|
||
WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²'
|
||
ELSE NULL
|
||
END AS bucket,
|
||
LN(crm.deals_total_count::float8) AS y,
|
||
LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x
|
||
FROM objective_corpus_room_month crm
|
||
WHERE crm.district = :dn
|
||
{where_class}
|
||
AND crm.deals_total_count > 0
|
||
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
|
||
AND crm.report_month >= NOW() - (:ew || ' months')::interval
|
||
)
|
||
SELECT bucket,
|
||
regr_slope(y, x) AS slope,
|
||
regr_r2(y, x) AS r2,
|
||
COUNT(*)::bigint AS n
|
||
FROM pts
|
||
WHERE bucket IS NOT NULL
|
||
GROUP BY bucket
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
out: dict[str, dict[str, Any]] = {}
|
||
fallback_e = float(fallback["elasticity"])
|
||
by_bucket = {r["bucket"]: r for r in rows}
|
||
for bucket_id, bucket_pretty in _BUCKET_PRETTY.items():
|
||
r = by_bucket.get(bucket_id)
|
||
n_b = int(r["n"]) if r and r["n"] is not None else 0
|
||
slope = _f(r["slope"]) if r else None
|
||
r2 = _f(r["r2"]) if r else None
|
||
if n_b >= 30 and slope is not None and r2 is not None and r2 >= 0.05 and slope < 0:
|
||
out[bucket_pretty] = {
|
||
"elasticity": round(slope, 4),
|
||
"r2": round(r2, 4),
|
||
"n": n_b,
|
||
"source": "regression",
|
||
}
|
||
else:
|
||
out[bucket_pretty] = {
|
||
"elasticity": fallback_e,
|
||
"r2": round(r2, 4) if r2 is not None else 0.0,
|
||
"n": n_b,
|
||
"source": "fallback_global",
|
||
}
|
||
return out
|
||
|
||
|
||
def _noise_penalty_factor(db: Session, district_name: str | None) -> tuple[float, dict]:
|
||
"""Penalty к ценам исходя из плотности шумных объектов в районе.
|
||
|
||
Returns: (factor in [0.90, 1.0], breakdown dict).
|
||
Чем больше магистралей/жд/промзон — тем ниже factor (max -10%).
|
||
"""
|
||
if not district_name:
|
||
return 1.0, {}
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH district_noise AS (
|
||
SELECT n.source_type, n.road_class, COUNT(*) AS n
|
||
FROM osm_noise_sources_ekb n
|
||
JOIN ekb_districts d ON ST_Intersects(n.geom, d.geom)
|
||
WHERE d.district_name = :dn
|
||
GROUP BY 1, 2
|
||
)
|
||
SELECT COALESCE(SUM(n), 0) AS total_sources,
|
||
COALESCE(SUM(CASE WHEN source_type = 'railway' THEN n END), 0) AS railway_n,
|
||
COALESCE(SUM(CASE WHEN source_type = 'industrial' THEN n END), 0)
|
||
AS industrial_n,
|
||
COALESCE(
|
||
SUM(CASE WHEN road_class IN ('motorway', 'trunk') THEN n END), 0
|
||
) AS magistral_n
|
||
FROM district_noise
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row or not row["total_sources"]:
|
||
return 1.0, {"district": district_name, "noise_sources": 0}
|
||
score = (
|
||
float(row["magistral_n"]) * 0.05
|
||
+ float(row["railway_n"]) * 0.02
|
||
+ float(row["industrial_n"]) * 0.03
|
||
)
|
||
penalty = min(0.10, max(0.0, score / 100))
|
||
factor = 1.0 - penalty
|
||
return round(factor, 4), {
|
||
"district": district_name,
|
||
"magistral_n": int(row["magistral_n"]),
|
||
"railway_n": int(row["railway_n"]),
|
||
"industrial_n": int(row["industrial_n"]),
|
||
"total_sources": int(row["total_sources"]),
|
||
"penalty_pct": round(penalty * 100, 1),
|
||
}
|
||
|
||
|
||
def _competitors_two_dim(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
) -> tuple[int, int, float, str]:
|
||
"""Двумерный подсчёт активных конкурентов:
|
||
- radius_n: ЖК в радиусе 3км от центроида района
|
||
- district_only_n: ЖК в районе, но вне 3км радиуса
|
||
- total_weighted = radius_n * 1.0 + district_only_n * 0.6
|
||
|
||
Returns (radius_n, district_only_n, total_weighted, scope).
|
||
Если district_name не найден в ekb_districts — падает в старый
|
||
_active_competitors_count с total_weighted = float(competitors).
|
||
"""
|
||
# Получаем центроид района для radius-фильтра
|
||
centroid_row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT ST_AsText(ST_Centroid(geom)) AS centroid_wkt
|
||
FROM ekb_districts
|
||
WHERE district_name = :dn
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not centroid_row or not centroid_row["centroid_wkt"]:
|
||
# Fallback: используем старый одномерный счётчик
|
||
n, scope = _active_competitors_count(
|
||
db, region_code=region_code, district_name=district_name, target_class=target_class
|
||
)
|
||
return 0, n, float(n), scope
|
||
|
||
class_filter = "AND obj_class = :cls" if target_class else ""
|
||
params: dict[str, Any] = {
|
||
"rc": region_code,
|
||
"dn": district_name,
|
||
"centroid": centroid_row["centroid_wkt"],
|
||
}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH active AS (
|
||
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
|
||
FROM domrf_kn_objects
|
||
WHERE region_cd = :rc
|
||
AND site_status = 'Строящиеся'
|
||
AND district_name = :dn
|
||
{class_filter}
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
),
|
||
centroid AS (
|
||
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
|
||
)
|
||
SELECT
|
||
COUNT(*) FILTER (
|
||
WHERE ST_DWithin(
|
||
ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography,
|
||
c.pt,
|
||
3000
|
||
)
|
||
) AS radius_n,
|
||
COUNT(*) FILTER (
|
||
WHERE NOT ST_DWithin(
|
||
ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography,
|
||
c.pt,
|
||
3000
|
||
)
|
||
) AS district_only_n
|
||
FROM active a, centroid c
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
radius_n = int(row["radius_n"] or 0) if row else 0
|
||
district_only_n = int(row["district_only_n"] or 0) if row else 0
|
||
total_weighted = radius_n * 1.0 + district_only_n * 0.6
|
||
if total_weighted < 1.0:
|
||
# Нет конкурентов в районе — fallback к старому счётчику (регион)
|
||
n, scope = _active_competitors_count(
|
||
db, region_code=region_code, district_name=district_name, target_class=target_class
|
||
)
|
||
return 0, n, float(max(n, 1)), scope
|
||
return radius_n, district_only_n, max(total_weighted, 1.0), "district_2d"
|
||
|
||
|
||
def _bucket_success_ranking(
|
||
db: Session, district_name: str | None, target_class: str | None
|
||
) -> list[dict]:
|
||
"""Рейтинг bucket'ов по success_score из v_bucket_success_score.
|
||
|
||
Возвращает список dict {bucket, success_score, n_deals, velocity_z,
|
||
price_z, area_z}, sorted DESC by success_score. Пустой список если
|
||
данных нет или district_name не передан.
|
||
"""
|
||
if not district_name:
|
||
return []
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z
|
||
FROM v_bucket_success_score
|
||
WHERE district_name = :dn
|
||
AND obj_class = COALESCE(:cls, 'Comfort')
|
||
ORDER BY success_score DESC
|
||
"""
|
||
),
|
||
{"dn": district_name, "cls": target_class},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"bucket": r["bucket"],
|
||
"success_score": float(r["success_score"]) if r["success_score"] is not None else 0.0,
|
||
"n_deals": int(r["n_deals"] or 0),
|
||
"velocity_z": float(r["velocity_z"]) if r["velocity_z"] is not None else 0.0,
|
||
"price_z": float(r["price_z"]) if r["price_z"] is not None else 0.0,
|
||
"area_z": float(r["area_z"]) if r["area_z"] is not None else 0.0,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def recommend_mix(
|
||
db: Session,
|
||
*,
|
||
district_name: str,
|
||
area_total_m2: float | None = None,
|
||
target_class: str | None = None,
|
||
months_window: int = 24,
|
||
region_code: int = 66,
|
||
price_factor: float = 1.0,
|
||
target_months: int | None = None,
|
||
horizon_months: int | None = None,
|
||
cad_num: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Rule-based квартирография recommender v3.1-v3.4.
|
||
|
||
City-wide bucket distribution from rosreestr_deals (последние N месяцев),
|
||
скорректированная на район (через ekb_districts.median_price_per_m2) и
|
||
класс (через yandex_realty_zk price-агрегаты per-class).
|
||
|
||
v3.1: noise penalty (-10% max) по osm_noise_sources_ekb
|
||
v3.2: hard-cap comparables по boundaries района
|
||
v3.3: hard-cap 24 мес + elasticity_window_months = 24
|
||
v3.4: success-driven mix из v_bucket_success_score
|
||
"""
|
||
warnings: list[str] = []
|
||
|
||
# #24 Hard-cap: данные старше 24 мес нерелевантны (ставки ЦБ, ипотека менялись)
|
||
if months_window > 24:
|
||
logger.warning("recommend_mix: months_window=%d > 24, capped to 24", months_window)
|
||
months_window = 24
|
||
elasticity_window_months = 24 # синхронизировано с share_window (issue #24)
|
||
|
||
# 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.
|
||
# Дефолт 24 мес — после загрузки rosreestr 2024Q1+Q2 у нас 27 мес истории,
|
||
# 24 мес даёт устойчивые per-bucket медианы для большинства региональных
|
||
# ЖК-классов. Если хоть в одном бакете <30 сделок — расширяем до 27 мес
|
||
# (мах доступного окна), это последний rampe перед deferring к global.
|
||
bucket_rows = _bucket_distribution(db, region_code, months_window)
|
||
effective_window = months_window
|
||
if months_window < 27 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
|
||
bucket_rows_27 = _bucket_distribution(db, region_code, 27)
|
||
if bucket_rows_27:
|
||
bucket_rows = bucket_rows_27
|
||
effective_window = 27
|
||
warnings.append(
|
||
f"Окно расширено до 27 мес: при {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,
|
||
# #1359: сырое area_avg сохраняем, чтобы при success-boost
|
||
# перераспределить units/revenue под новые share_pct. Удаляется
|
||
# из ответа перед возвратом.
|
||
"_area_avg_raw": area_avg,
|
||
}
|
||
)
|
||
|
||
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 для всех ЖК Свердл (отдельный
|
||
# баг скрейпера). Если в районе нет ни одного ЖК ИМЕННО запрошенного класса —
|
||
# игнорируем target_class фильтр на уровне velocity/elasticity/comparable
|
||
# запросов, иначе obj_pool пустой и всё падает в fallback.
|
||
# #1375: БД хранит русские названия классов, UI шлёт английские — переводим
|
||
# перед матчем obj_class = :cls, иначе точный матч молча даёт ноль строк
|
||
# ("Comfort" != "Комфорт") и comparables/competitors тихо пустеют.
|
||
target_class_db = _class_to_db_vocab(target_class)
|
||
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
|
||
AND (CAST(:cls AS TEXT) IS NULL OR obj_class = :cls)
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"rc": region_code, "dn": district_row["district_name"], "cls": target_class_db},
|
||
).scalar()
|
||
)
|
||
target_class_for_geo = target_class_db if has_class_data else None
|
||
if target_class and not has_class_data:
|
||
warnings.append(
|
||
f"Нет ЖК класса '{target_class}' среди размеченных в районе"
|
||
f" {district_row['district_name']} — фильтр по классу игнорируется в"
|
||
" 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,
|
||
)
|
||
sale_graph_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
|
||
# velocity_source label: "objective" when data available, "rosreestr_fallback" otherwise.
|
||
# Value key kept as "sale_graph" in output for frontend backward-compat (no breaking change).
|
||
# After fix #574: per-bucket objective data (_velocity_baseline_per_bucket) is used even
|
||
# when aggregate sale_graph_vel_pm is None. velocity_source reflects aggregate source;
|
||
# per-bucket source is tracked in bucket["velocity_source"] added below.
|
||
velocity_source = "objective" if sale_graph_vel_pm is not None else "rosreestr_fallback"
|
||
|
||
elast = _elasticity_coef(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
elasticity_window_months=elasticity_window_months,
|
||
)
|
||
elasticity = elast["elasticity"]
|
||
if elast["source"] == "fallback":
|
||
warnings.append(
|
||
f"Эластичность цена↔темп взята по умолчанию ({elasticity})"
|
||
f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}"
|
||
" (недостаточно для регрессии)."
|
||
)
|
||
|
||
# Tier 3: per-bucket эластичность. Регрессия sale_graph по
|
||
# «доминирующему bucket» каждого ЖК. Если для bucket'а данных мало —
|
||
# подставляем общую elasticity. Малые сегменты (1-2 студии в районе)
|
||
# таким образом не выкидываются — используем общую кривую.
|
||
elast_per_bucket = _elasticity_per_bucket_coef(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
fallback=elast,
|
||
elasticity_window_months=elasticity_window_months,
|
||
)
|
||
|
||
# 5b-1) Двумерные конкуренты (#23): radius_n (3км) + district_only_n.
|
||
# total_weighted используется как divisor в rosreestr-fallback.
|
||
competitors_radius_n, competitors_district_only_n, competitors_weighted, competitors_scope = (
|
||
_competitors_two_dim(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
)
|
||
)
|
||
# Обратная совместимость: одномерный счётчик для warnings
|
||
competitors = round(competitors_weighted)
|
||
if competitors_scope == "fallback_singleton":
|
||
warnings.append(
|
||
f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
|
||
f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
|
||
)
|
||
elif competitors_scope not in ("district+class", "district_2d"):
|
||
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 (fix #574: per-bucket formula, realistic срок).
|
||
#
|
||
# BUG (до fix): market_vel_pm = total_deals/months/competitors_district, затем
|
||
# bucket_v = market_vel_pm × share/100 → all buckets scale proportionally →
|
||
# aggregate velocity ≡ market_vel_pm независимо от mix (slider static bug).
|
||
# При competitors_district≈1 получаем 159 кв/мес (темп РЫНКА, не проекта).
|
||
#
|
||
# FIX: per-bucket velocity вычисляется независимо для каждого бакета:
|
||
# objective path: _velocity_baseline_per_bucket → median per ЖК per room_bucket
|
||
# rosreestr fallback: bucket_deals / months / n_comp (district+class competitors)
|
||
#
|
||
# Это позволяет mix-слайдерам реально менять aggregate KPI, т.к. velocities
|
||
# студий, 1к и т.д. теперь независимые константы, не производные от share.
|
||
|
||
# Objective path: per-bucket velocities из objective_corpus_room_month
|
||
obj_per_bucket = _velocity_baseline_per_bucket(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
)
|
||
|
||
# n_comp — district+class конкуренты (_active_competitors_count каскад,
|
||
# уже вычислено выше как competitors_weighted). Делим темп РЫНКА района/класса
|
||
# на число активных ЖК этого района/класса → среднерыночный темп одного проекта.
|
||
# НЕ region-wide (~442 ЖК) — это давало срок 379-1180 мес (нереалистично).
|
||
n_comp = max(round(competitors_weighted), 1) # district+class competitors, NOT region-wide
|
||
|
||
# Aggregate market_vel_pm (сохраняем для scope/output, не для bucket расчётов)
|
||
if sale_graph_vel_pm is not None:
|
||
market_vel_pm = sale_graph_vel_pm
|
||
else:
|
||
# Rosreestr fallback aggregate: district+class deals / months / n_comp =
|
||
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
|
||
market_vel_pm = (total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
|
||
warnings.append(
|
||
f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
|
||
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
|
||
)
|
||
|
||
# 5b-2.5) Per-bucket market velocity (fix #574).
|
||
#
|
||
# Objective path: используем per-bucket velocities из objective_corpus_room_month.
|
||
# Для бакетов без данных в objective — fallback к rosreestr per-bucket.
|
||
# Rosreestr fallback: bucket_deals_per_month / n_comp (district+class competitors).
|
||
# КРИТИЧНО: bucket_deal_counts — это MARKET deal counts (независимы от share_pct
|
||
# пользователя), поэтому per-bucket velocities — независимые константы →
|
||
# mix-слайдеры реально влияют на aggregate KPI (static-mix bug fix).
|
||
bucket_deal_counts = {r["bucket"]: int(r["deals"] or 0) for r in bucket_rows}
|
||
|
||
bucket_market_velocities: dict[str, float] = {}
|
||
for b in buckets:
|
||
bkey = b["bucket"]
|
||
bkt_id = next((k for k, v in _BUCKET_PRETTY.items() if v == bkey), bkey)
|
||
# Objective per-bucket (preferred): median units/month per ЖК в этом бакете
|
||
if obj_per_bucket and bkt_id in obj_per_bucket:
|
||
bucket_market_velocities[bkey] = obj_per_bucket[bkt_id]
|
||
else:
|
||
# Rosreestr fallback per-bucket: market bucket_deals / months / n_comp.
|
||
# n_comp — district+class competitors (НЕ region-wide ~442), иначе срок
|
||
# выходит 379-1180 мес. bucket_deal_counts — market (не user share).
|
||
raw_deals = bucket_deal_counts.get(bkt_id, 0)
|
||
bucket_market_velocities[bkey] = raw_deals / max(effective_window, 1) / n_comp
|
||
|
||
# 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)
|
||
|
||
# Cadastre cross-check: медианная кадастровая стоимость ₽/м² района через
|
||
# cad_buildings → ekb_districts spatial-join. Аномалии (рынок vs кадастр)
|
||
# выводятся как warning-цена в RecommendVelocityPanel.
|
||
cadastre = _district_cadastre_baseline(db, district_name=district_row["district_name"])
|
||
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)
|
||
|
||
# #237: индикатор актуальности данных — MAX(snapshot_date) основного источника
|
||
data_last_updated = _recommend_data_last_updated(db, region_code=region_code)
|
||
|
||
# #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
|
||
noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])
|
||
|
||
# #25 Success-driven ranking из v_bucket_success_score
|
||
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
|
||
|
||
# 5b-3) Per-bucket project velocity at price_factor=1.0:
|
||
# bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region.
|
||
# После fix #574: каждый бакет имеет независимую скорость
|
||
# (не производную от share) → mix-слайдеры реально меняют KPI.
|
||
# project_velocity = bucket_market_v × sat_factor × trend_factor
|
||
# sat — зрелый рынок ускоряет; trend — текущая
|
||
# динамика (горит/остывает).
|
||
# adjusted = project_velocity × price_factor^elasticity
|
||
# months_to_sellout = units_planned / adjusted
|
||
# Цены корректируются на poi_factor (развитость района = премиум)
|
||
# и noise_penalty (шумное окружение = дисконт).
|
||
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
|
||
macro_velocity_mult = sat_factor * trend_factor
|
||
# Комбинированный ценовой коэффициент: POI-премиум × noise-дисконт
|
||
combined_price_factor = poi_factor * noise_penalty
|
||
total_units = 0
|
||
for b in buckets:
|
||
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
|
||
bkt_id_for_src = next(
|
||
(k for k, v in _BUCKET_PRETTY.items() if v == b["bucket"]), b["bucket"]
|
||
)
|
||
b["velocity_source"] = (
|
||
"objective_per_bucket"
|
||
if (obj_per_bucket and bkt_id_for_src in obj_per_bucket)
|
||
else "rosreestr_fallback"
|
||
)
|
||
bucket_velocity = round(bucket_market_v * macro_velocity_mult, 4)
|
||
b["velocity_per_month"] = bucket_velocity
|
||
# Per-bucket эластичность: ключ — pretty-имя (b["bucket"] уже pretty).
|
||
be = elast_per_bucket.get(b["bucket"]) or {}
|
||
bucket_elasticity = float(be.get("elasticity", elasticity))
|
||
bucket_pf_pow = price_factor**bucket_elasticity if price_factor > 0 else 1.0
|
||
b["elasticity"] = bucket_elasticity
|
||
b["elasticity_r2"] = be.get("r2", 0.0)
|
||
b["elasticity_n"] = be.get("n", 0)
|
||
b["elasticity_source"] = be.get("source", "fallback_global")
|
||
# POI-корректировка + noise penalty на цены (ВСЕ p25/median/p75)
|
||
b["price_median_per_m2"] = round(b["price_median_per_m2"] * combined_price_factor, 2)
|
||
b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * combined_price_factor, 2)
|
||
b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * combined_price_factor, 2)
|
||
b["is_top_success"] = False
|
||
if b["units_planned"] and bucket_velocity > 0:
|
||
# Revenue тоже пересчитываем после combined-correction (linear scale).
|
||
if b["revenue_planned_rub"] is not None:
|
||
b["revenue_planned_rub"] = round(
|
||
b["revenue_planned_rub"] * combined_price_factor, 2
|
||
)
|
||
adjusted_velocity = bucket_velocity * bucket_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 + noise penalty.
|
||
if have_revenue:
|
||
total_revenue *= combined_price_factor
|
||
if weighted_avg_price is not None:
|
||
weighted_avg_price = round(weighted_avg_price * combined_price_factor, 2)
|
||
|
||
# #25 Success-driven mix: поднимаем долю top-success bucket'а на 10%,
|
||
# пропорционально уменьшаем остальные. Условие: success_score > 0 AND n_deals >= 30.
|
||
if success_ranking:
|
||
top = next(
|
||
(r for r in success_ranking if r["success_score"] > 0 and r["n_deals"] >= 30),
|
||
None,
|
||
)
|
||
if top:
|
||
top_bucket_name = top["bucket"]
|
||
# Найти bucket в списке по имени
|
||
top_b = next((b for b in buckets if b["bucket"] == top_bucket_name), None)
|
||
if top_b is not None:
|
||
boost = top_b["share_pct"] * 0.10 # +10%
|
||
top_b["share_pct"] = round(top_b["share_pct"] + boost, 1)
|
||
top_b["is_top_success"] = True
|
||
# Пропорционально уменьшаем остальные чтобы sum = 100
|
||
other_sum = sum(b["share_pct"] for b in buckets if b["bucket"] != top_bucket_name)
|
||
if other_sum > 0:
|
||
scale = (100.0 - top_b["share_pct"]) / other_sum
|
||
for b in buckets:
|
||
if b["bucket"] != top_bucket_name:
|
||
b["share_pct"] = round(b["share_pct"] * scale, 1)
|
||
|
||
# #1576: success-boost изменил share_pct → средневзвешенная цена
|
||
# должна пересчитаться под новые доли, иначе weighted_avg_price
|
||
# остаётся от ДО-boost микса (пробел в фиксе #1359, который
|
||
# обновлял только units/revenue). price_median_per_m2 уже включает
|
||
# combined_price_factor (line 2743), поэтому домножать НЕ нужно —
|
||
# это согласовано с per-bucket revenue ниже. Веса = area_avg×share,
|
||
# независимо от area_total_m2, поэтому вне ветки area_total_m2.
|
||
wnum = sum(
|
||
b["_area_avg_raw"] * b["share_pct"] * b["price_median_per_m2"]
|
||
for b in buckets
|
||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||
)
|
||
wden = sum(
|
||
b["_area_avg_raw"] * b["share_pct"]
|
||
for b in buckets
|
||
if b["_area_avg_raw"] and b["_area_avg_raw"] > 0
|
||
)
|
||
weighted_avg_price = round(wnum / wden, 2) if wden > 0 else None
|
||
|
||
# #1359: success-boost изменил share_pct → перераспределяем
|
||
# units/revenue/months_to_sellout и агрегаты под новые доли,
|
||
# иначе share_pct рассогласуется с units_planned/revenue/sellout
|
||
# (UI показывал бы 40% доли при units от старых 30%).
|
||
if area_total_m2:
|
||
total_units = 0
|
||
total_revenue = 0.0
|
||
for b in buckets:
|
||
area_avg = b["_area_avg_raw"]
|
||
if area_avg and area_avg > 0:
|
||
allocated = area_total_m2 * (b["share_pct"] / 100.0)
|
||
units = max(1, round(allocated / area_avg))
|
||
b["units_planned"] = units
|
||
# price_median_per_m2 уже с combined_price_factor —
|
||
# revenue считаем по скорректированной цене.
|
||
revenue = round(units * area_avg * b["price_median_per_m2"], 2)
|
||
b["revenue_planned_rub"] = revenue
|
||
total_revenue += revenue
|
||
bucket_velocity = b["velocity_per_month"] or 0.0
|
||
be_val = b.get("elasticity")
|
||
bpf = (
|
||
price_factor**be_val
|
||
if (be_val is not None and price_factor > 0)
|
||
else 1.0
|
||
)
|
||
adj_vel = bucket_velocity * bpf
|
||
b["months_to_sellout"] = (
|
||
round(units / adj_vel, 1) if adj_vel > 0 else None
|
||
)
|
||
total_units += units
|
||
# total_revenue/weighted_avg_price уже включают
|
||
# combined_price_factor (через per-bucket price_median_per_m2),
|
||
# повторно домножать НЕ нужно.
|
||
have_revenue = total_revenue > 0
|
||
|
||
# 5c) Inverse mode: target_months → required price_factor.
|
||
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
|
||
# → агрегатная только когда нужна одна цифра). При smooth-buckets разница
|
||
# с глобальной невелика, но если bucket-mix сильно перекошен в одну сторону —
|
||
# weighted-эластичность точнее отражает поведение портфеля.
|
||
required_price_factor: float | None = None
|
||
weighted_elasticity = elasticity
|
||
if total_units > 0:
|
||
weighted_elasticity = (
|
||
sum(
|
||
(b.get("elasticity") or elasticity) * (b.get("units_planned") or 0) for b in buckets
|
||
)
|
||
/ total_units
|
||
)
|
||
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 weighted_elasticity != 0:
|
||
required_velocity = total_units / target_months
|
||
ratio = required_velocity / base_total_velocity
|
||
try:
|
||
required_price_factor = round(ratio ** (1.0 / weighted_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. Total months_to_sellout считаем как сумму
|
||
# bucket-уровневых adjusted velocities (каждая со своим pf_pow по своей
|
||
# эластичности) — иначе перекос в bucket-mix искажает агрегат.
|
||
months_to_sellout_total: float | None = None
|
||
base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
|
||
adjusted_total_v = 0.0
|
||
for b in buckets:
|
||
v = b.get("velocity_per_month") or 0
|
||
be = b.get("elasticity")
|
||
bpf = price_factor**be if (be is not None and price_factor > 0) else pf_pow
|
||
adjusted_total_v += v * bpf
|
||
if total_units > 0 and adjusted_total_v > 0:
|
||
months_to_sellout_total = round(total_units / adjusted_total_v, 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.
|
||
# #22 Hard-cap по границам: фильтруем по ST_Within чтобы исключить ЖК
|
||
# у границы района, формально в domrf по district_name, но реально за
|
||
# пределами полигона (координаты из v_complex_full). ЖК без координат
|
||
# (latitude/longitude NULL) — пропускаем через LEFT JOIN + фильтр.
|
||
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
|
||
),
|
||
vcf_dedup AS (
|
||
-- один ряд на canonical_name: берём с наибольшим cad_buildings_n
|
||
SELECT DISTINCT ON (lower(canonical_name))
|
||
lower(canonical_name) AS name_key,
|
||
cad_quarter,
|
||
latitude,
|
||
longitude,
|
||
cad_buildings_n
|
||
FROM v_complex_full
|
||
ORDER BY lower(canonical_name), cad_buildings_n DESC NULLS LAST
|
||
),
|
||
district_geom AS (
|
||
SELECT geom FROM ekb_districts WHERE district_name = :dn LIMIT 1
|
||
),
|
||
latest_obj AS (
|
||
-- domrf_kn_objects содержит ~3 snapshot'а на obj_id;
|
||
-- берём только самый свежий, иначе comparables дублируются
|
||
SELECT DISTINCT ON (obj_id) *
|
||
FROM domrf_kn_objects
|
||
WHERE region_cd = :rc
|
||
AND district_name = :dn
|
||
AND (CAST(:cls AS TEXT) IS NULL OR obj_class = :cls)
|
||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
||
)
|
||
SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.flat_count,
|
||
a.perc AS sold_perc,
|
||
c.cad_quarter,
|
||
c.latitude AS lat,
|
||
c.longitude AS lon,
|
||
c.cad_buildings_n AS buildings_n
|
||
FROM latest_obj 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'
|
||
LEFT JOIN vcf_dedup c ON c.name_key = lower(o.comm_name)
|
||
WHERE (
|
||
-- hard-cap по границам района: только если координаты известны И
|
||
-- точка внутри полигона. Без координат — включаем (нет данных для отсева)
|
||
c.latitude IS NULL
|
||
OR c.longitude IS NULL
|
||
OR ST_Within(
|
||
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326),
|
||
(SELECT geom FROM district_geom)
|
||
)
|
||
)
|
||
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:
|
||
# Tempo = sum bucket-adjusted velocities (каждая со своим pf_pow по своей
|
||
# эластичности). Это согласовано с months_to_sellout_total выше.
|
||
tempo = adjusted_total_v if adjusted_total_v > 0 else 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
|
||
|
||
# #1359: убираем служебное поле перед возвратом (и до передачи в overlay).
|
||
for b in buckets:
|
||
b.pop("_area_avg_raw", None)
|
||
|
||
result: dict[str, Any] = {
|
||
"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,
|
||
# fix #574: n_competitors (district+class) — знаменатель в rosreestr-fallback
|
||
"n_competitors": n_comp,
|
||
"velocity_observations": vel["observations"],
|
||
"velocity_objects": vel["objects_count"],
|
||
"competitors_count": competitors,
|
||
"competitors_scope": competitors_scope,
|
||
"competitors_radius_n": competitors_radius_n,
|
||
"competitors_district_only_n": competitors_district_only_n,
|
||
"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,
|
||
# #237: дата актуальности данных recommend (ISO 'YYYY-MM-DD' | null)
|
||
"last_updated": data_last_updated,
|
||
"elasticity": elasticity,
|
||
"elasticity_r2": elast["r2"],
|
||
"elasticity_n": elast["n"],
|
||
"elasticity_source": elast["source"],
|
||
"elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
|
||
"elasticity_per_bucket": elast_per_bucket,
|
||
# Окна источников данных — для прозрачности и UI-tooltip.
|
||
# share_window_months — окно по rosreestr_deals для bucket-shares
|
||
# и market velocity (input months_window, может расшириться до 27).
|
||
# elasticity_window_months — синхронизировано с share_window (issue #24).
|
||
"share_window_months": effective_window,
|
||
"elasticity_window_months": elasticity_window_months,
|
||
# Noise penalty (issue #22)
|
||
"noise_penalty": noise_penalty,
|
||
"noise_breakdown": noise_breakdown,
|
||
# Success ranking (issue #25)
|
||
"success_ranking": success_ranking,
|
||
"cadastre_median_per_m2": (
|
||
round(cadastre["median_per_m2"], 0)
|
||
if cadastre["median_per_m2"] is not None
|
||
else None
|
||
),
|
||
"cadastre_buildings_n": cadastre["buildings_n"],
|
||
"cadastre_vs_market_pct": (
|
||
round(
|
||
(district_median - cadastre["median_per_m2"])
|
||
/ cadastre["median_per_m2"]
|
||
* 100.0,
|
||
1,
|
||
)
|
||
if (cadastre["median_per_m2"] and cadastre["median_per_m2"] > 0 and district_median)
|
||
else None
|
||
),
|
||
"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"]),
|
||
"cad_quarter": r["cad_quarter"],
|
||
"lat": _f(r["lat"]),
|
||
"lon": _f(r["lon"]),
|
||
"buildings_n": r["buildings_n"],
|
||
}
|
||
for r in cmp_rows
|
||
],
|
||
}
|
||
|
||
# #982 (953-A) ADDITIVE OPT-IN: horizon-aware forecast-overlay. Живой микс выше
|
||
# БАЙТ-в-БАЙТ не тронут — overlay добавляется ТОЛЬКО при заданном horizon_months
|
||
# и ТОЛЬКО под scope["forecast"] (открытый dict). КРИТИЧНО (live-endpoint safety):
|
||
# overlay СОВЕТУЮЩИЙ и НИКОГДА не должен уронить живой ответ — вся ветка в
|
||
# try/except, на сбое кладём {error, advisory} и всё равно возвращаем микс.
|
||
# Локальный импорт — избегаем потенциального import-cycle (analytics_queries ↔
|
||
# forecasting) и не утяжеляем top-level живого analytics-стека advisory-движком.
|
||
if horizon_months is not None:
|
||
try:
|
||
from app.services.forecasting.recommendation import build_forecast_overlay
|
||
|
||
result["scope"]["forecast"] = build_forecast_overlay(
|
||
db,
|
||
district=district_row["district_name"],
|
||
cad_num=cad_num,
|
||
horizon_months=horizon_months,
|
||
target_class=target_class,
|
||
market_buckets=buckets,
|
||
)
|
||
except Exception as e:
|
||
logger.exception(
|
||
"recommend_mix: forecast-overlay failed (district=%s cad_num=%s horizon=%s) "
|
||
"— живой микс возвращён без overlay",
|
||
district_row["district_name"],
|
||
cad_num,
|
||
horizon_months,
|
||
)
|
||
result["scope"]["forecast"] = {"error": str(e), "advisory": True}
|
||
|
||
return result
|
||
|
||
|
||
# ── Cadastral buildings per complex ──────────────────────────────────────────
|
||
|
||
|
||
def complex_buildings(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||
"""Список зданий из cad_buildings для данного ЖК.
|
||
|
||
Возвращает [] если ни одного здания не найдено.
|
||
"""
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT cad_num, floors, area, purpose, building_name,
|
||
readable_address, ST_AsGeoJSON(geom) AS geom_geojson
|
||
FROM cad_buildings
|
||
WHERE complex_id = :obj_id
|
||
ORDER BY cad_num
|
||
"""
|
||
),
|
||
{"obj_id": obj_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
import json as _json
|
||
|
||
result: list[dict[str, Any]] = []
|
||
for r in rows:
|
||
geom_raw = r["geom_geojson"]
|
||
geom: dict[str, Any] | None = None
|
||
if geom_raw:
|
||
try:
|
||
geom = _json.loads(geom_raw)
|
||
except (ValueError, TypeError):
|
||
geom = None
|
||
result.append(
|
||
{
|
||
"cad_num": r["cad_num"],
|
||
"floors": r["floors"],
|
||
"area": _f(r["area"]),
|
||
"purpose": r["purpose"],
|
||
"building_name": r["building_name"],
|
||
"readable_address": r["readable_address"],
|
||
"geom_geojson": geom,
|
||
}
|
||
)
|
||
return result
|