feat(forecast): resolve admin district -> micro set in §9.x market/supply/sales filters
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m7s

/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.

Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).

Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
This commit is contained in:
Light1YT 2026-06-05 07:03:37 +05:00
parent b83ffedec3
commit 681a922d99
8 changed files with 662 additions and 30 deletions

View file

@ -58,6 +58,7 @@ from sqlalchemy.orm import Session
# Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в # Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в
# macro_series, хоть и не в __all__ пакета. # macro_series, хоть и не в __all__ пакета.
from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months 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__) logger = logging.getLogger(__name__)
@ -357,7 +358,10 @@ _SOURCE_A_SQL = text(
FROM objective_corpus_room_month crm FROM objective_corpus_room_month crm
WHERE crm.report_month >= CAST(:since AS date) 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(: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 ( AND (
CAST(:room_bucket AS text) IS NULL CAST(:room_bucket AS text) IS NULL
OR crm.room_bucket = CAST(:room_bucket AS text) 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 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(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text)))
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[]))
) )
) )
SELECT SELECT
@ -534,10 +538,14 @@ def _query_source_a(
"(monthly aggregate carries no per-lot price)", "(monthly aggregate carries no per-lot price)",
spec.price_bucket, spec.price_bucket,
) )
# district (админ-имя ЕКБ) → набор informal микро (crm.district хранит микро —
# тот же вокабуляр, что objective_lots; verified на prod). None → EKB-wide.
micros = resolve_objective_districts(db, spec.district)
params = { params = {
"since": since, "since": since,
"cls": spec.obj_class, "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, "room_bucket": spec.room_bucket,
} }
try: try:
@ -557,11 +565,15 @@ def _query_source_b(
(зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же (зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же
правилом и в БД, и в Python. правилом и в БД, и в Python.
""" """
# district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро).
# None → EKB-wide (без district-фильтра).
micros = resolve_objective_districts(db, spec.district)
params = { params = {
"since": since, "since": since,
"premise_kind": premise_kind, "premise_kind": premise_kind,
"cls": spec.obj_class, "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, "room_bucket": spec.room_bucket,
"price_bucket": spec.price_bucket, "price_bucket": spec.price_bucket,
# bucket-пороги (зеркало _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*). # 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 sqlalchemy.orm import Session
from app.services.analytics_queries import _elasticity_coef from app.services.analytics_queries import _elasticity_coef
from app.services.site_finder.district_resolver import resolve_objective_districts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -260,8 +261,8 @@ _STOCK_SQL = text(
FROM objective_lots ol FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind WHERE ol.premise_kind = :premise_kind
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(:has_obj_ids AS boolean) IS FALSE CAST(:has_obj_ids AS boolean) IS FALSE
@ -300,8 +301,8 @@ _SALES_WINDOW_SQL = text(
FROM objective_lots ol FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind WHERE ol.premise_kind = :premise_kind
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(:has_obj_ids AS boolean) IS FALSE 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 [] obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
has_obj_ids = bool(obj_id_list) 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] = { params: dict[str, Any] = {
"premise_kind": premise_kind, "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, "has_obj_ids": has_obj_ids,
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет. # ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
"obj_ids": obj_id_list, "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) confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
logger.info( logger.info(
"market_metrics: district=%s obj_ids=%d n_lots=%d n_sold=%d n_available=%d " "market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
"obj_count=%d units_sold_window=%d confidence=%s", "n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
district, district,
micros,
len(obj_id_list), len(obj_id_list),
n_lots, n_lots,
n_sold_total, n_sold_total,
@ -417,7 +427,9 @@ def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
try: try:
row = db.execute(_STOCK_SQL, dict(params)).mappings().first() row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
except Exception: 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 row = None
if row is None: if row is None:
return { return {
@ -449,7 +461,7 @@ def _query_sales_window(
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all() rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
except Exception: except Exception:
logger.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 = [] rows = []

View file

@ -66,6 +66,8 @@ from typing import Any, Literal
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.services.site_finder.district_resolver import resolve_objective_districts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
Confidence = Literal["high", "medium", "low"] Confidence = Literal["high", "medium", "low"]
@ -192,8 +194,8 @@ _L1_OPEN_SQL = text(
FROM objective_lots ol FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind WHERE ol.premise_kind = :premise_kind
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[]))
) )
) )
SELECT SELECT
@ -296,8 +298,18 @@ def compute_layer1_open(
Источник `objective_lots` (выставленные лоты), переиспользует district-stock Источник `objective_lots` (выставленные лоты), переиспользует district-stock
паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке паттерн market_metrics. source='objective', confidence='high'. Возвращает по строке
на (district, obj_class) с доступными лотами. Пустые данные []. Не бросает. на (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) rows = _safe_rows(db, _L1_OPEN_SQL, params, layer=1)
out: list[SupplyLayerRow] = [] out: list[SupplyLayerRow] = []
@ -484,7 +496,7 @@ def _safe_rows(
try: try:
return db.execute(sql, dict(params)).mappings().all() return db.execute(sql, dict(params)).mappings().all()
except Exception: except Exception:
logger.exception( # L1 фильтрует по resolved-микро (`districts`); L2/L3 — по админ `district`.
"supply_layers: L%d query failed (district=%s)", layer, params.get("district") district_dbg = params.get("district", params.get("districts"))
) logger.exception("supply_layers: L%d query failed (district=%s)", layer, district_dbg)
return [] return []

View file

@ -18,7 +18,10 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import math import math
import os 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") 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, 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 ───────────────────────────────────────────────────── # ── pure: price_bucket_of ─────────────────────────────────────────────────────
@ -365,7 +383,8 @@ class TestBuildSalesSeriesSourceShape:
sql = _sql_of(db) sql = _sql_of(db)
assert "CAST(:since AS date)" in sql assert "CAST(:since AS date)" in sql
assert "CAST(:cls AS text)" 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 assert "CAST(:room_bucket AS text)" in sql
# psycopg v3 trap: никаких :name::type. # psycopg v3 trap: никаких :name::type.
assert "::" not in sql assert "::" not in sql
@ -419,7 +438,10 @@ class TestBuildSalesSeriesSourceShape:
) )
params = _params_of(db) params = _params_of(db)
assert params["cls"] == "Комфорт" 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 params["room_bucket"] == "1"
assert isinstance(params["since"], dt.date) assert isinstance(params["since"], dt.date)
@ -643,3 +665,61 @@ class TestBuildSalesSeriesGraceful:
) )
assert out.n_months == 1 # только текущий месяц assert out.n_months == 1 # только текущий месяц
assert out.units == [0] 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 from __future__ import annotations
import os import os
from collections.abc import Iterator
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services.site_finder.market_metrics import ( 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" _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 ─────────────────────────────────────────────────────── # ── pure: _monthly_rate ───────────────────────────────────────────────────────
@ -338,13 +357,14 @@ class TestComputeMarketMetricsShape:
compute_market_metrics(db, district="Автовокзал") compute_market_metrics(db, district="Автовокзал")
stock_sql = _executed_sql(db, 0) stock_sql = _executed_sql(db, 0)
sales_sql = _executed_sql(db, 1) sales_sql = _executed_sql(db, 1)
# psycopg v3: CAST(:x AS type), НЕ :x::type # psycopg v3: CAST(:x AS type), НЕ :x::type. District-фильтр теперь массив микро.
assert "CAST(:district AS text)" in stock_sql assert "CAST(:districts AS text[])" in stock_sql
assert "CAST(:obj_ids AS bigint[])" in stock_sql assert "CAST(:obj_ids AS bigint[])" in stock_sql
assert "CAST(:overstock_interval AS interval)" 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 assert "CAST(:window_interval AS interval)" in sales_sql
for sql in (stock_sql, 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 ":obj_ids::" not in sql
assert ":window_interval::" not in sql assert ":window_interval::" not in sql
assert ":overstock_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"}): with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
compute_market_metrics(db, district="Автовокзал", premise_kind="квартира") compute_market_metrics(db, district="Автовокзал", premise_kind="квартира")
p = _executed_params(db, 0) 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["premise_kind"] == "квартира"
assert p["has_obj_ids"] is False assert p["has_obj_ids"] is False
assert p["obj_ids"] == [] assert p["obj_ids"] == []
@ -415,6 +439,67 @@ class TestComputeMarketMetricsShape:
assert m.price_sensitivity_source is None 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: class TestComputeMarketMetricsThinData:
"""Graceful-on-thin-data: empty / zero → None metrics + low confidence, no crash.""" """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 from __future__ import annotations
import os import os
from collections.abc import Iterator
from datetime import date 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") 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_SOURCES = {"objective", "domrf_declared", "domrf_multiphase"}
_ALLOWED_CONFIDENCE = {"high", "medium", "low"} _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) ──────────────────────────────────────────── # ── pure: _hidden_units (L2 clamp) ────────────────────────────────────────────
@ -205,14 +225,18 @@ class TestLayer1Open:
db = _mock_db([]) db = _mock_db([])
compute_layer1_open(db, district="Автовокзал") compute_layer1_open(db, district="Автовокзал")
sql = _executed_sql(db) 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) _assert_no_double_colon_cast(sql)
def test_params(self) -> None: def test_params(self) -> None:
db = _mock_db([]) db = _mock_db([])
compute_layer1_open(db, district="Автовокзал", premise_kind="квартира") compute_layer1_open(db, district="Автовокзал", premise_kind="квартира")
p = _executed_params(db) 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"] == "квартира" assert p["premise_kind"] == "квартира"
def test_rows_built_with_whitelisted_values(self) -> None: def test_rows_built_with_whitelisted_values(self) -> None:
@ -271,6 +295,36 @@ class TestLayer1Open:
assert compute_layer1_open(db, district="Сбой") == [] 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 ──────────────────────────────────────────────── # ── Layer2 (hidden) — THE CRUX ────────────────────────────────────────────────
@ -325,6 +379,17 @@ class TestLayer2Hidden:
assert p["region_cd"] == 66 assert p["region_cd"] == 66
assert p["district"] == "Автовокзал" 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: def test_hidden_aggregate_medium_confidence(self) -> None:
rows = [ rows = [
{ {