fix(forecasting): resolve admin district to micros в _query_artificial_demand (#1205) #1266

Merged
bot-backend merged 1 commit from fix/artificial-demand-admin-to-micros into main 2026-06-13 07:34:55 +00:00
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.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,
},
)

View file

@ -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