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
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:
parent
0fffc535cd
commit
c48f45c48f
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.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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue