diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 97271169..161e8f71 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -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, diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 0c87d6e3..4fe96955 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1814,8 +1814,7 @@ def _active_competitors_count( # #38: реальный obj_class в приоритете, иначе obj_class_fallback. if target_class: n = _q( - "AND district_name = :dn" - " AND COALESCE(obj_class, obj_class_fallback) = :cls", + "AND district_name = :dn" " AND COALESCE(obj_class, obj_class_fallback) = :cls", {"rc": region_code, "dn": district_name, "cls": target_class}, ) if n >= 2: @@ -2221,9 +2220,7 @@ def _competitors_two_dim( return 0, n, float(n), scope # #38: реальный obj_class в приоритете, иначе obj_class_fallback. - class_filter = ( - "AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else "" - ) + class_filter = "AND COALESCE(obj_class, obj_class_fallback) = :cls" if target_class else "" params: dict[str, Any] = { "rc": region_code, "dn": district_name, @@ -2291,6 +2288,17 @@ def _bucket_success_ranking( Возвращает список dict {bucket, success_score, n_deals, velocity_z, price_z, area_z}, sorted DESC by success_score. Пустой список если данных нет или district_name не передан. + + target_class ДОЛЖЕН быть в словаре БД (русский) — caller переводит через + _class_to_db_vocab. Default-класс (когда target_class is None) = 'Комфорт': + после миграции 172 view хранит только канонические русские классы + ('Комфорт'/'Типовой'/'Бизнес'/'Элит'/'Премиум'/'не указан'), английский + 'Comfort' больше не существует и матчил бы 0 строк (#1955). 'Комфорт' — + самый массовый класс ЕКБ (723 объекта) → разумный baseline для + «класс не указан в запросе». NULL-class строки ('не указан', бывшие + obj_class IS NULL) в default-путь НЕ попадают намеренно: для рекомендации + квартирографии лучше показать реальный массовый класс, чем агрегат «не + указан»; явный запрос с target_class='не указан' их при этом достанет. """ if not district_name: return [] @@ -2301,7 +2309,7 @@ def _bucket_success_ranking( 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') + AND obj_class = COALESCE(:cls, 'Комфорт') ORDER BY success_score DESC """ ), @@ -2742,8 +2750,11 @@ def recommend_mix( # #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) + # #25 Success-driven ranking из v_bucket_success_score. + # #1955: передаём target_class_db (русский словарь БД), НЕ сырой английский + # target_class — иначе obj_class = :cls молча даёт ноль строк ("Comfort" != + # "Комфорт"), success-boost тихо деградирует в []. + success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class_db) # 5b-3) Per-bucket project velocity at price_factor=1.0: # bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region. diff --git a/backend/tests/api/v1/test_analyze_inline_weights.py b/backend/tests/api/v1/test_analyze_inline_weights.py index e94f8491..ad7a2248 100644 --- a/backend/tests/api/v1/test_analyze_inline_weights.py +++ b/backend/tests/api/v1/test_analyze_inline_weights.py @@ -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 diff --git a/backend/tests/api/v1/test_analyze_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index f01176cb..af52d252 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -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,201 @@ 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() + + +def test_district_median_quarter_rosreestr_selected_as_last_resort() -> None: + """#1960: если ВСЕ newbuild-fallback'и None (нет Objective-по-имени, нет + geo_radius, нет ekb_reference), но квартальная росреестровская медиана имеет + deals_count ≥ 5 — она выбирается как basis (4-й fallback) с явным источником.""" + from app.core.db import get_db + + db = _make_db_for_analyze( + district_row_override={ + "district_name": "Глухой", + "ekb_reference_median": None, # нет справочной DDU-медианы + "quarter_median_12m": 88000, + "quarter_deals_count": 7, # ≥ 5 → проходит guard + "dist_to_center": 3000.0, + }, + district_price_row=None, # нет Objective по имени + geo_radius_row=None, # нет geo_radius + ) + 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"] == "quarter_rosreestr" + assert district["median_price_per_m2"] == pytest.approx(88000.0) + 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() diff --git a/backend/tests/api/v1/test_analyze_osrm_distances.py b/backend/tests/api/v1/test_analyze_osrm_distances.py index 47bcd01f..a7d91431 100644 --- a/backend/tests/api/v1/test_analyze_osrm_distances.py +++ b/backend/tests/api/v1/test_analyze_osrm_distances.py @@ -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 diff --git a/backend/tests/api/v1/test_analyze_parcel_meta.py b/backend/tests/api/v1/test_analyze_parcel_meta.py index 3991da1b..ab5c1869 100644 --- a/backend/tests/api/v1/test_analyze_parcel_meta.py +++ b/backend/tests/api/v1/test_analyze_parcel_meta.py @@ -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, } ) diff --git a/backend/tests/api/v1/test_analyze_recent_permits.py b/backend/tests/api/v1/test_analyze_recent_permits.py index 4bbef033..67ecfd34 100644 --- a/backend/tests/api/v1/test_analyze_recent_permits.py +++ b/backend/tests/api/v1/test_analyze_recent_permits.py @@ -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 diff --git a/backend/tests/api/v1/test_analyze_zoning_regulation.py b/backend/tests/api/v1/test_analyze_zoning_regulation.py index e0ddcbdd..09b0d116 100644 --- a/backend/tests/api/v1/test_analyze_zoning_regulation.py +++ b/backend/tests/api/v1/test_analyze_zoning_regulation.py @@ -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, } ) diff --git a/backend/tests/api/v1/test_custom_pois.py b/backend/tests/api/v1/test_custom_pois.py index 151728f0..3d9ae0f8 100644 --- a/backend/tests/api/v1/test_custom_pois.py +++ b/backend/tests/api/v1/test_custom_pois.py @@ -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, } ) diff --git a/backend/tests/api/v1/test_parcels_forecast.py b/backend/tests/api/v1/test_parcels_forecast.py index 607db44a..99fd76c1 100644 --- a/backend/tests/api/v1/test_parcels_forecast.py +++ b/backend/tests/api/v1/test_parcels_forecast.py @@ -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 diff --git a/backend/tests/api/v1/test_run_history_and_response_contract.py b/backend/tests/api/v1/test_run_history_and_response_contract.py index 06a5b6f3..cbbf5724 100644 --- a/backend/tests/api/v1/test_run_history_and_response_contract.py +++ b/backend/tests/api/v1/test_run_history_and_response_contract.py @@ -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 diff --git a/backend/tests/services/test_recommend_mix_velocity.py b/backend/tests/services/test_recommend_mix_velocity.py index 1a36345d..73ea6e0a 100644 --- a/backend/tests/services/test_recommend_mix_velocity.py +++ b/backend/tests/services/test_recommend_mix_velocity.py @@ -14,6 +14,7 @@ analytics_queries + прямые unit-тесты helper-функций. from __future__ import annotations +import contextlib from typing import Any from unittest.mock import MagicMock, patch @@ -36,9 +37,7 @@ _CITY_BUCKET_DEALS = { _TOTAL_DEALS = sum(_CITY_BUCKET_DEALS.values()) # 3800 -def _make_bucket_row( - bucket_id: str, deals: int, area_avg: float = 40.0 -) -> MagicMock: +def _make_bucket_row(bucket_id: str, deals: int, area_avg: float = 40.0) -> MagicMock: r = MagicMock() data = { "bucket": bucket_id, @@ -111,7 +110,9 @@ class TestVelocityBaselinePerBucket: ]: r = MagicMock() r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: { - "bucket_id": _bid, "median_pm": _med, "observations": _obs, + "bucket_id": _bid, + "median_pm": _med, + "observations": _obs, }[k] rows.append(r) db.execute.return_value.mappings.return_value.all.return_value = rows @@ -131,12 +132,14 @@ class TestVelocityBaselinePerBucket: db = MagicMock() rows = [] for bid, median_pm, obs in [ - ("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем - ("2-1-к", 5.0, 10), # OK + ("1-Студия", 3.0, 2), # < 3 наблюдений → пропускаем + ("2-1-к", 5.0, 10), # OK ]: r = MagicMock() r.__getitem__ = lambda self, k, _bid=bid, _med=median_pm, _obs=obs: { - "bucket_id": _bid, "median_pm": _med, "observations": _obs, + "bucket_id": _bid, + "median_pm": _med, + "observations": _obs, }[k] rows.append(r) db.execute.return_value.mappings.return_value.all.return_value = rows @@ -157,7 +160,9 @@ class TestVelocityBaselinePerBucket: for bid, obs in [("1-Студия", 1), ("2-1-к", 2)]: r = MagicMock() r.__getitem__ = lambda self, k, _bid=bid, _obs=obs: { - "bucket_id": _bid, "median_pm": 3.0, "observations": _obs, + "bucket_id": _bid, + "median_pm": 3.0, + "observations": _obs, }[k] rows.append(r) db.execute.return_value.mappings.return_value.all.return_value = rows @@ -208,7 +213,7 @@ class TestRosreestrFallbackPerBucketVelocity: def test_velocity_scales_with_competitor_count(self) -> None: """При большем n_comp velocity одного проекта меньше (делим рынок на больше ЖК).""" - v_few = 710 / 24 / 5 # 5 конкурентов + v_few = 710 / 24 / 5 # 5 конкурентов v_many = 710 / 24 / 15 # 15 конкурентов assert v_few > v_many assert v_few == pytest.approx(710 / 24 / 5, rel=0.001) @@ -241,30 +246,35 @@ def _make_full_mock_db(has_class_data: bool = False) -> MagicMock: "mean_price_per_m2": 112_000.0, }[k] - # Sequence для прямых db.execute calls - calls: list[MagicMock] = [] + # Сигнатурный диспетчер вместо позиционной последовательности — устойчив к + # доп. запросам class-пути (#1955: class_multiplier yandex_realty_zk + # выполняется ТОЛЬКО когда target_class задан → лишний db.execute, который + # ломал бы жёсткую нумерацию). + def _dispatch(stmt: Any, params: Any = None) -> MagicMock: + sql = " ".join(str(stmt).split()) + r = MagicMock() + # district_row + if "FROM ekb_districts" in sql and "district_name ILIKE" in sql: + r.mappings.return_value.first.return_value = dr + # has_class_data + elif "FROM domrf_kn_objects" in sql and "obj_class_fallback" in sql: + r.scalar.return_value = 1 if has_class_data else None + # class_multiplier (yandex_realty_zk) — только class-путь + elif "comfort_avg" in sql or "yandex_realty_zk" in sql: + cr = MagicMock() + cr.__getitem__ = lambda self, k: {"class_avg": None, "comfort_avg": None}[k] + r.mappings.return_value.first.return_value = cr + # comparables → пусто + elif "latest_agg" in sql or "domrf_kn_sales_agg" in sql: + r.mappings.return_value.all.return_value = [] + # city_median (любой оставшийся scalar) + else: + r.scalar.return_value = 110_000.0 + r.mappings.return_value.all.return_value = [] + r.mappings.return_value.first.return_value = None + return r - # 1) district_row - r1 = MagicMock() - r1.mappings.return_value.first.return_value = dr - calls.append(r1) - - # 2) city_median scalar - r2 = MagicMock() - r2.scalar.return_value = 110_000.0 - calls.append(r2) - - # 3) has_class_data scalar - r3 = MagicMock() - r3.scalar.return_value = 1 if has_class_data else None - calls.append(r3) - - # 4) comparables query → пустой - r4 = MagicMock() - r4.mappings.return_value.all.return_value = [] - calls.append(r4) - - db.execute.side_effect = calls + db.execute.side_effect = _dispatch return db @@ -276,6 +286,8 @@ def _run_recommend_mix_full( area_total_m2: float = 12_000.0, horizon_months: int | None = None, cad_num: str | None = None, + target_class: str | None = None, + patch_success_ranking: bool = True, ) -> dict[str, Any]: """Запускает recommend_mix с правильным набором моков. @@ -284,10 +296,16 @@ def _run_recommend_mix_full( horizon_months/cad_num — #982 forecast-overlay opt-in (по умолчанию None → живой микс БАЙТ-в-БАЙТ как раньше; существующие вызовы не затронуты). + + target_class — #1955: проверка перевода english→русский перед success-ranking. + has_class_data=True когда target_class задан, чтобы class-путь вернул строки. + + patch_success_ranking — если False, НЕ патчим _bucket_success_ranking (даём + реальной функции/внешнему spy отработать; #1955 translation-тест). """ from app.services.analytics_queries import recommend_mix - db = _make_full_mock_db() + db = _make_full_mock_db(has_class_data=target_class is not None) patches = [ patch(f"{_MOD}._bucket_distribution", return_value=_city_bucket_rows()), @@ -321,32 +339,19 @@ def _run_recommend_mix_full( ), patch(f"{_MOD}._current_mortgage_rate", return_value=(None, None)), patch(f"{_MOD}._noise_penalty_factor", return_value=(1.0, [])), - patch(f"{_MOD}._bucket_success_ranking", return_value=[]), patch(f"{_MOD}._recommend_data_last_updated", return_value=None), ] + if patch_success_ranking: + patches.append(patch(f"{_MOD}._bucket_success_ranking", return_value=[])) - with ( - patches[0], - patches[1], - patches[2], - patches[3], - patches[4], - patches[5], - patches[6], - patches[7], - patches[8], - patches[9], - patches[10], - patches[11], - patches[12], - patches[13], - patches[14], - ): + with contextlib.ExitStack() as stack: + for p in patches: + stack.enter_context(p) return recommend_mix( db, district_name="Ленинский", area_total_m2=area_total_m2, - target_class=None, + target_class=target_class, months_window=24, region_code=66, horizon_months=horizon_months, @@ -417,9 +422,9 @@ class TestRealisticSrokFallback: ) srok = result["summary"]["months_to_sellout_total"] assert srok is not None - assert lo <= srok <= hi, ( - f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]" - ) + assert ( + lo <= srok <= hi + ), f"n_comp={n_comp}, area={area}: срок {srok:.1f} вне [{lo}, {hi}]" def test_scope_has_n_competitors(self) -> None: """scope.n_competitors присутствует и равен district+class competitors.""" @@ -487,9 +492,10 @@ class TestPerBucketVelocityVariesByBucket: ) for b in result["buckets"]: assert "velocity_source" in b, f"Бакет '{b['bucket']}' не имеет velocity_source" - assert b["velocity_source"] in ("rosreestr_fallback", "objective_per_bucket"), ( - f"Неожиданное velocity_source='{b['velocity_source']}'" - ) + assert b["velocity_source"] in ( + "rosreestr_fallback", + "objective_per_bucket", + ), f"Неожиданное velocity_source='{b['velocity_source']}'" class TestObjectivePerBucketPath: @@ -516,9 +522,9 @@ class TestObjectivePerBucketPath: # Studio: macro_mult = sat_factor × trend_factor = 1.0 × 1.0 = 1.0 studio = bkt_map.get("Студии 15-30") assert studio is not None - assert studio["velocity_per_month"] == pytest.approx(3.5, rel=0.01), ( - f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5" - ) + assert studio["velocity_per_month"] == pytest.approx( + 3.5, rel=0.01 + ), f"Studio velocity={studio['velocity_per_month']:.3f}, ожидалось 3.5" assert studio.get("velocity_source") == "objective_per_bucket" def test_objective_velocities_vary(self) -> None: @@ -621,3 +627,96 @@ class TestForecastOverlayOptIn: assert result["scope"]["forecast"]["advisory"] is True assert "boom" in result["scope"]["forecast"]["error"] assert set(result.keys()) == {"scope", "buckets", "summary", "comparables"} + + +# ── #1955: _bucket_success_ranking использует русский default (НЕ english 'Comfort') + + +def _make_ranking_row(bucket: str, score: float, n: int) -> MagicMock: + r = MagicMock() + data = { + "bucket": bucket, + "success_score": score, + "n_deals": n, + "velocity_z": 0.1, + "price_z": 0.2, + "area_z": -0.1, + } + r.__getitem__ = lambda self, k: data[k] + return r + + +class TestBucketSuccessRankingDefault: + """#1955: после миграции 172 view хранит только русские классы — default + в _bucket_success_ranking ДОЛЖЕН быть 'Комфорт', не english 'Comfort' + (иначе obj_class = 'Comfort' матчит 0 строк → success-boost тихо пустеет).""" + + def test_default_class_is_russian_komfort_in_sql(self) -> None: + """SQL использует COALESCE(:cls, 'Комфорт'), НЕ 'Comfort'.""" + from app.services.analytics_queries import _bucket_success_ranking + + captured: dict[str, Any] = {} + + def _exec(stmt: Any, params: Any) -> MagicMock: + captured["sql"] = " ".join(str(stmt).split()) + captured["params"] = params + # Реальный запрос отрабатывает и возвращает строки (как было бы с + # русским default-классом в обновлённом view). + return _make_mapping_result([_make_ranking_row("1-к 30-45", 1.2, 1029)]) + + db = MagicMock() + db.execute.side_effect = _exec + + # target_class=None → должен сработать default-путь. + result = _bucket_success_ranking(db, "Железнодорожный", None) + + assert "'Комфорт'" in captured["sql"], captured["sql"] + assert "'Comfort'" not in captured["sql"], "english 'Comfort' default — баг #1955" + # Реальный запрос (не замокан в []) вернул непустой ranking. + assert result, "success_ranking пуст — default-класс не матчит view (#1955)" + assert result[0]["bucket"] == "1-к 30-45" + assert result[0]["n_deals"] == 1029 + + def test_explicit_russian_class_passed_through(self) -> None: + """Явный русский класс уходит в bind как есть (caller уже перевёл).""" + from app.services.analytics_queries import _bucket_success_ranking + + captured: dict[str, Any] = {} + + def _exec(stmt: Any, params: Any) -> MagicMock: + captured["params"] = params + return _make_mapping_result([_make_ranking_row("Студии 15-30", 0.9, 4857)]) + + db = MagicMock() + db.execute.side_effect = _exec + + result = _bucket_success_ranking(db, "Академический", "Комфорт") + assert captured["params"]["cls"] == "Комфорт" + assert result and result[0]["n_deals"] == 4857 + + +class TestRecommendMixTranslatesClassForSuccessRanking: + """#1955: recommend_mix ДОЛЖЕН передавать переведённый русский класс + (target_class_db) в _bucket_success_ranking, а не сырой english target_class + из UI ('Comfort' != 'Комфорт' → 0 строк).""" + + def test_english_ui_class_translated_before_ranking_query(self) -> None: + from app.services.analytics_queries import _class_to_db_vocab + + captured: dict[str, Any] = {} + + def _spy_ranking(db: Any, dn: str | None, cls: str | None) -> list[dict]: + captured["cls"] = cls + return [] + + with patch(f"{_MOD}._bucket_success_ranking", side_effect=_spy_ranking): + # UI шлёт english 'Business' → recommend_mix должен передать русский 'Бизнес'. + # patch_success_ranking=False → helper НЕ перепатчивает наш spy на []. + _run_recommend_mix_full( + objective_per_bucket=None, + target_class="Business", + patch_success_ranking=False, + ) + + assert captured["cls"] == _class_to_db_vocab("Business") == "Бизнес" + assert captured["cls"] != "Business", "сырой english класс утёк в ranking-запрос (#1955)" diff --git a/data/sql/172_fix_v_bucket_success_score_obj_class.sql b/data/sql/172_fix_v_bucket_success_score_obj_class.sql new file mode 100644 index 00000000..8d73025a --- /dev/null +++ b/data/sql/172_fix_v_bucket_success_score_obj_class.sql @@ -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; diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx index 76288bd4..bd7144c7 100644 --- a/frontend/src/app/legacy/site-finder/page.tsx +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -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)} км от центра` : "") diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx index ab0f9030..a44ad8f5 100644 --- a/frontend/src/components/site-finder/OverviewTab.tsx +++ b/frontend/src/components/site-finder/OverviewTab.tsx @@ -76,9 +76,11 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
Медиана:{" "} - {(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)} тыс ₽/м²` + : "нет данных"} - (12 мес) + (новостройки) {(data.district.dist_to_center / 1000).toFixed(1)} км до центра diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 519017f5..ffdea0e1 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -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;