fix(forecasting): resolve admin district to micros в _query_artificial_demand (#1205)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m30s
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m30s
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:
parent
5826812816
commit
a08cc317c3
2 changed files with 143 additions and 26 deletions
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue