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/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..74b4384e 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,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() 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/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) {