feat(forecast): wire admin→micro district resolver into §9.x filters — Step 2 #1054

Merged
bot-backend merged 1 commit from feat/wire-district-resolver into main 2026-06-05 02:04:10 +00:00
8 changed files with 662 additions and 30 deletions

View file

@ -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_*).

View file

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

View file

@ -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 = []

View file

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

View file

@ -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"] == []

View file

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

View file

@ -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.
Тесты, проверяющие adminmicro-разворот, переопределяют .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."""

View file

@ -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-тесты, проверяющие
adminmicro-разворот, переопределяют .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 = [
{