"""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. SAVEPOINT НА ВНУТРЕННЕМ swallow-сайте (#2464 cluster A, RELEASE-trap): резолвер зовётся §22-форсайт-билдерами (sales_series `_query_source_a/_b`, `_query_artificial_demand`) на ОБЩЕЙ сессии отчёта, которые сами обёрнуты во внешние SAVEPOINT'ы. БЕЗ `db.begin_nested():` здесь сбойный `db.execute` проглатывается (→ frozenset()), оставляя транзакцию aborted; тогда внешний SAVEPOINT при RELEASE падает (RELEASE запрещён в aborted-tx) и отравление каскадит. Savepoint в точке перехвата откатывает сбой (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей. """ global _ADMIN_NAMES_CACHE if _ADMIN_NAMES_CACHE is not None: return _ADMIN_NAMES_CACHE try: with db.begin_nested(): 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 — официальное админ-имя: разворачиваем в чистые ЕКБ-микро. # SAVEPOINT на внутреннем swallow-сайте (#2464 cluster A, RELEASE-trap): см. # `_admin_names` — тот же общий-сессионный вызов из §22-билдеров под внешним # SAVEPOINT. `db.begin_nested():` откатывает сбой в точке перехвата, чтобы # внешний RELEASE не упал в aborted-tx (RELEASE запрещён в aborted). try: with db.begin_nested(): 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