fix(report): newbuild-consistent district median + obj_class dedup (#1953)
FIX A (#1955) «Что хорошо продаётся»: убран фантомный класс 'Comfort'. - Миграция 172: v_bucket_success_score COALESCE(obj_class,'Comfort') → COALESCE(obj_class,'не указан'). Английский литерал заполнял 397 NULL и сливался отдельным классом от русского 'Комфорт' → визуальные дубли бакетов в UI. Источник уже канонически-русский (проверено на проде), synonym-mapping не нужен. - parcels.py: obj_class протаскивается в success-ranking query + dict. - TS SuccessRankingBucket.obj_class добавлен. FIX B (#1960) «Медиана рынка» = 64k (квартальная росреестровская n=1 ДКП): - district.median_price_per_m2 больше не COALESCE(median_12m, ekb_ref) в SQL. Basis-приоритет (newbuild-first): Objective по имени района → geo_radius (Objective в 3км) → ekb_districts reference → квартальная росреестровская медиана ТОЛЬКО при deals_count≥5. Для 66:41:0205010:287: 64k → 132690 (geo_radius, newbuild-consistent). - median_price_basis добавлен в payload + TS type (nullable median). - Frontend null-guards для нового nullable median. Tests: +4 (geo_radius basis, objective-приоритет, deals-guard, obj_class passthrough); обновлены district-моки в 9 analyze-тестах под новую SQL-сигнатуру.
This commit is contained in:
parent
ebdbedcabb
commit
453a1f08da
14 changed files with 464 additions and 61 deletions
|
|
@ -153,6 +153,12 @@ _GEO_PRICE_RADIUS_M: float = 3000.0 # 3 км — городской радиу
|
|||
_GEO_PRICE_MIN_LOTS: int = 10
|
||||
_GEO_PRICE_MIN_COMPLEXES: int = 2
|
||||
|
||||
# #1960 «Медиана рынка»: минимум сделок квартальной росреестровской MV
|
||||
# (mv_quarter_price_per_m2.deals_count, окно 24 мес), чтобы её медиана вообще
|
||||
# могла служить последним fallback'ом для карточки district.median_price_per_m2.
|
||||
# Тонкие кварталы (1 ДКП → 64k) больше не «выигрывают» у newbuild-basis.
|
||||
_QUARTER_MEDIAN_MIN_DEALS: int = 5
|
||||
|
||||
# Эмпирические пороги score для ЕКБ: средний диапазон 15-30, max редко >40.
|
||||
SCORE_THRESHOLDS: dict[str, float] = {"плохо": 5.0, "средне": 15.0, "хорошо": 25.0, "отлично": 40.0}
|
||||
SCORE_MAX_REFERENCE: float = 40.0
|
||||
|
|
@ -1646,13 +1652,21 @@ def analyze_parcel(
|
|||
geom_wkt: str = geom_row["wkt"] # type: ignore[index]
|
||||
|
||||
# 2) District context — ближайший район ЕКБ
|
||||
# median_price_per_m2: предпочитаем median_12m из mv_quarter_price_per_m2 (12 мес),
|
||||
# fallback на ekb_districts.median_price_per_m2 (24 мес).
|
||||
district_row = (
|
||||
# #1960: НЕ COALESCE'им median_12m в SQL. Раньше карточка «Медиана рынка» брала
|
||||
# median_12m из mv_quarter_price_per_m2 — это РОСРЕЕСТРОВСКАЯ квартальная медиана
|
||||
# (ДКП), которая для тонких кварталов считается по 1 сделке (=64k для
|
||||
# 66:41:0205010:287) и противоречит остальной странице, построенной на NEWBUILD
|
||||
# Objective/DOM.РФ basis (geo_radius ≈132k). Поэтому возвращаем компоненты по
|
||||
# отдельности — финальный выбор basis делается в Python (см. блок «#1960» ниже),
|
||||
# где доступны district_price_block (Objective по имени района) и geo_radius_price.
|
||||
# ekb_districts.median_price_per_m2 — справочный DDU-fallback (24 мес).
|
||||
district_row_raw = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT d.district_name,
|
||||
COALESCE(mq.median_12m, d.median_price_per_m2) AS median_price_per_m2,
|
||||
d.median_price_per_m2 AS ekb_reference_median,
|
||||
mq.median_12m AS quarter_median_12m,
|
||||
mq.deals_count AS quarter_deals_count,
|
||||
ST_Distance(
|
||||
d.geom::geography,
|
||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||
|
|
@ -1674,6 +1688,19 @@ def analyze_parcel(
|
|||
.mappings()
|
||||
.first()
|
||||
)
|
||||
# district_row сохраняет тот же контракт, что и раньше (district_name,
|
||||
# median_price_per_m2, dist_to_center). median_price_per_m2 пока = справочный
|
||||
# ekb-fallback; финальный newbuild-consistent basis проставляется ниже (#1960),
|
||||
# когда посчитаны district_price_block + geo_radius_price.
|
||||
district_row: dict[str, Any] | None
|
||||
if district_row_raw is not None:
|
||||
district_row = {
|
||||
"district_name": district_row_raw["district_name"],
|
||||
"median_price_per_m2": district_row_raw["ekb_reference_median"],
|
||||
"dist_to_center": district_row_raw["dist_to_center"],
|
||||
}
|
||||
else:
|
||||
district_row = None
|
||||
|
||||
# 3) POI в радиусе 1 км — список с distance_m (straight-line, ST_Distance).
|
||||
# В dict, а не RowMapping (read-only) — чтобы при включённом OSRM (#39 A2)
|
||||
|
|
@ -2761,7 +2788,8 @@ def analyze_parcel(
|
|||
success_rows = (
|
||||
db.execute(
|
||||
text("""
|
||||
SELECT bucket, success_score, n_deals, avg_price_per_m2, avg_area_m2,
|
||||
SELECT bucket, obj_class, success_score, n_deals,
|
||||
avg_price_per_m2, avg_area_m2,
|
||||
velocity_z, price_z, area_z
|
||||
FROM v_bucket_success_score
|
||||
WHERE district_name = :dn
|
||||
|
|
@ -2784,6 +2812,10 @@ def analyze_parcel(
|
|||
"ranking": [
|
||||
{
|
||||
"bucket": r["bucket"],
|
||||
# #1955: obj_class теперь различает строки с одинаковым
|
||||
# area-label (Комфорт vs Типовой vs «не указан») — убирает
|
||||
# визуальные дубли бакетов и фантом english 'Comfort'.
|
||||
"obj_class": r["obj_class"],
|
||||
"success_score": round(float(r["success_score"]), 2),
|
||||
"n_deals": int(r["n_deals"]),
|
||||
"avg_price_per_m2": (
|
||||
|
|
@ -3290,6 +3322,50 @@ def analyze_parcel(
|
|||
logger.warning("financial_estimate bridge failed for %s: %s", cad_num, e)
|
||||
financial_estimate = None
|
||||
|
||||
# #1960: карточка «Медиана рынка» (district.median_price_per_m2) должна быть
|
||||
# КОНСИСТЕНТНА с остальной страницей, построенной на NEWBUILD Objective/DOM.РФ
|
||||
# basis (geo_radius_price.median, market_avg ≈197k), а не на загрязнённой
|
||||
# квартальной росреестровской ДКП-медиане (median_12m, для 66:41:0205010:287 = 64k
|
||||
# по 1 сделке). Раньше SQL делал COALESCE(median_12m, ekb_reference) → выигрывала
|
||||
# тонкая квартальная медиана.
|
||||
#
|
||||
# Basis-приоритет (newbuild-first, с устойчивыми fallback'ами):
|
||||
# 1. district_price_block.district_price_per_m2_median — Objective-лоты по ИМЕНИ
|
||||
# района (objective_lots.district). NULL для 5/9 районов ЕКБ без name-match.
|
||||
# 2. geo_radius_price.median — Objective-лоты в 3 км радиусе вокруг центроида
|
||||
# (n≥10 лотов, ≥2 ЖК). Закрывает name-match-пробел; для 66:41:0205010:287 ≈132k.
|
||||
# Это ТОТ ЖЕ источник, что и market_pulse/financial_estimate → консистентно.
|
||||
# 3. ekb_districts.median_price_per_m2 — справочная DDU-медиана района (24 мес).
|
||||
# 4. mv_quarter_price_per_m2.median_12m — росреестровская квартальная медиана,
|
||||
# ТОЛЬКО если deals_count ≥ _QUARTER_MEDIAN_MIN_DEALS (5). Тонкие кварталы
|
||||
# (1 ДКП → 64k) сюда не проходят и медиана честно остаётся None.
|
||||
if district_row is not None:
|
||||
_newbuild_basis: float | None = None
|
||||
_basis_source = "none"
|
||||
_dpm = district_price_block.get("district_price_per_m2_median")
|
||||
_geo_m = geo_radius_price.get("median")
|
||||
_ekb_ref = district_row_raw["ekb_reference_median"] if district_row_raw else None
|
||||
_q_median = district_row_raw["quarter_median_12m"] if district_row_raw else None
|
||||
_q_deals = district_row_raw["quarter_deals_count"] if district_row_raw else None
|
||||
if _dpm is not None:
|
||||
_newbuild_basis = float(_dpm)
|
||||
_basis_source = "objective_district"
|
||||
elif _geo_m is not None:
|
||||
_newbuild_basis = float(_geo_m)
|
||||
_basis_source = "objective_geo_radius"
|
||||
elif _ekb_ref is not None:
|
||||
_newbuild_basis = float(_ekb_ref)
|
||||
_basis_source = "ekb_districts_reference"
|
||||
elif (
|
||||
_q_median is not None
|
||||
and _q_deals is not None
|
||||
and (int(_q_deals) >= _QUARTER_MEDIAN_MIN_DEALS)
|
||||
):
|
||||
_newbuild_basis = float(_q_median)
|
||||
_basis_source = "quarter_rosreestr"
|
||||
district_row["median_price_per_m2"] = _newbuild_basis
|
||||
district_row["median_price_basis"] = _basis_source
|
||||
|
||||
result_payload: dict[str, Any] = {
|
||||
"cad_num": cad_num,
|
||||
"source": source,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ def _make_db_for_analyze(
|
|||
_make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
@ -91,7 +93,7 @@ def _make_db_for_analyze(
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
|
|||
|
|
@ -53,11 +53,19 @@ def _make_db_for_analyze(
|
|||
geom_found: bool = True,
|
||||
district_found: bool = True,
|
||||
market_price_row: dict[str, Any] | None = None,
|
||||
district_row_override: dict[str, Any] | None = None,
|
||||
success_rows: list[dict[str, Any]] | None = None,
|
||||
geo_radius_row: dict[str, Any] | None = None,
|
||||
district_price_row: dict[str, Any] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Сконструировать mock DB Session для analyze_parcel.
|
||||
|
||||
market_price_row=None → имитирует "нет данных в MV" (mp_row is None → source='no_data').
|
||||
market_price_row={...} → имитирует найденную строку в MV.
|
||||
district_row_override → переопределяет raw-district mapping (#1960 basis chain).
|
||||
success_rows → строки v_bucket_success_score (#1955 obj_class passthrough).
|
||||
geo_radius_row → строка geo_radius_price (objective lots в радиусе, #1960 basis #2).
|
||||
district_price_row → строка district_price_block (objective по имени, #1960 basis #1).
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
|
|
@ -67,20 +75,20 @@ def _make_db_for_analyze(
|
|||
else None
|
||||
)
|
||||
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||||
district_row = (
|
||||
_make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
if district_found
|
||||
else None
|
||||
)
|
||||
_district_data = district_row_override or {
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
district_row = _make_mapping(_district_data) if district_found else None
|
||||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||
|
||||
mp_mock = _make_mapping(market_price_row) if market_price_row is not None else None
|
||||
success_mocks = [_make_mapping(r) for r in success_rows] if success_rows is not None else []
|
||||
geo_mock = _make_mapping(geo_radius_row) if geo_radius_row is not None else None
|
||||
dp_mock = _make_mapping(district_price_row) if district_price_row is not None else None
|
||||
|
||||
def _execute_side_effect(*args: Any, **kwargs: Any) -> MagicMock:
|
||||
# Нормализуем SQL для сигнатурного матчинга (collapse whitespace).
|
||||
|
|
@ -94,13 +102,22 @@ def _make_db_for_analyze(
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
# market-price (#33) — уникальная сигнатура трёх скользящих медиан.
|
||||
elif "median_6m" in sql and "median_12m" in sql and "median_24m" in sql:
|
||||
first_val = mp_mock
|
||||
# #1955: success-ranking из v_bucket_success_score.
|
||||
elif "v_bucket_success_score" in sql:
|
||||
all_val = success_mocks
|
||||
# #1960 basis #1: district_price_block (Objective по имени района).
|
||||
elif "AS sample_size" in sql and "objective_lots" in sql:
|
||||
first_val = dp_mock
|
||||
# #1960 basis #2: geo_radius_price (Objective в радиусе).
|
||||
elif "AS n_complexes" in sql and "objective_lots" in sql:
|
||||
first_val = geo_mock
|
||||
|
||||
r = MagicMock()
|
||||
r.mappings.return_value.first.return_value = first_val
|
||||
|
|
@ -254,3 +271,170 @@ def test_market_price_invalid_cad_returns_404() -> None:
|
|||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
# ── #1960: «Медиана рынка» = newbuild-consistent basis ──────────────────────────
|
||||
|
||||
|
||||
def test_district_median_uses_geo_radius_not_quarter_rosreestr() -> None:
|
||||
"""#1960: при тонкой росреестровской квартальной медиане (deals<5) карточка
|
||||
district.median_price_per_m2 берёт newbuild geo_radius медиану, НЕ 64k.
|
||||
|
||||
Репродукция бага для 66:41:0205010:287: quarter_median_12m=63694 (1 ДКП),
|
||||
geo_radius медиана=132690. Objective-по-имени отсутствует (name-match gap) →
|
||||
basis должен упасть на geo_radius_price.
|
||||
"""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze(
|
||||
district_row_override={
|
||||
"district_name": "Железнодорожный",
|
||||
"ekb_reference_median": 69687,
|
||||
"quarter_median_12m": 63694, # загрязнённая росреестровская квартальная
|
||||
"quarter_deals_count": 6, # 24-мес окно ≥5, но 12-мес — тонкое
|
||||
"dist_to_center": 0.0,
|
||||
},
|
||||
# Objective по имени района отсутствует (5/9 районов ЕКБ без name-match).
|
||||
district_price_row=None,
|
||||
# geo_radius — newbuild basis (что и остальная страница).
|
||||
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
district = body["district"]
|
||||
assert district["median_price_basis"] == "objective_geo_radius"
|
||||
assert district["median_price_per_m2"] == pytest.approx(132690.0)
|
||||
# КЛЮЧЕВОЕ: загрязнённая 64k росреестровская медиана НЕ выигрывает.
|
||||
assert district["median_price_per_m2"] != pytest.approx(63694.0)
|
||||
# И значение в newbuild-диапазоне (~130-200k).
|
||||
assert 130000 <= district["median_price_per_m2"] <= 200000
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_district_median_prefers_objective_district_when_available() -> None:
|
||||
"""#1960: если есть Objective-медиана по имени района — она имеет приоритет
|
||||
над geo_radius и квартальной росреестровской."""
|
||||
from app.core.db import get_db
|
||||
|
||||
db = _make_db_for_analyze(
|
||||
district_row_override={
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": 64000,
|
||||
"quarter_deals_count": 6,
|
||||
"dist_to_center": 1500.0,
|
||||
},
|
||||
district_price_row={
|
||||
"price_min": 90000,
|
||||
"price_max": 250000,
|
||||
"price_median": 165000,
|
||||
"sample_size": 42,
|
||||
},
|
||||
geo_radius_row={"median": 132690.0, "n": 12085, "n_complexes": 14},
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
district = resp.json()["district"]
|
||||
assert district["median_price_basis"] == "objective_district"
|
||||
assert district["median_price_per_m2"] == pytest.approx(165000.0)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
def test_district_median_quarter_rosreestr_gated_by_min_deals() -> None:
|
||||
"""#1960: квартальная росреестровская медиана может стать basis ТОЛЬКО как
|
||||
последний fallback и ТОЛЬКО при deals_count ≥ 5. Тонкий квартал (deals<5)
|
||||
→ median None (не показываем загрязнённое значение)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
# Нет Objective-данных вообще, нет ekb_reference, тонкий квартал (1 сделка).
|
||||
db = _make_db_for_analyze(
|
||||
district_row_override={
|
||||
"district_name": "Глухой",
|
||||
"ekb_reference_median": None,
|
||||
"quarter_median_12m": 64000,
|
||||
"quarter_deals_count": 1, # < 5 → не проходит guard
|
||||
"dist_to_center": 3000.0,
|
||||
},
|
||||
district_price_row=None,
|
||||
geo_radius_row=None,
|
||||
)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
district = resp.json()["district"]
|
||||
assert district["median_price_basis"] == "none"
|
||||
assert district["median_price_per_m2"] is None
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
||||
|
||||
# ── #1955: obj_class passthrough в success_recommendation ────────────────────────
|
||||
|
||||
|
||||
def test_success_recommendation_carries_obj_class() -> None:
|
||||
"""#1955: ranking-строки несут obj_class — UI может различать строки с
|
||||
одинаковым area-label (Комфорт vs Типовой vs «не указан»)."""
|
||||
from app.core.db import get_db
|
||||
|
||||
success = [
|
||||
{
|
||||
"bucket": "Студии 15-30",
|
||||
"obj_class": "Комфорт",
|
||||
"success_score": 1.2,
|
||||
"n_deals": 4857,
|
||||
"avg_price_per_m2": 194000,
|
||||
"avg_area_m2": 25.0,
|
||||
"velocity_z": 0.5,
|
||||
"price_z": 0.3,
|
||||
"area_z": -0.2,
|
||||
},
|
||||
{
|
||||
"bucket": "Студии 15-30",
|
||||
"obj_class": "не указан",
|
||||
"success_score": 0.4,
|
||||
"n_deals": 165,
|
||||
"avg_price_per_m2": 142000,
|
||||
"avg_area_m2": 26.0,
|
||||
"velocity_z": 0.1,
|
||||
"price_z": -0.1,
|
||||
"area_z": 0.0,
|
||||
},
|
||||
]
|
||||
db = _make_db_for_analyze(success_rows=success)
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
_start_patches()
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
rec = resp.json()["success_recommendation"]
|
||||
assert rec is not None
|
||||
ranking = rec["ranking"]
|
||||
assert len(ranking) == 2
|
||||
assert ranking[0]["obj_class"] == "Комфорт"
|
||||
assert ranking[1]["obj_class"] == "не указан"
|
||||
# Один area-label, но разные классы → больше не визуально-дубль.
|
||||
assert ranking[0]["bucket"] == ranking[1]["bucket"]
|
||||
assert ranking[0]["obj_class"] != ranking[1]["obj_class"]
|
||||
# И никакого английского фантома 'Comfort'.
|
||||
assert all(r["obj_class"] != "Comfort" for r in ranking)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
_stop_patches()
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ def _make_mapping(data: dict[str, Any]) -> MagicMock:
|
|||
def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
|
||||
db = MagicMock()
|
||||
|
||||
geom_row = _make_mapping(
|
||||
{"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"}
|
||||
)
|
||||
geom_row = _make_mapping({"geom_geojson": _GEOJSON, "geom_wkb": None, "source": "cad_quarter"})
|
||||
wkt_row = _make_mapping({"wkt": _WKT})
|
||||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
@ -63,7 +63,7 @@ def _make_db_for_analyze(poi_rows: list[Any]) -> MagicMock:
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
# И centroid_row для блока 6, и OSRM-origin SELECT (тот же shape).
|
||||
|
|
@ -170,9 +170,7 @@ def test_flag_on_osrm_replaces_distance(monkeypatch) -> None:
|
|||
monkeypatch.setattr(settings, "use_osrm_distances", True)
|
||||
_start_patches()
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.parcels.get_road_distances_m", return_value=[700.0]
|
||||
) as mock_osrm:
|
||||
with patch("app.api.v1.parcels.get_road_distances_m", return_value=[700.0]) as mock_osrm:
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ def _make_db_for_analyze(
|
|||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@ def _make_db_for_analyze(
|
|||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
@ -83,7 +85,7 @@ def _make_db_for_analyze(
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
district_row = _make_mapping(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -259,7 +259,9 @@ def _make_db_for_analyze() -> MagicMock:
|
|||
district_row = _make_mapping_analyze(
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"median_price_per_m2": 120000,
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -77,7 +77,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
)
|
||||
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||||
district_row = _make_mapping(
|
||||
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||
|
||||
|
|
@ -88,7 +94,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
@ -157,9 +163,7 @@ def test_horizon_default_12_and_enqueued() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -186,9 +190,7 @@ def test_horizon_valid_values_accepted() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
|
||||
assert resp.status_code == 200, f"horizon={h}: {resp.text}"
|
||||
|
|
@ -213,9 +215,7 @@ def test_horizon_invalid_returns_422() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze?horizon={h}")
|
||||
assert resp.status_code == 422, f"horizon={h}: {resp.text}"
|
||||
|
|
@ -234,9 +234,7 @@ def test_enqueue_passes_created_by_header() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock()
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
f"/api/v1/parcels/{_CAD}/analyze",
|
||||
|
|
@ -261,9 +259,7 @@ def test_enqueue_failure_returns_200_unavailable() -> None:
|
|||
_start_patches()
|
||||
delay_mock = MagicMock(side_effect=RuntimeError("redis down"))
|
||||
try:
|
||||
with patch(
|
||||
"app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock
|
||||
):
|
||||
with patch("app.workers.tasks.forecast.forecast_site_finder_report.delay", delay_mock):
|
||||
client = TestClient(app)
|
||||
resp = client.post(f"/api/v1/parcels/{_CAD}/analyze")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -314,9 +310,7 @@ def test_get_forecast_ready_when_run_present() -> None:
|
|||
try:
|
||||
# 1-й вызов — §22-ран ("1.0"); 2-й — analyze-ран для gate_caveat (#1740),
|
||||
# здесь None (gate не применяется).
|
||||
with patch(
|
||||
"app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]
|
||||
) as lrf:
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=[fake_run, None]) as lrf:
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
|
@ -354,9 +348,7 @@ def test_get_forecast_db_error_returns_pending_not_500() -> None:
|
|||
db = MagicMock()
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
with patch(
|
||||
"app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")
|
||||
):
|
||||
with patch("app.api.v1.parcels.latest_run_for", side_effect=RuntimeError("db gone")):
|
||||
client = TestClient(app)
|
||||
resp = client.get(f"/api/v1/parcels/{_CAD}/forecast")
|
||||
assert resp.status_code == 202, resp.text
|
||||
|
|
|
|||
|
|
@ -54,7 +54,13 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
)
|
||||
wkt_row = _make_mapping({"wkt": _WKT}) if geom_found else None
|
||||
district_row = _make_mapping(
|
||||
{"district_name": "Октябрьский", "median_price_per_m2": 120000, "dist_to_center": 1500.0}
|
||||
{
|
||||
"district_name": "Октябрьский",
|
||||
"ekb_reference_median": 120000,
|
||||
"quarter_median_12m": None,
|
||||
"quarter_deals_count": 0,
|
||||
"dist_to_center": 1500.0,
|
||||
}
|
||||
)
|
||||
centroid_row = _make_mapping({"lat": 56.84, "lon": 60.605})
|
||||
|
||||
|
|
@ -65,7 +71,7 @@ def _make_db_for_analyze(geom_found: bool = True) -> MagicMock:
|
|||
first_val = geom_row
|
||||
elif "AS wkt" in sql:
|
||||
first_val = wkt_row
|
||||
elif "AS median_price_per_m2" in sql and "district_name" in sql:
|
||||
elif "AS ekb_reference_median" in sql and "district_name" in sql:
|
||||
first_val = district_row
|
||||
elif "AS lon" in sql and "AS lat" in sql:
|
||||
first_val = centroid_row
|
||||
|
|
|
|||
124
data/sql/172_fix_v_bucket_success_score_obj_class.sql
Normal file
124
data/sql/172_fix_v_bucket_success_score_obj_class.sql
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
-- 172_fix_v_bucket_success_score_obj_class.sql
|
||||
-- Issue #1955 (audit epic #1953) — «Что хорошо продаётся»: дубли бакетов + фантом Comfort.
|
||||
--
|
||||
-- Проблема:
|
||||
-- Старый view (86_v_bucket_success_score.sql) использовал
|
||||
-- `COALESCE(o.obj_class, 'Comfort')` — АНГЛИЙСКИЙ литерал, которым заполнялись
|
||||
-- NULL-классы (на проде: 397 объектов с obj_class IS NULL, регион 66). В итоге
|
||||
-- эти строки сливались в фантомный класс 'Comfort', отдельный от реального
|
||||
-- русского 'Комфорт' (723 объекта). В UI таблица проецирует только bucket-label,
|
||||
-- поэтому строки разных obj_class с одинаковым area-label визуально дублировались
|
||||
-- («Студии 15-30» = Комфорт 8256 И Comfort 150; «1-к 30-45» = Типовой И Бизнес).
|
||||
--
|
||||
-- Фикс:
|
||||
-- 1. NULL obj_class → честный `'не указан'` (НЕ домысливаем comfort).
|
||||
-- 2. obj_class уже присутствует в выходных колонках (был и раньше) — backend
|
||||
-- теперь протаскивает его в API (parcels.py success-ranking), чтобы UI мог
|
||||
-- различать классы и не схлопывать строки.
|
||||
--
|
||||
-- Нормализация синонимов: проверены distinct obj_class на проде (регион 66, latest
|
||||
-- snapshot per obj): только канонические русские значения —
|
||||
-- 'Комфорт' (723), NULL (397), 'Типовой' (105), 'Бизнес' (84), 'Элит' (7),
|
||||
-- 'Премиум' (6). Английских/нестандартных литералов в ИСТОЧНИКЕ нет (фантом
|
||||
-- 'Comfort' рождался ИСКЛЮЧИТЕЛЬНО из COALESCE). Поэтому synonym-mapping не нужен —
|
||||
-- замена COALESCE-литерала полностью решает проблему. Если в будущем появятся
|
||||
-- реальные не-канонические значения (Comfort/Economy/Business/...) — добавить CASE
|
||||
-- normalization здесь.
|
||||
--
|
||||
-- Dependencies:
|
||||
-- domrf_kn_objects (obj_id, district_name, obj_class, region_cd, snapshot_date),
|
||||
-- domrf_kn_flats, domrf_kn_sale_graph.
|
||||
-- Idempotent: CREATE OR REPLACE VIEW (структура колонок не меняется — backend-safe).
|
||||
-- Deploy: auto-applied by deploy.yml via _schema_migrations tracking.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE VIEW v_bucket_success_score AS
|
||||
WITH
|
||||
-- Latest snapshot per object (both tables are time-series)
|
||||
latest_obj AS (
|
||||
SELECT DISTINCT ON (obj_id)
|
||||
obj_id, district_name, obj_class, region_cd
|
||||
FROM domrf_kn_objects
|
||||
WHERE district_name IS NOT NULL
|
||||
ORDER BY obj_id, snapshot_date DESC
|
||||
),
|
||||
|
||||
bucket_aggs AS (
|
||||
SELECT
|
||||
o.district_name,
|
||||
-- #1955: NULL obj_class → честный 'не указан' (НЕ английский 'Comfort').
|
||||
-- Это убирает фантомный класс и сливание с реальным русским 'Комфорт'.
|
||||
COALESCE(o.obj_class, 'не указан') AS obj_class,
|
||||
CASE
|
||||
WHEN f.rooms = 0 OR f.total_area < 30 THEN 'Студии 15-30'
|
||||
WHEN f.total_area < 45 THEN '1-к 30-45'
|
||||
WHEN f.total_area < 60 THEN '2-к 45-60'
|
||||
WHEN f.total_area < 80 THEN '3-к 60-80'
|
||||
ELSE '80+ м²'
|
||||
END AS bucket,
|
||||
AVG(NULLIF(f.price_per_m2, 0)) AS avg_price_per_m2,
|
||||
AVG(NULLIF(f.total_area, 0)) AS avg_area_m2,
|
||||
COUNT(*) AS n_deals,
|
||||
-- Velocity proxy: avg monthly contracted units per object, over last 24 months
|
||||
AVG(
|
||||
(SELECT AVG(sg.contracted)
|
||||
FROM domrf_kn_sale_graph sg
|
||||
WHERE sg.obj_id = o.obj_id
|
||||
AND sg.report_month > NOW() - INTERVAL '24 months')
|
||||
) AS avg_velocity_per_month
|
||||
FROM domrf_kn_flats f
|
||||
JOIN latest_obj o ON o.obj_id = f.obj_id
|
||||
WHERE f.total_area BETWEEN 15 AND 200
|
||||
AND f.price_per_m2 BETWEEN 30000 AND 500000
|
||||
AND o.region_cd = 66
|
||||
AND f.snapshot_date > CURRENT_DATE - INTERVAL '24 months'
|
||||
GROUP BY 1, 2, 3
|
||||
HAVING COUNT(*) >= 15 -- мин 15: weak-confidence (15-29), strong (≥30)
|
||||
),
|
||||
|
||||
z_scores AS (
|
||||
SELECT
|
||||
district_name, obj_class, bucket,
|
||||
n_deals, avg_price_per_m2, avg_area_m2, avg_velocity_per_month,
|
||||
-- Z-score нормированный по группе (district × class)
|
||||
(avg_velocity_per_month
|
||||
- AVG(avg_velocity_per_month) OVER (PARTITION BY district_name, obj_class))
|
||||
/ NULLIF(STDDEV(avg_velocity_per_month) OVER (PARTITION BY district_name, obj_class), 0)
|
||||
AS velocity_z,
|
||||
(avg_price_per_m2
|
||||
- AVG(avg_price_per_m2) OVER (PARTITION BY district_name, obj_class))
|
||||
/ NULLIF(STDDEV(avg_price_per_m2) OVER (PARTITION BY district_name, obj_class), 0)
|
||||
AS price_z,
|
||||
(avg_area_m2
|
||||
- AVG(avg_area_m2) OVER (PARTITION BY district_name, obj_class))
|
||||
/ NULLIF(STDDEV(avg_area_m2) OVER (PARTITION BY district_name, obj_class), 0)
|
||||
AS area_z
|
||||
FROM bucket_aggs
|
||||
)
|
||||
|
||||
SELECT
|
||||
district_name,
|
||||
obj_class,
|
||||
bucket,
|
||||
n_deals,
|
||||
avg_price_per_m2,
|
||||
avg_area_m2,
|
||||
avg_velocity_per_month,
|
||||
COALESCE(velocity_z, 0) * 0.5
|
||||
+ COALESCE(price_z, 0) * 0.3
|
||||
- COALESCE(area_z, 0) * 0.2 AS success_score,
|
||||
COALESCE(velocity_z, 0) AS velocity_z,
|
||||
COALESCE(price_z, 0) AS price_z,
|
||||
COALESCE(area_z, 0) AS area_z
|
||||
FROM z_scores
|
||||
ORDER BY district_name, obj_class, success_score DESC;
|
||||
|
||||
COMMENT ON VIEW v_bucket_success_score IS
|
||||
'Success-driven bucket ranking per (district, class). '
|
||||
'Z-scores: velocity (+) приоритет, price (+) приоритет, area (-) приоритет. '
|
||||
'Min 15 сделок в группе (15-29 = weak confidence, ≥30 = strong). '
|
||||
'NULL obj_class → ''не указан'' (#1955: убран фантом ''Comfort''). '
|
||||
'Используется recommend_mix + analyze success_recommendation (issue #25, #1955).';
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -257,7 +257,9 @@ function SiteFinderContent() {
|
|||
// (≈0 внутри района), а не до центра города (#1414).
|
||||
const distToCenterKm = data?.location?.distance_to_center_km;
|
||||
const districtLabel = data?.district
|
||||
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` +
|
||||
? (data.district.median_price_per_m2 != null
|
||||
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²`
|
||||
: "нет данных") +
|
||||
(distToCenterKm != null
|
||||
? ` · ${distToCenterKm.toFixed(1)} км от центра`
|
||||
: "")
|
||||
|
|
|
|||
|
|
@ -76,9 +76,11 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
|
|||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||
Медиана:{" "}
|
||||
<strong style={{ color: "#374151" }}>
|
||||
{(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²
|
||||
{data.district.median_price_per_m2 != null
|
||||
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²`
|
||||
: "нет данных"}
|
||||
</strong>
|
||||
<span className="text-xs text-slate-500 ml-1">(12 мес)</span>
|
||||
<span className="text-xs text-slate-500 ml-1">(новостройки)</span>
|
||||
<span style={{ marginLeft: 12 }}>
|
||||
{(data.district.dist_to_center / 1000).toFixed(1)} км до центра
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,13 @@ export interface ParcelAnalysisCompetitor {
|
|||
|
||||
export interface ParcelAnalysisDistrict {
|
||||
district_name: string;
|
||||
median_price_per_m2: number;
|
||||
/** #1960: newbuild-consistent медиана цены района (Objective/DOM.РФ basis).
|
||||
* null, если ни один basis не разрешился. См. median_price_basis. */
|
||||
median_price_per_m2: number | null;
|
||||
/** #1960: какой источник дал median_price_per_m2 —
|
||||
* objective_district | objective_geo_radius | ekb_districts_reference |
|
||||
* quarter_rosreestr | none. */
|
||||
median_price_basis?: string;
|
||||
dist_to_center: number;
|
||||
}
|
||||
|
||||
|
|
@ -376,6 +382,9 @@ export interface GeometrySuitability {
|
|||
|
||||
export interface SuccessRankingBucket {
|
||||
bucket: string;
|
||||
/** #1955: класс ЖК ('Комфорт' / 'Типовой' / 'Бизнес' / 'не указан' / …) —
|
||||
* различает строки с одинаковым area-label, убирает визуальные дубли бакетов. */
|
||||
obj_class: string;
|
||||
success_score: number;
|
||||
n_deals: number;
|
||||
avg_price_per_m2: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue