fix(forecasting): resolve admin district to micros в _query_artificial_demand (#1205)
Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled

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
This commit is contained in:
Light1YT 2026-06-13 11:15:17 +05:00 committed by bot-backend
parent 0fffc535cd
commit c48f45c48f
2 changed files with 143 additions and 26 deletions

View file

@ -66,6 +66,7 @@ from app.services.forecasting.sales_series import (
) )
from app.services.forecasting.what_to_build import rank_segments 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.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.market_metrics import compute_market_metrics
from app.services.site_finder.own_portfolio import OwnProject, get_own_portfolio 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 заполнены (Объектив пишет банк/тип обременения # encumbrance_type ЛИБО bank_name заполнены (Объектив пишет банк/тип обременения
# для кредитных сделок). Фильтры по district/class/premise_kind — все опциональны # для кредитных сделок). Фильтры по district/class/premise_kind — все опциональны.
# (CAST(:x AS text) IS NULL OR ...). «Продан» = is_sold IS TRUE ИЛИ contract_date # District-фильтр: admin-имя (вход в caller) РАЗВОРАЧИВАЕТСЯ через
# заполнена (две независимые отметки сделки в objective_lots). psycopg v3: CAST, # `resolve_objective_districts` в набор МИКРО-районов (`ol.district` хранит
# НИКОГДА :x::type. # микро-вокабуляр: 'Уралмаш', 'ЖБИ', ...; 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( _ARTIFICIAL_DEMAND_SQL = text(
""" """
WITH sold AS ( WITH sold AS (
@ -817,8 +823,8 @@ _ARTIFICIAL_DEMAND_SQL = text(
WHERE ol.premise_kind = CAST(:premise_kind AS text) WHERE ol.premise_kind = CAST(:premise_kind AS text)
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL) AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
AND ( AND (
CAST(:district AS text) IS NULL CAST(:has_district AS boolean) IS FALSE
OR ol.district = CAST(:district AS text) OR ol.district = ANY(CAST(:districts AS text[]))
) )
AND ( AND (
CAST(:obj_class AS text) IS NULL CAST(:obj_class AS text) IS NULL
@ -844,13 +850,23 @@ def _query_artificial_demand(
Параметризовано (psycopg v3 CAST). Сбой/пустой результат {n_sold:0,n_mortgage:0} Параметризовано (psycopg v3 CAST). Сбой/пустой результат {n_sold:0,n_mortgage:0}
(НЕ crash выше преобразуется в деградацию-None). Детерминированно. (НЕ 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 = ( row = (
db.execute( db.execute(
_ARTIFICIAL_DEMAND_SQL, _ARTIFICIAL_DEMAND_SQL,
{ {
"premise_kind": premise_kind, "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, "obj_class": obj_class,
}, },
) )

View file

@ -886,55 +886,156 @@ def _executed_params(db: MagicMock, call_index: int = 0) -> dict[str, Any]:
return args[1] 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: 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}) db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
_query_artificial_demand( with _identity_resolver:
db, district="Академический", obj_class="комфорт", premise_kind="квартира" _query_artificial_demand(
) db, district="Академический", obj_class="комфорт", premise_kind="квартира"
)
sql = _executed_sql(db) sql = _executed_sql(db)
assert "CAST(:premise_kind AS text)" in sql 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 assert "CAST(:obj_class AS text)" in sql
# psycopg v3: никогда :x::type # psycopg v3: никогда :x::type
assert ":premise_kind::" not in sql 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 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}) 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) sql = _executed_sql(db)
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots). # сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
assert "encumbrance_type" in sql assert "encumbrance_type" in sql
assert "bank_name" in sql assert "bank_name" in sql
assert "objective_lots" 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}) db = _mock_db_one({"n_sold": 1, "n_mortgage": 0})
_query_artificial_demand( with _identity_resolver:
db, district="Пионерский", obj_class="бизнес", premise_kind="квартира" _query_artificial_demand(
) db, district="Пионерский", obj_class="бизнес", premise_kind="квартира"
)
p = _executed_params(db) 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["obj_class"] == "бизнес"
assert p["premise_kind"] == "квартира" 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}) 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} 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) 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} 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}) 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} 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 # Artificial-Demand builder via mocked rows: signal → index; no signal → None+caveat