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_market_price.py b/backend/tests/api/v1/test_analyze_market_price.py index 74b4384e..af52d252 100644 --- a/backend/tests/api/v1/test_analyze_market_price.py +++ b/backend/tests/api/v1/test_analyze_market_price.py @@ -385,6 +385,37 @@ def test_district_median_quarter_rosreestr_gated_by_min_deals() -> None: _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 ──────────────────────── 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)"