diff --git a/backend/app/services/forecasting/sales_series.py b/backend/app/services/forecasting/sales_series.py index fa771c48..fc4be3a4 100644 --- a/backend/app/services/forecasting/sales_series.py +++ b/backend/app/services/forecasting/sales_series.py @@ -58,6 +58,7 @@ from sqlalchemy.orm import Session # Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в # macro_series, хоть и не в __all__ пакета. from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months +from app.services.site_finder.district_resolver import resolve_objective_districts logger = logging.getLogger(__name__) @@ -357,7 +358,10 @@ _SOURCE_A_SQL = text( FROM objective_corpus_room_month crm WHERE crm.report_month >= CAST(:since AS date) AND (CAST(:cls AS text) IS NULL OR LOWER(crm.class) = LOWER(CAST(:cls AS text))) - AND (CAST(:district AS text) IS NULL OR crm.district = CAST(:district AS text)) + AND ( + CAST(:has_district AS boolean) IS FALSE + OR crm.district = ANY(CAST(:districts AS text[])) + ) AND ( CAST(:room_bucket AS text) IS NULL OR crm.room_bucket = CAST(:room_bucket AS text) @@ -415,8 +419,8 @@ _SOURCE_B_SQL = text( AND ol.registration_date >= CAST(:since AS date) AND (CAST(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text))) 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[])) ) ) SELECT @@ -534,10 +538,14 @@ def _query_source_a( "(monthly aggregate carries no per-lot price)", spec.price_bucket, ) + # district (админ-имя ЕКБ) → набор informal микро (crm.district хранит микро — + # тот же вокабуляр, что objective_lots; verified на prod). None → EKB-wide. + micros = resolve_objective_districts(db, spec.district) params = { "since": since, "cls": spec.obj_class, - "district": spec.district, + "has_district": micros is not None, + "districts": micros if micros is not None else [], "room_bucket": spec.room_bucket, } try: @@ -557,11 +565,15 @@ def _query_source_b( (зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же правилом и в БД, и в Python. """ + # district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро). + # None → EKB-wide (без district-фильтра). + micros = resolve_objective_districts(db, spec.district) params = { "since": since, "premise_kind": premise_kind, "cls": spec.obj_class, - "district": spec.district, + "has_district": micros is not None, + "districts": micros if micros is not None else [], "room_bucket": spec.room_bucket, "price_bucket": spec.price_bucket, # bucket-пороги (зеркало _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*). diff --git a/backend/app/services/site_finder/district_resolver.py b/backend/app/services/site_finder/district_resolver.py new file mode 100644 index 00000000..b8b487fa --- /dev/null +++ b/backend/app/services/site_finder/district_resolver.py @@ -0,0 +1,144 @@ +"""District-resolver — мост между ДВУМЯ районными вокабулярами ЕКБ (Step 2). + +Контекст бага (ground-truthed на prod, см. `data/sql/131_district_vocab_map.sql`): + • §9.x market/supply/sales метрики фильтруют `objective_lots.district` (и + `objective_corpus_room_month.district`) — это НЕФОРМАЛЬНЫЕ МИКРО-районы ЕКБ + («Втузгородок», «ЖБИ», «Уралмаш» … — 35 distinct значений). + • Но `/analyze` резолвит у участка ОФИЦИАЛЬНЫЙ АДМИН-район из `ekb_districts` + (8 полигонов: «Кировский», «Ленинский», «Орджоникидзевский» …). + • Админ-имя → 0 строк в objective_lots → молча пустой прогноз. Напр. + market_metrics('Кировский') → obj_count=0 (а 'Академический' случайно работает, + т.к. он есть в ОБОИХ вокабулярах). + +Step 1 (PR #1053, УЖЕ НА ПРОДЕ) добавил таблицу-словарь `ekb_district_alias` +(admin_district → micro_district, note). Этот модуль — Step 2: резолвер, который +разворачивает админ-имя в список микро-районов, чтобы фильтры §9.x перестали +возвращать пустоту. + +Семантика `resolve_objective_districts(db, district)` — см. docstring функции. +psycopg v3: все binds через `CAST(:x AS type)`, НИКОГДА `:x::type`. Read-only SELECT. +""" + +from __future__ import annotations + +import logging + +from sqlalchemy import text +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +# Сентинел «район не определён» — приходит из /analyze, когда участок не попал ни в +# один из 8 полигонов ekb_districts (вне ЕКБ / без координат). Трактуем как None → +# EKB-wide fallback (без district-фильтра), а не как буквальное имя района. +_UNDETERMINED_DISTRICT: str = "не определён" + +# Список 8 официальных АДМИН-районов ЕКБ (district_name из ekb_districts с непустым +# geom). Кешируем per-process: он фактически статичен (8 полигонов, меняется только +# при пере-импорте границ), а резолвер бьётся per-analyze — лишний round-trip ни к +# чему. Lazy-init на первом вызове; сбрасывается через `_reset_admin_cache()` в тестах. +_ADMIN_NAMES_CACHE: frozenset[str] | None = None + +# Имена 8 админ-районов: те, у кого в ekb_districts есть непустой geom (= ekb_districts_geom +# view). 'не определён' (9-я строка-dimension без geom) сюда НЕ попадает — и не должен. +_ADMIN_NAMES_SQL = text( + """ + SELECT district_name + FROM ekb_districts + WHERE geom IS NOT NULL + """ +) + +# Чистые ЕКБ-микро для одного админ-района. note IS NULL отсекает cross-city +# (Н.Тагил/Каменск, собранные в bucket «Чкаловский») и ambiguous-пометки — +# нам нужны только однозначные ЕКБ-микро (см. 131_district_vocab_map.sql). +_MICROS_FOR_ADMIN_SQL = text( + """ + SELECT micro_district + FROM ekb_district_alias + WHERE admin_district = CAST(:d AS text) + AND note IS NULL + ORDER BY micro_district + """ +) + + +def _admin_names(db: Session) -> frozenset[str]: + """Множество 8 официальных админ-имён ЕКБ (с непустым geom), кеш per-process. + + На ошибке БД возвращает пустой set (logged) — резолвер тогда трактует ЛЮБОЙ + вход как raw-микро (legacy pass-through), что безопаснее, чем уронить /analyze. + """ + global _ADMIN_NAMES_CACHE + if _ADMIN_NAMES_CACHE is not None: + return _ADMIN_NAMES_CACHE + try: + rows = db.execute(_ADMIN_NAMES_SQL).scalars().all() + _ADMIN_NAMES_CACHE = frozenset(str(r) for r in rows if r) + except Exception: + # Не кешируем пустой результат-от-сбоя: следующий вызов попробует снова. + logger.exception("district_resolver: admin-names lookup failed") + return frozenset() + return _ADMIN_NAMES_CACHE + + +def _reset_admin_cache() -> None: + """Сбросить per-process кеш админ-имён (для тестов / форс-рефреша границ).""" + global _ADMIN_NAMES_CACHE + _ADMIN_NAMES_CACHE = None + + +def resolve_objective_districts(db: Session, district: str | None) -> list[str] | None: + """Развернуть district-вход в набор МИКРО-районов для фильтра objective_lots. + + Решает рассинхрон вокабуляров ЕКБ (admin polygons vs informal micro-districts). + Возвращаемое значение — контракт для вызывающего фильтра: + + • ``None`` → фильтровать EKB-wide (БЕЗ district-фильтра). Это происходит + когда: + - вход ``None`` или ``'не определён'`` (участок вне 8 + полигонов — нечего сужать), ЛИБО + - вход — валидное админ-имя, но у него НЕТ чистых алиасов + (на практике не случается: все 8 покрыты сидом; страховка). + • ``[micro, …]`` → фильтровать ``ol.district = ANY(...)``: + - вход — одно из 8 АДМИН-имён → его чистые микро + (note IS NULL: cross-city/ambiguous исключены), ЛИБО + - вход — НЕ админ-имя (raw МИКРО, переданный внутренним + legacy-вызовом) → ``[district]`` как есть (сохраняем + прежнее поведение фильтра-по-микро). + + Никогда не возвращает пустой список — вместо «0 строк гарантированно» отдаёт + ``None`` (EKB-wide), чтобы прогноз не схлопывался молча в пустоту (это и есть + суть фикса). + + psycopg v3: bind через CAST(:d AS text). Read-only. + """ + if district is None or district == _UNDETERMINED_DISTRICT: + return None + + admin_names = _admin_names(db) + if district not in admin_names: + # Raw микро-район (legacy внутренний вызов) — фильтруем по нему как есть. + return [district] + + # district — официальное админ-имя: разворачиваем в чистые ЕКБ-микро. + try: + rows = db.execute(_MICROS_FOR_ADMIN_SQL, {"d": district}).scalars().all() + micros = [str(m) for m in rows] + except Exception: + logger.exception( + "district_resolver: micro-lookup failed (admin=%s) — falling back to EKB-wide", + district, + ) + return None + + if not micros: + # Админ без чистых алиасов (дыра в словаре) → EKB-wide fallback, НЕ пустой набор. + logger.warning( + "district_resolver: admin '%s' has no clean micro aliases — EKB-wide fallback", + district, + ) + return None + + logger.debug("district_resolver: admin '%s' → micros %s", district, micros) + return micros diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index 290fa56b..fde10f0b 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -38,6 +38,7 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.services.analytics_queries import _elasticity_coef +from app.services.site_finder.district_resolver import resolve_objective_districts logger = logging.getLogger(__name__) @@ -260,8 +261,8 @@ _STOCK_SQL = text( FROM objective_lots ol WHERE ol.premise_kind = :premise_kind 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(:has_obj_ids AS boolean) IS FALSE @@ -300,8 +301,8 @@ _SALES_WINDOW_SQL = text( FROM objective_lots ol WHERE ol.premise_kind = :premise_kind 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(:has_obj_ids AS boolean) IS FALSE @@ -333,9 +334,17 @@ def compute_market_metrics( """ obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else [] has_obj_ids = bool(obj_id_list) + # Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым + # реально фильтруется objective_lots. None → EKB-wide (без district-фильтра); это + # лечит баг «админ-имя → 0 строк → пустой прогноз» (см. district_resolver). + micros = resolve_objective_districts(db, district) + has_district = micros is not None params: dict[str, Any] = { "premise_kind": premise_kind, - "district": district, + # has_district=False → district-ветка фильтра отключена (EKB-wide). + "has_district": has_district, + # ANY(NULL::text[]) валиден; пустой список когда фильтра нет (has_district=False). + "districts": micros if micros is not None else [], "has_obj_ids": has_obj_ids, # ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет. "obj_ids": obj_id_list, @@ -378,9 +387,10 @@ def compute_market_metrics( confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total) logger.info( - "market_metrics: district=%s obj_ids=%d n_lots=%d n_sold=%d n_available=%d " - "obj_count=%d units_sold_window=%d confidence=%s", + "market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d " + "n_available=%d obj_count=%d units_sold_window=%d confidence=%s", district, + micros, len(obj_id_list), n_lots, n_sold_total, @@ -417,7 +427,9 @@ def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]: try: row = db.execute(_STOCK_SQL, dict(params)).mappings().first() except Exception: - logger.exception("market_metrics: stock query failed (district=%s)", params.get("district")) + logger.exception( + "market_metrics: stock query failed (districts=%s)", params.get("districts") + ) row = None if row is None: return { @@ -449,7 +461,7 @@ def _query_sales_window( rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all() except Exception: logger.exception( - "market_metrics: sales-window query failed (district=%s)", params.get("district") + "market_metrics: sales-window query failed (districts=%s)", params.get("districts") ) rows = [] diff --git a/backend/app/services/site_finder/supply_layers.py b/backend/app/services/site_finder/supply_layers.py index a3e205b2..ab54dee5 100644 --- a/backend/app/services/site_finder/supply_layers.py +++ b/backend/app/services/site_finder/supply_layers.py @@ -66,6 +66,8 @@ from typing import Any, Literal from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.site_finder.district_resolver import resolve_objective_districts + logger = logging.getLogger(__name__) Confidence = Literal["high", "medium", "low"] @@ -192,8 +194,8 @@ _L1_OPEN_SQL = text( FROM objective_lots ol WHERE ol.premise_kind = :premise_kind 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[])) ) ) SELECT @@ -296,8 +298,18 @@ def compute_layer1_open( Источник — `objective_lots` (выставленные лоты), переиспользует district-stock паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке на (district, obj_class) с доступными лотами. Пустые данные → []. Не бросает. + + district резолвится через district_resolver: админ-имя ЕКБ → набор informal микро + (objective_lots хранит микро, не админ — иначе админ-имя дало бы 0 строк). None → + EKB-wide (без district-фильтра). L2/L3 ниже НЕ резолвятся: они бьют по + domrf_kn_objects.district_name, а это УЖЕ админ-вокабуляр (м.57 spatial-join). """ - params: dict[str, Any] = {"premise_kind": premise_kind, "district": district} + micros = resolve_objective_districts(db, district) + params: dict[str, Any] = { + "premise_kind": premise_kind, + "has_district": micros is not None, + "districts": micros if micros is not None else [], + } rows = _safe_rows(db, _L1_OPEN_SQL, params, layer=1) out: list[SupplyLayerRow] = [] @@ -484,7 +496,7 @@ def _safe_rows( try: return db.execute(sql, dict(params)).mappings().all() except Exception: - logger.exception( - "supply_layers: L%d query failed (district=%s)", layer, params.get("district") - ) + # L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`. + district_dbg = params.get("district", params.get("districts")) + logger.exception("supply_layers: L%d query failed (district=%s)", layer, district_dbg) return [] diff --git a/backend/tests/services/forecasting/test_sales_series.py b/backend/tests/services/forecasting/test_sales_series.py index 460408c7..2b9ca881 100644 --- a/backend/tests/services/forecasting/test_sales_series.py +++ b/backend/tests/services/forecasting/test_sales_series.py @@ -18,7 +18,10 @@ from __future__ import annotations import datetime as dt import math import os -from unittest.mock import MagicMock +from collections.abc import Iterator +from unittest.mock import MagicMock, patch + +import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -45,6 +48,21 @@ from app.services.forecasting.sales_series import ( room_area_bucket_of, ) +# Резолвер admin→micro (Step 2). Обе SQL-ветки (Source A crm + Source B objective_lots) +# теперь резолвят spec.district → набор микро. Патчим на identity raw-микро, чтобы +# db.execute не получал лишний resolver-запрос; отдельный класс TestDistrictResolution +# проверяет, что резолвнутые микро реально идут в SQL-параметры. +_RESOLVE = "app.services.forecasting.sales_series.resolve_objective_districts" + + +@pytest.fixture(autouse=True) +def _patch_resolver() -> Iterator[MagicMock]: + """По умолчанию резолвер = identity raw-микро (district→[district], None→None).""" + with patch(_RESOLVE) as m: + m.side_effect = lambda _db, d: [d] if d is not None else None + yield m + + # ── pure: price_bucket_of ───────────────────────────────────────────────────── @@ -365,7 +383,8 @@ class TestBuildSalesSeriesSourceShape: sql = _sql_of(db) assert "CAST(:since AS date)" in sql assert "CAST(:cls AS text)" in sql - assert "CAST(:district AS text)" in sql + # district теперь резолвится в набор микро → ANY(CAST(:districts AS text[])). + assert "CAST(:districts AS text[])" in sql assert "CAST(:room_bucket AS text)" in sql # psycopg v3 trap: никаких :name::type. assert "::" not in sql @@ -419,7 +438,10 @@ class TestBuildSalesSeriesSourceShape: ) params = _params_of(db) assert params["cls"] == "Комфорт" - assert params["district"] == "Уралмаш" + # district резолвится в набор микро (identity → ['Уралмаш']) → ANY(:districts). + assert params["has_district"] is True + assert params["districts"] == ["Уралмаш"] + assert "district" not in params assert params["room_bucket"] == "1" assert isinstance(params["since"], dt.date) @@ -643,3 +665,61 @@ class TestBuildSalesSeriesGraceful: ) assert out.n_months == 1 # только текущий месяц assert out.units == [0] + + +class TestDistrictResolution: + """Step 2: spec.district (админ-имя ЕКБ) резолвится в МИКРО-набор в SQL-фильтре. + + Оба источника используют МИКРО-вокабуляр: objective_corpus_room_month.district и + objective_lots.district несут одни и те же 35 informal-микро (verified на prod) → + админ-имя по ним давало 0 строк. Резолвер разворачивает его в чистые микро. + """ + + def test_source_a_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None: + _patch_resolver.side_effect = lambda _db, d: ( + ["Втузгородок", "ЖБИ"] if d == "Кировский" else None + ) + db = MagicMock() + db.execute.return_value = _result([]) + build_sales_series( + db, + spec=SegmentSpec(district="Кировский"), + source="corpus_room_month", + months_back=1, + ) + assert _patch_resolver.call_args.args[1] == "Кировский" + p = _params_of(db) + assert p["has_district"] is True + assert p["districts"] == ["Втузгородок", "ЖБИ"] + assert "Кировский" not in p["districts"] + + def test_source_b_admin_resolves_to_micros(self, _patch_resolver: MagicMock) -> None: + _patch_resolver.side_effect = lambda _db, d: ( + ["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None + ) + db = MagicMock() + db.execute.return_value = _result([]) + build_sales_series( + db, + spec=SegmentSpec(district="Орджоникидзевский"), + source="objective_lots", + months_back=1, + ) + p = _params_of(db) + assert p["has_district"] is True + assert p["districts"] == ["Уралмаш", "Эльмаш"] + assert "Орджоникидзевский" not in p["districts"] + + def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None: + _patch_resolver.side_effect = lambda _db, _d: None + db = MagicMock() + db.execute.return_value = _result([]) + build_sales_series( + db, + spec=SegmentSpec(district="не определён"), + source="objective_lots", + months_back=1, + ) + p = _params_of(db) + assert p["has_district"] is False + assert p["districts"] == [] diff --git a/backend/tests/services/site_finder/test_district_resolver.py b/backend/tests/services/site_finder/test_district_resolver.py new file mode 100644 index 00000000..d797a6db --- /dev/null +++ b/backend/tests/services/site_finder/test_district_resolver.py @@ -0,0 +1,222 @@ +"""Unit-тесты district-resolver (Step 2 — мост admin↔micro вокабуляров ЕКБ). + +Резолвер лечит баг §9.x: /analyze передаёт ОФИЦИАЛЬНОЕ админ-имя («Кировский»), а +objective_lots.district хранит НЕФОРМАЛЬНЫЕ микро («Втузгородок»/«ЖБИ») → админ-имя +давало 0 строк → молча пустой прогноз (см. data/sql/131_district_vocab_map.sql). + +Контракт resolve_objective_districts(db, district): + • None / 'не определён' → None (EKB-wide, без district-фильтра) + • админ-имя с чистыми алиасами → [micro, …] (note IS NULL) + • админ-имя без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор) + • не-админ (raw микро, legacy) → [district] (фильтр-по-микро как есть) + +Тесты бьют МОК-сессию (без живой БД): резолвер делает максимум 2 execute — +admin-names (.scalars().all()) + micro-lookup (.scalars().all()). КРИТИЧНО: тест на +админ-вход ОБЯЗАН падать, если резолвер регрессирует к pass-through админ-имени. +""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +import pytest + +from app.services.site_finder import district_resolver +from app.services.site_finder.district_resolver import ( + _reset_admin_cache, + resolve_objective_districts, +) + +# 8 официальных админ-районов ЕКБ (district_name из ekb_districts с непустым geom). +_ADMIN_NAMES = [ + "Академический", + "Верх-Исетский", + "Железнодорожный", + "Кировский", + "Ленинский", + "Октябрьский", + "Орджоникидзевский", + "Чкаловский", +] + +# Чистые микро (note IS NULL) для админов, которые тестим (срез prod-сида 131_*). +_CLEAN_MICROS: dict[str, list[str]] = { + "Кировский": ["Втузгородок", "ЖБИ"], + "Ленинский": ["Автовокзал", "УНЦ", "Юго-Западный"], + "Орджоникидзевский": ["Уралмаш", "Эльмаш"], +} + + +@pytest.fixture(autouse=True) +def _reset_cache_between_tests() -> None: + """Сбрасывать per-process кеш админ-имён — иначе мок одного теста течёт в другой.""" + _reset_admin_cache() + yield + _reset_admin_cache() + + +def _make_db( + admin_names: list[str] | None = None, + micros: list[str] | None = None, +) -> MagicMock: + """Мок-сессия: 1-й execute → admin-names; 2-й (если есть) → micro-lookup. + + Оба читаются через .scalars().all(). admin_names=None → дефолтные 8. + """ + names = _ADMIN_NAMES if admin_names is None else admin_names + db = MagicMock() + admin_result = MagicMock() + admin_result.scalars.return_value.all.return_value = names + micro_result = MagicMock() + micro_result.scalars.return_value.all.return_value = micros if micros is not None else [] + db.execute.side_effect = [admin_result, micro_result] + return db + + +def _micro_lookup_param(db: MagicMock) -> str | None: + """Достать bind :d из ВТОРОГО execute (micro-lookup). None если его не было.""" + if len(db.execute.call_args_list) < 2: + return None + args, _kwargs = db.execute.call_args_list[1] + return args[1]["d"] + + +# ── None / 'не определён' → None (EKB-wide) ─────────────────────────────────── + + +class TestNoneAndUndetermined: + def test_none_returns_none(self) -> None: + db = _make_db() + assert resolve_objective_districts(db, None) is None + # ранний выход — БД даже не трогается + assert db.execute.call_count == 0 + + def test_undetermined_returns_none(self) -> None: + db = _make_db() + assert resolve_objective_districts(db, "не определён") is None + assert db.execute.call_count == 0 + + +# ── админ-имя → его чистые микро (THE FIX) ───────────────────────────────────── + + +class TestAdminExpandsToMicros: + @pytest.mark.parametrize("admin", list(_CLEAN_MICROS.keys())) + def test_admin_expands_to_clean_micros(self, admin: str) -> None: + expected = _CLEAN_MICROS[admin] + db = _make_db(micros=expected) + out = resolve_objective_districts(db, admin) + assert out == expected + + def test_does_not_pass_admin_name_through(self) -> None: + # РЕГРЕССИОННЫЙ guard: резолвер НЕ должен возвращать [admin] (это и был баг — + # админ-имя → 0 строк). Должен вернуть микро, а админ-имя НЕ должно быть в наборе. + db = _make_db(micros=["Втузгородок", "ЖБИ"]) + out = resolve_objective_districts(db, "Кировский") + assert out == ["Втузгородок", "ЖБИ"] + assert out != ["Кировский"] + assert "Кировский" not in out + + def test_micro_lookup_bound_with_admin_name(self) -> None: + # micro-lookup параметризован именно админ-именем (CAST(:d AS text)). + db = _make_db(micros=["Уралмаш", "Эльмаш"]) + resolve_objective_districts(db, "Орджоникидзевский") + assert _micro_lookup_param(db) == "Орджоникидзевский" + + +# ── админ без чистых алиасов → None (EKB-wide fallback, НЕ пустой набор) ─────── + + +class TestAdminNoCleanAliasesFallback: + def test_admin_without_clean_micros_returns_none(self) -> None: + # Админ в ekb_districts, но micro-lookup пуст (note IS NULL отсёк всё) → + # EKB-wide fallback. НИКОГДА не пустой список (иначе гарантированно 0 строк). + db = _make_db(micros=[]) + out = resolve_objective_districts(db, "Чкаловский") + assert out is None + + def test_never_returns_empty_list(self) -> None: + db = _make_db(micros=[]) + out = resolve_objective_districts(db, "Ленинский") + assert out != [] + assert out is None + + +# ── raw микро (legacy внутренний вызов) → [district] как есть ────────────────── + + +class TestRawMicroPassThrough: + def test_raw_micro_returned_as_is(self) -> None: + # 'Втузгородок' — НЕ админ-имя → фильтруем по нему как есть (legacy-поведение). + db = _make_db() + out = resolve_objective_districts(db, "Втузгородок") + assert out == ["Втузгородок"] + + def test_raw_micro_does_not_hit_micro_lookup(self) -> None: + # raw-микро не разворачивается → второй execute (micro-lookup) НЕ делается. + db = _make_db() + resolve_objective_districts(db, "Уралмаш") + assert db.execute.call_count == 1 # только admin-names + + +# ── кеш админ-имён ───────────────────────────────────────────────────────────── + + +class TestAdminNamesCache: + def test_admin_names_cached_across_calls(self) -> None: + # admin-names читается ОДИН раз и кешируется per-process: второй резолв + # raw-микро НЕ делает повторный admin-names запрос. + db = MagicMock() + admin_result = MagicMock() + admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES + db.execute.return_value = admin_result # любой вызов вернёт admin-names + + resolve_objective_districts(db, "Уралмаш") # raw micro → 1 execute (admins) + resolve_objective_districts(db, "Эльмаш") # raw micro → 0 execute (cache hit) + assert db.execute.call_count == 1 + + def test_admin_lookup_failure_treats_input_as_raw_micro(self) -> None: + # Сбой admin-names lookup → пустой admin-set → ЛЮБОЙ вход трактуется как raw + # микро (pass-through), НЕ роняем /analyze. Кеш-от-сбоя не залипает. + db = MagicMock() + db.execute.side_effect = RuntimeError("db down") + out = resolve_objective_districts(db, "Кировский") + # admin-set пуст → 'Кировский' не распознан как админ → [district] как есть. + assert out == ["Кировский"] + + +# ── сбой micro-lookup → EKB-wide fallback (не crash) ────────────────────────── + + +class TestMicroLookupFailure: + def test_micro_lookup_exception_returns_none(self) -> None: + db = MagicMock() + admin_result = MagicMock() + admin_result.scalars.return_value.all.return_value = _ADMIN_NAMES + db.execute.side_effect = [admin_result, RuntimeError("micro lookup down")] + out = resolve_objective_districts(db, "Кировский") + # админ распознан, но lookup упал → EKB-wide (None), а не crash/пустой набор. + assert out is None + + +# ── форма SQL: CAST(:d AS text), без :d::text ───────────────────────────────── + + +class TestResolverSqlShape: + def test_micro_sql_uses_cast_not_double_colon(self) -> None: + sql = str(district_resolver._MICROS_FOR_ADMIN_SQL) + assert "CAST(:d AS text)" in sql + assert ":d::" not in sql + + def test_micro_sql_filters_note_is_null(self) -> None: + # cross-city/ambiguous (note IS NOT NULL) ОБЯЗАНЫ быть отсечены. + sql = str(district_resolver._MICROS_FOR_ADMIN_SQL) + assert "note IS NULL" in sql + + def test_admin_sql_filters_non_null_geom(self) -> None: + # 8 админов = ekb_districts с непустым geom ('не определён' без geom исключён). + sql = str(district_resolver._ADMIN_NAMES_SQL) + assert "geom IS NOT NULL" in sql diff --git a/backend/tests/services/site_finder/test_market_metrics.py b/backend/tests/services/site_finder/test_market_metrics.py index d1e5df84..833e0faa 100644 --- a/backend/tests/services/site_finder/test_market_metrics.py +++ b/backend/tests/services/site_finder/test_market_metrics.py @@ -14,8 +14,11 @@ psycopg v3 правило проверяется явно: bind-параметр from __future__ import annotations import os +from collections.abc import Iterator from unittest.mock import MagicMock, patch +import pytest + os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.services.site_finder.market_metrics import ( @@ -33,6 +36,22 @@ from app.services.site_finder.market_metrics import ( ) _ELAST = "app.services.site_finder.market_metrics._elasticity_coef" +# Резолвер admin→micro (Step 2). В shape/thin-data тестах патчим его на identity-обёртку +# через _patch_resolver(), чтобы не ходить в БД за admin-names/aliases. Отдельный класс +# TestDistrictResolution проверяет, что РЕЗОЛВНУТЫЕ микро реально попадают в SQL-параметры. +_RESOLVE = "app.services.site_finder.market_metrics.resolve_objective_districts" + + +@pytest.fixture(autouse=True) +def _patch_resolver() -> Iterator[MagicMock]: + """По умолчанию резолвер = identity-обёртка raw-микро (district→[district], None→None). + + Так _mock_db (stock+sales, 2 execute) остаётся валиден — резолвер не трогает db. + Тесты, проверяющие admin→micro-разворот, переопределяют .side_effect у этого мока. + """ + with patch(_RESOLVE) as m: + m.side_effect = lambda _db, d: [d] if d is not None else None + yield m # ── pure: _monthly_rate ─────────────────────────────────────────────────────── @@ -338,13 +357,14 @@ class TestComputeMarketMetricsShape: compute_market_metrics(db, district="Автовокзал") stock_sql = _executed_sql(db, 0) sales_sql = _executed_sql(db, 1) - # psycopg v3: CAST(:x AS type), НЕ :x::type - assert "CAST(:district AS text)" in stock_sql + # psycopg v3: CAST(:x AS type), НЕ :x::type. District-фильтр теперь массив микро. + assert "CAST(:districts AS text[])" in stock_sql assert "CAST(:obj_ids AS bigint[])" in stock_sql assert "CAST(:overstock_interval AS interval)" in stock_sql + assert "CAST(:districts AS text[])" in sales_sql assert "CAST(:window_interval AS interval)" in sales_sql for sql in (stock_sql, sales_sql): - assert ":district::" not in sql + assert ":districts::" not in sql assert ":obj_ids::" not in sql assert ":window_interval::" not in sql assert ":overstock_interval::" not in sql @@ -354,7 +374,11 @@ class TestComputeMarketMetricsShape: with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): compute_market_metrics(db, district="Автовокзал", premise_kind="квартира") p = _executed_params(db, 0) - assert p["district"] == "Автовокзал" + # district резолвится в набор микро (тут identity → ['Автовокзал']) и идёт в + # ANY(:districts); прежнего скалярного :district больше нет. + assert p["has_district"] is True + assert p["districts"] == ["Автовокзал"] + assert "district" not in p assert p["premise_kind"] == "квартира" assert p["has_obj_ids"] is False assert p["obj_ids"] == [] @@ -415,6 +439,67 @@ class TestComputeMarketMetricsShape: assert m.price_sensitivity_source is None +class TestDistrictResolution: + """Step 2: админ-имя ЕКБ резолвится в МИКРО-набор, который реально идёт в SQL-фильтр. + + Баг, который это ловит: раньше админ-имя («Кировский») фильтровало + objective_lots.district = 'Кировский' → 0 строк (микро-вокабуляр не содержит + админ-имён) → пустой прогноз. Теперь резолвер разворачивает его в чистые микро, + и SQL фильтрует ol.district = ANY([микро…]). + """ + + def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None: + # Резолвер разворачивает 'Кировский' → ['Втузгородок','ЖБИ'] (как на проде). + _patch_resolver.side_effect = lambda _db, d: ( + ["Втузгородок", "ЖБИ"] if d == "Кировский" else None + ) + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): + m = compute_market_metrics(db, district="Кировский") + + # резолвер вызван именно с админ-именем + _patch_resolver.assert_called_once() + assert _patch_resolver.call_args.args[1] == "Кировский" + + # КРИТИЧНО: в SQL-параметры попали МИКРО, а НЕ админ-имя (иначе 0 строк). + for call_idx in (0, 1): # stock + sales-window + p = _executed_params(db, call_idx) + assert p["has_district"] is True + assert p["districts"] == ["Втузгородок", "ЖБИ"] + assert "Кировский" not in p["districts"] + assert "district" not in p # старого скалярного параметра больше нет + + # district на результате всё равно ЭХОИТ входное админ-имя (для отображения). + assert m.district == "Кировский" + + def test_resolver_none_drops_district_filter_ekb_wide(self, _patch_resolver: MagicMock) -> None: + # Админ без чистых алиасов / 'не определён' → резолвер None → EKB-wide: + # has_district=False, district-ветка фильтра отключена (пустой массив-плейсхолдер). + _patch_resolver.side_effect = lambda _db, _d: None + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): + compute_market_metrics(db, district="не определён") + p = _executed_params(db, 0) + assert p["has_district"] is False + assert p["districts"] == [] + + def test_does_not_filter_by_admin_name_directly(self, _patch_resolver: MagicMock) -> None: + # Anti-vacuous guard: даже если кто-то «починит» резолвер обратно на pass-through + # админ-имени, фильтр обязан содержать РЕЗОЛВНУТЫЙ набор. Здесь резолвер отдаёт + # микро, и мы проверяем, что admin-имя НЕ просочилось в :districts ни в одном + # из двух запросов. + _patch_resolver.side_effect = lambda _db, d: ( + ["Уралмаш", "Эльмаш"] if d == "Орджоникидзевский" else None + ) + db = _mock_db(_FULL_STOCK, _FULL_SALES) + with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}): + compute_market_metrics(db, district="Орджоникидзевский") + for call_idx in (0, 1): + p = _executed_params(db, call_idx) + assert p["districts"] == ["Уралмаш", "Эльмаш"] + assert "Орджоникидзевский" not in p["districts"] + + class TestComputeMarketMetricsThinData: """Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash.""" diff --git a/backend/tests/services/site_finder/test_supply_layers.py b/backend/tests/services/site_finder/test_supply_layers.py index 8fd5b192..ce231ec6 100644 --- a/backend/tests/services/site_finder/test_supply_layers.py +++ b/backend/tests/services/site_finder/test_supply_layers.py @@ -13,8 +13,11 @@ psycopg v3 правило проверяется явно: bind-параметр from __future__ import annotations import os +from collections.abc import Iterator from datetime import date -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch + +import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") @@ -34,6 +37,23 @@ from app.services.site_finder.supply_layers import ( _ALLOWED_SOURCES = {"objective", "domrf_declared", "domrf_multiphase"} _ALLOWED_CONFIDENCE = {"high", "medium", "low"} +# Резолвер admin→micro (Step 2) — патчим в L1-тестах. ТОЛЬКО L1 (open) резолвится +# (objective_lots = микро-вокабуляр); L2/L3 бьют domrf_kn_objects.district_name, а это +# УЖЕ админ-вокабуляр (м.57 spatial-join) → их district НЕ резолвится. +_RESOLVE = "app.services.site_finder.supply_layers.resolve_objective_districts" + + +@pytest.fixture(autouse=True) +def _patch_resolver() -> Iterator[MagicMock]: + """По умолчанию резолвер = identity raw-микро (district→[district], None→None). + + Так _mock_db остаётся валиден (резолвер не трогает db). L1-тесты, проверяющие + admin→micro-разворот, переопределяют .side_effect. + """ + with patch(_RESOLVE) as m: + m.side_effect = lambda _db, d: [d] if d is not None else None + yield m + # ── pure: _hidden_units (L2 clamp) ──────────────────────────────────────────── @@ -205,14 +225,18 @@ class TestLayer1Open: db = _mock_db([]) compute_layer1_open(db, district="Автовокзал") sql = _executed_sql(db) - assert "CAST(:district AS text)" in sql + # District-фильтр L1 теперь массив резолвнутых микро (ANY), не скаляр. + assert "CAST(:districts AS text[])" in sql _assert_no_double_colon_cast(sql) def test_params(self) -> None: db = _mock_db([]) compute_layer1_open(db, district="Автовокзал", premise_kind="квартира") p = _executed_params(db) - assert p["district"] == "Автовокзал" + # district резолвится в набор микро (identity → ['Автовокзал']) → ANY(:districts). + assert p["has_district"] is True + assert p["districts"] == ["Автовокзал"] + assert "district" not in p assert p["premise_kind"] == "квартира" def test_rows_built_with_whitelisted_values(self) -> None: @@ -271,6 +295,36 @@ class TestLayer1Open: assert compute_layer1_open(db, district="Сбой") == [] +class TestLayer1DistrictResolution: + """Step 2: L1 (open) резолвит админ-имя → микро-набор в SQL-фильтре. + + Тот же баг, что в market_metrics: админ-имя по objective_lots.district давало 0 + строк открытого предложения. Резолвер разворачивает его в чистые микро. + """ + + def test_admin_resolves_to_micros_in_sql_params(self, _patch_resolver: MagicMock) -> None: + _patch_resolver.side_effect = lambda _db, d: ( + ["Втузгородок", "ЖБИ"] if d == "Кировский" else None + ) + db = _mock_db([]) + compute_layer1_open(db, district="Кировский") + _patch_resolver.assert_called_once() + assert _patch_resolver.call_args.args[1] == "Кировский" + p = _executed_params(db) + # КРИТИЧНО: фильтр содержит МИКРО, а НЕ админ-имя (иначе 0 строк). + assert p["has_district"] is True + assert p["districts"] == ["Втузгородок", "ЖБИ"] + assert "Кировский" not in p["districts"] + + def test_resolver_none_drops_district_filter(self, _patch_resolver: MagicMock) -> None: + _patch_resolver.side_effect = lambda _db, _d: None + db = _mock_db([]) + compute_layer1_open(db, district="не определён") + p = _executed_params(db) + assert p["has_district"] is False + assert p["districts"] == [] + + # ── Layer2 (hidden) — THE CRUX ──────────────────────────────────────────────── @@ -325,6 +379,17 @@ class TestLayer2Hidden: assert p["region_cd"] == 66 assert p["district"] == "Автовокзал" + def test_district_not_resolved_admin_vocab_preserved(self, _patch_resolver: MagicMock) -> None: + # КРИТИЧНО: L2 бьёт domrf_kn_objects.district_name (УЖЕ админ-вокабуляр, м.57 + # spatial-join) → резолвер НЕ вызывается, админ-имя идёт в фильтр как есть. + # Иначе резолв в микро дал бы 0 строк (domrf не знает микро-имён). + db = _mock_db([]) + compute_layer2_hidden(db, district="Кировский") + _patch_resolver.assert_not_called() + p = _executed_params(db) + assert p["district"] == "Кировский" + assert "districts" not in p + def test_hidden_aggregate_medium_confidence(self) -> None: rows = [ {