From a08cc317c3e641e9ac697b05e224a287e32faa9e Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 11:15:17 +0500 Subject: [PATCH] =?UTF-8?q?fix(forecasting):=20resolve=20admin=20district?= =?UTF-8?q?=20to=20micros=20=D0=B2=20=5Fquery=5Fartificial=5Fdemand=20(#12?= =?UTF-8?q?05)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objective_lots.district хранит МИКРО-вокабуляр ('Уралмаш', 'ЖБИ', ...). _query_artificial_demand фильтровал сырым АДМИН-именем ('Кировский' с forecast.py:123) → ol.district='Кировский' = 0 строк → n_sold=0 → §25.5 Artificial Demand 'unavailable' с ложной причиной «нет проданных лотов» в каждом district-scoped отчёте. Тот же класс бага, что #1211 в _price_sensitivity. Patch: импорт resolve_objective_districts + замена сырого `ol.district = CAST(:district AS text)` на зеркальный паттерн sales_series._SOURCE_B_SQL / market_metrics._SALES_WINDOW_SQL: (CAST(:has_district AS boolean) IS FALSE OR ol.district = ANY(CAST(:districts AS text[]))) Сигнатура _query_artificial_demand / _build_artificial_demand НЕ меняется — caller остаётся admin-aware на входе. +5 новых тестов (TestArtificialDemandDistrictResolution: резолвер вызван, микро в bind, n_sold>0 после фикса), 6 обновлённых SQL-тестов. 21 passed artificial_demand + 1030 forecasting тестов зелёные. ruff clean. Closes #1205 --- .../services/forecasting/special_indices.py | 30 +++- .../forecasting/test_special_indices.py | 139 +++++++++++++++--- 2 files changed, 143 insertions(+), 26 deletions(-) diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py index 6272686f..251657a4 100644 --- a/backend/app/services/forecasting/special_indices.py +++ b/backend/app/services/forecasting/special_indices.py @@ -66,6 +66,7 @@ from app.services.forecasting.sales_series import ( ) from app.services.forecasting.what_to_build import rank_segments from app.services.site_finder.competitors import _CLASS_ORDER, _normalize_class, get_competitors +from app.services.site_finder.district_resolver import resolve_objective_districts from app.services.site_finder.market_metrics import compute_market_metrics from app.services.site_finder.own_portfolio import OwnProject, get_own_portfolio @@ -803,10 +804,15 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex: # Доля ипотечных сделок среди проданных лотов сегмента. Ипотечный признак = # encumbrance_type ЛИБО bank_name заполнены (Объектив пишет банк/тип обременения -# для кредитных сделок). Фильтры по district/class/premise_kind — все опциональны -# (CAST(:x AS text) IS NULL OR ...). «Продан» = is_sold IS TRUE ИЛИ contract_date -# заполнена (две независимые отметки сделки в objective_lots). psycopg v3: CAST, -# НИКОГДА :x::type. +# для кредитных сделок). Фильтры по district/class/premise_kind — все опциональны. +# District-фильтр: admin-имя (вход в caller) РАЗВОРАЧИВАЕТСЯ через +# `resolve_objective_districts` в набор МИКРО-районов (`ol.district` хранит +# микро-вокабуляр: 'Уралмаш', 'ЖБИ', ...; admin 'Кировский' там НЕ встречается → +# сырая фильтрация по admin-имени схлопывалась в 0 строк — #1205, тот же класс +# что #1211 в _price_sensitivity). Зеркало `sales_series._SOURCE_B_SQL` / +# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает +# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ +# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type. _ARTIFICIAL_DEMAND_SQL = text( """ WITH sold AS ( @@ -817,8 +823,8 @@ _ARTIFICIAL_DEMAND_SQL = text( WHERE ol.premise_kind = CAST(:premise_kind AS text) AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL) AND ( - CAST(:district AS text) IS NULL - OR ol.district = CAST(:district AS text) + CAST(:has_district AS boolean) IS FALSE + OR ol.district = ANY(CAST(:districts AS text[])) ) AND ( CAST(:obj_class AS text) IS NULL @@ -844,13 +850,23 @@ def _query_artificial_demand( Параметризовано (psycopg v3 CAST). Сбой/пустой результат → {n_sold:0,n_mortgage:0} (НЕ crash — выше преобразуется в деградацию-None). Детерминированно. + + District-фильтр: admin-имя ЕКБ → набор МИКРО-районов через + `resolve_objective_districts` (admin не встречается в `objective_lots.district` — + там микро-вокабуляр). None / unresolved → EKB-wide (без district-фильтра). + Лечит #1205 (silent-correctness: admin 'Кировский' → 0 строк → ложное + 'unavailable' в district-scoped отчётах). """ + micros = resolve_objective_districts(db, district) row = ( db.execute( _ARTIFICIAL_DEMAND_SQL, { "premise_kind": premise_kind, - "district": district, + # has_district=False → district-ветка фильтра отключена (EKB-wide). + "has_district": micros is not None, + # ANY(NULL::text[]) валиден; пустой список когда фильтра нет. + "districts": micros if micros is not None else [], "obj_class": obj_class, }, ) diff --git a/backend/tests/services/forecasting/test_special_indices.py b/backend/tests/services/forecasting/test_special_indices.py index 44de6a52..146ee422 100644 --- a/backend/tests/services/forecasting/test_special_indices.py +++ b/backend/tests/services/forecasting/test_special_indices.py @@ -886,55 +886,156 @@ def _executed_params(db: MagicMock, call_index: int = 0) -> dict[str, Any]: return args[1] +# Patch-таргет резолвера admin→micro (#1205 — fix зеркалит sales_series/market_metrics): +# `_query_artificial_demand` зовёт `resolve_objective_districts` ДО SQL. В shape/params +# тестах патчим резолвер identity-обёрткой (raw→[raw], None→None), чтобы _mock_db_one +# остался валиден (1 execute). Отдельные тесты переопределяют side_effect для проверки +# admin→micros разворота. +_RESOLVE_AD = f"{_MOD}.resolve_objective_districts" + + +@pytest.fixture +def _identity_resolver() -> Any: + return patch(_RESOLVE_AD, side_effect=lambda _db, d: [d] if d is not None else None) + + class TestArtificialDemandSQL: - def test_sql_uses_cast_not_double_colon(self) -> None: + def test_sql_uses_cast_not_double_colon(self, _identity_resolver: Any) -> None: db = _mock_db_one({"n_sold": 10, "n_mortgage": 4}) - _query_artificial_demand( - db, district="Академический", obj_class="комфорт", premise_kind="квартира" - ) + with _identity_resolver: + _query_artificial_demand( + db, district="Академический", obj_class="комфорт", premise_kind="квартира" + ) sql = _executed_sql(db) assert "CAST(:premise_kind AS text)" in sql - assert "CAST(:district AS text)" in sql + # #1205: district-фильтр теперь через has_district + districts (admin→micros резолв). + assert "CAST(:has_district AS boolean)" in sql + assert "CAST(:districts AS text[])" in sql assert "CAST(:obj_class AS text)" in sql # psycopg v3: никогда :x::type assert ":premise_kind::" not in sql - assert ":district::" not in sql + assert ":has_district::" not in sql + assert ":districts::" not in sql assert ":obj_class::" not in sql - def test_sql_reads_mortgage_signal_columns(self) -> None: + def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None: db = _mock_db_one({"n_sold": 10, "n_mortgage": 4}) - _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") + with _identity_resolver: + _query_artificial_demand( + db, district=None, obj_class=None, premise_kind="квартира" + ) sql = _executed_sql(db) # сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots). assert "encumbrance_type" in sql assert "bank_name" in sql assert "objective_lots" in sql - def test_params_passed(self) -> None: + def test_params_passed(self, _identity_resolver: Any) -> None: db = _mock_db_one({"n_sold": 1, "n_mortgage": 0}) - _query_artificial_demand( - db, district="Пионерский", obj_class="бизнес", premise_kind="квартира" - ) + with _identity_resolver: + _query_artificial_demand( + db, district="Пионерский", obj_class="бизнес", premise_kind="квартира" + ) p = _executed_params(db) - assert p["district"] == "Пионерский" + # #1205: identity-резолвер → districts=[district], has_district=True. + assert p["has_district"] is True + assert p["districts"] == ["Пионерский"] assert p["obj_class"] == "бизнес" assert p["premise_kind"] == "квартира" + # Старый сырый `district` bind больше НЕ передаётся (вокабуляр-фикс #1205). + assert "district" not in p - def test_signal_present_returns_counts(self) -> None: + def test_signal_present_returns_counts(self, _identity_resolver: Any) -> None: db = _mock_db_one({"n_sold": 80, "n_mortgage": 52}) - out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") + with _identity_resolver: + out = _query_artificial_demand( + db, district=None, obj_class=None, premise_kind="квартира" + ) assert out == {"n_sold": 80, "n_mortgage": 52} - def test_empty_row_degrades_to_zeros(self) -> None: + def test_empty_row_degrades_to_zeros(self, _identity_resolver: Any) -> None: db = _mock_db_one(None) - out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") + with _identity_resolver: + out = _query_artificial_demand( + db, district=None, obj_class=None, premise_kind="квартира" + ) assert out == {"n_sold": 0, "n_mortgage": 0} - def test_null_counts_coerced_to_zero(self) -> None: + def test_null_counts_coerced_to_zero(self, _identity_resolver: Any) -> None: db = _mock_db_one({"n_sold": None, "n_mortgage": None}) - out = _query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира") + with _identity_resolver: + out = _query_artificial_demand( + db, district=None, obj_class=None, premise_kind="квартира" + ) assert out == {"n_sold": 0, "n_mortgage": 0} + def test_none_district_disables_filter(self, _identity_resolver: Any) -> None: + # #1205: district=None → has_district=False, districts=[]. EKB-wide. + db = _mock_db_one({"n_sold": 200, "n_mortgage": 80}) + with _identity_resolver: + _query_artificial_demand( + db, district=None, obj_class=None, premise_kind="квартира" + ) + p = _executed_params(db) + assert p["has_district"] is False + assert p["districts"] == [] + + +class TestArtificialDemandDistrictResolution: + """#1205 — admin 'Кировский' разворачивается в МИКРО через resolve_objective_districts. + + `objective_lots.district` хранит микро-вокабуляр ('Уралмаш', 'ЖБИ', ...). Без + резолва SQL фильтровал ol.district = 'Кировский' → 0 строк → ложное 'unavailable' + в каждом district-scoped отчёте. Тот же класс бага, что #1211 (_price_sensitivity). + """ + + def test_admin_input_invokes_resolver(self) -> None: + # admin → resolver вызван с теми же db, district. + db = _mock_db_one({"n_sold": 50, "n_mortgage": 20}) + with patch(_RESOLVE_AD, return_value=["Уралмаш", "ЖБИ", "Эльмаш"]) as m_resolve: + _query_artificial_demand( + db, district="Кировский", obj_class=None, premise_kind="квартира" + ) + m_resolve.assert_called_once() + call_args, _ = m_resolve.call_args + assert call_args[0] is db + assert call_args[1] == "Кировский" + + def test_resolved_micros_land_in_sql_bind(self) -> None: + # Резолвнутые микро попадают в :districts; has_district=True. + db = _mock_db_one({"n_sold": 50, "n_mortgage": 20}) + with patch(_RESOLVE_AD, return_value=["Уралмаш", "ЖБИ", "Эльмаш"]): + _query_artificial_demand( + db, district="Кировский", obj_class=None, premise_kind="квартира" + ) + p = _executed_params(db) + assert p["has_district"] is True + assert p["districts"] == ["Уралмаш", "ЖБИ", "Эльмаш"] + # Сырое admin-имя НЕ протекает в SQL (regression-guard #1205). + assert "Кировский" not in p.get("districts", []) + + def test_admin_yields_nonzero_sold_after_resolve(self) -> None: + # Сценарий бага: admin 'Кировский' → раньше 0 строк (фильтр по admin-имени) → + # 'unavailable'. После фикса: резолвер развернул в микро, SQL вернул n_sold>0. + db = _mock_db_one({"n_sold": 120, "n_mortgage": 75}) + with patch(_RESOLVE_AD, return_value=["Уралмаш", "Эльмаш"]): + out = _query_artificial_demand( + db, district="Кировский", obj_class=None, premise_kind="квартира" + ) + assert out["n_sold"] == 120 + assert out["n_mortgage"] == 75 + + def test_unresolved_admin_degrades_to_ekb_wide(self) -> None: + # Резолвер вернул None (admin без чистых микро / 'не определён') → has_district=False. + db = _mock_db_one({"n_sold": 300, "n_mortgage": 110}) + with patch(_RESOLVE_AD, return_value=None): + _query_artificial_demand( + db, district="не определён", obj_class=None, premise_kind="квартира" + ) + p = _executed_params(db) + assert p["has_district"] is False + assert p["districts"] == [] + # ────────────────────────────────────────────────────────────────────────────── # Artificial-Demand builder via mocked rows: signal → index; no signal → None+caveat -- 2.45.3