"""Gate Verdict aggregator — собирает can-build-MKD signal из NSPD lookups. Per #32 G5: user-facing answer на главный вопрос девелопера "Можно ли тут МКД?" Foundation: nspd_zoning, nspd_zouit_overlaps, nspd_engineering_nearby (Sprint 1.1). Pure function — no DB dependencies, consumes already-fetched data from analyze_parcel. """ from __future__ import annotations import re from typing import Any, Literal, TypedDict from app.core.config import settings from app.services.site_finder.network_obremenenie import ( classify_network_zone, network_kind_label, ) # ── Zone matching ───────────────────────────────────────────────────────────── # Regex для zone_code из PKK6 / pzz_zones_ekb: "Ж", "Ж-1", "Жс", "ЖС-2" и т.д. # Кейс-инсенситив для защиты от "ж-1" в нестандартных источниках. # NSPD quarter-dump в zone_code кладёт кадастровый рег.номер (напр. "66:41-7.14") — # он никогда не начинается на "Ж", поэтому этот regex не даст false positive. _RESIDENTIAL_CODE_RE = re.compile(r"^[Жж]", re.UNICODE) # Fallback по zone_name (lowercase substring) — "жилая", "жилой", "жилая зона" и т.д. RESIDENTIAL_KEYWORDS = ("жил",) # ── main_vri (authoritative permitted-uses) ──────────────────────────────────── # # zone_regulation_cache.main_vri несёт авторитетный список разрешённых ВРИ зоны # (JSON list of strings, напр. "2.5 Среднеэтажная жилая застройка"). Это единственный # источник, точно различающий ИЖС-зону (Ж-1/Ж-2, только код 2.1/2.2 — МКД нельзя) от # МКД-зоны (Ж-3/Ж-4/Ж-5/Ц-2). regex ^Ж и subcategory — лишь эвристики: # - Ж-1/Ж-2 = ИЖС → ^Ж даёт FALSE POSITIVE ("можно МКД" на ИЖС — реальный дефект) # - Ц-2 разрешает МКД (коды 2.5/2.6), но нет префикса Ж → ^Ж даёт FALSE NEGATIVE # МКД разрешён ⟺ main_vri содержит любой из: 2.1.1 (Малоэтажная многоквартирная), # 2.5 (Среднеэтажная жилая), 2.6 (Многоэтажная жилая). Проверено на проде (true-set # Ж-3/Ж-4/Ж-5/Ц-2; false: Ж-1/Ж-2/Ц-1/Ц-3/Ц-4/ЦС-1..4). _MKD_VRI_CODES: frozenset[str] = frozenset({"2.1.1", "2.5", "2.6"}) def mkd_permitted_from_vri(main_vri: list[str] | None) -> bool | None: """Authoritative МКД-permission из main_vri (список разрешённых ВРИ зоны). Returns: None — main_vri отсутствует/пуст (нет информации, нужен fallback); True — хотя бы один элемент имеет ведущий код-токен из _MKD_VRI_CODES; False — иначе (ВРИ есть, но МКД-кодов среди них нет). Матчинг по ТОЧНОМУ дотированному код-токену: элемент выглядит как "2.5 Среднеэтажная жилая застройка" или "2.1.1\\n Малоэтажная многоквартирная …", иногда просто "2.5". Берём первый whitespace-разделённый токен и сравниваем на равенство с set — поэтому "2.50"/"2.59"/"12.0" НЕ матчат "2.5" (нет ложного prefix-совпадения). """ if not main_vri: return None for elem in main_vri: if not elem: continue # Первый whitespace-разделённый токен (split разбивает по \n/\t/пробелам). token = str(elem).split(None, 1)[0] if str(elem).split() else "" if token in _MKD_VRI_CODES: return True return False # NSPD territorial zones layer 875838: subcategory (int) → тип зоны. # Значения получены из реальных данных nspd_quarter_dumps (ЕКБ, 2026-02): # subcategory=1 — 39 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы) # subcategory=2 — 221 объект — Жилые зоны (МКД разрешён, крупнейшая группа ЕКБ) # subcategory=3 — 126 объектов — Зоны смешанного использования (МКД по ПЗЗ ЕКБ допустим) # subcategory=4 — 77 объектов — Общественно-деловые # subcategory=5 — 112 объектов — Производственные # subcategory=6 — 92 объекта — Рекреационные # subcategory=8 — 16 объектов — Специальные # subcategory=10 — 113 объектов — Промышленные/транспортные # subcategory=1 исключён намеренно: ИЖС-зона МКД не допускает. NSPD_RESIDENTIAL_SUBCATEGORIES: frozenset[int] = frozenset({2, 3}) # ── ЗОУИТ taxonomy ──────────────────────────────────────────────────────────── # Subcategory codes охранных зон инж.сетей (ЛЭП/газа/трубопровода) из NSPD dump. # ВНИМАНИЕ: это НЕ безусловные блокеры — они AREA-GATED в compute_gate_verdict # (блокируют МКД только при суммарном покрытии участка > порога settings, иначе # warning ZOUIT_ENGINEERING_PARTIAL). Эти зоны ограничивают застройку ВНУТРИ полосы, # а не стерилизуют весь участок. dict-значение — человекочитаемый ярлык подкатегории. BLOCKER_SUBCATEGORIES: dict[int, str] = { 17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)", } # Keyword-based blockers для cad_zouit fallback (#232). # cad_zouit.subcategory = 100% NULL, поэтому классификация по type_zone substring. # Порядок: lowercase, substring match (any wins → blocker). BLOCKER_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ( "охранная зона", "трубопровод", "электр", "газ", ) # type_zone substrings, которые дают warning (не blocker) из cad_zouit. WARNING_TYPE_ZONE_KEYWORDS: tuple[str, ...] = ("сзз", "санитарно-защитная") # Engineering nearby search radius (метры) — совпадает с quarter_dump_lookup.py ENGINEERING_NEARBY_THRESHOLD_M = 200 # Warning codes that are purely informational — they appear in warnings[] for the user # but do NOT downgrade the final verdict from «Можно» to «С ограничениями» (#1617). # A clean residential parcel with only these warnings still gets verdict_label="Можно". INFORMATIONAL_WARNING_CODES: frozenset[str] = frozenset({"NO_ENGINEERING_NEARBY"}) # ── TypedDicts ──────────────────────────────────────────────────────────────── class Blocker(TypedDict): code: str detail: str class Warning(TypedDict): code: str detail: str class GateVerdict(TypedDict): can_build_mkd: bool | Literal["unknown"] verdict_label: str # "Можно" | "Нельзя" | "С ограничениями" | "Нужна проверка" blockers: list[Blocker] warnings: list[Warning] checks_performed: list[str] source: Literal["nspd_dump", "nspd_dump_partial", "no_data"] # ── Helpers ─────────────────────────────────────────────────────────────────── def is_residential_zone( zone_code: str | None, zone_name: str | None, raw_props: dict | None = None, main_vri: list[str] | None = None, ) -> bool: """Check if ПЗЗ zone permits residential (МКД) building. Detection strategy (in priority order): 0. main_vri (AUTHORITATIVE) — авторитетный список разрешённых ВРИ зоны из zone_regulation_cache. Если непустой → решение принимается ТОЛЬКО по нему (mkd_permitted_from_vri), перебивая regex/subcategory/keyword. Это и подавляет FALSE POSITIVE на ИЖС (Ж-1/Ж-2), и включает FALSE NEGATIVE (Ц-2 без префикса Ж). 1. zone_code regex «^Ж» — ПЗЗ коды PKK6/pzz_zones_ekb: "Ж-1", "Жс", "ЖС-2" и т.д. NSPD quarter-dump кладёт в zone_code кадастровый рег.номер ("66:41-7.14") — он не начинается на «Ж», ложных срабатываний нет. 2. NSPD subcategory из raw_props — единственный надёжный тип-дискриминатор в реальных данных nspd_quarter_dumps (layer 875838, ЕКБ 2026-02): subcategory=2 ("Жилые зоны") и subcategory=3 ("Смешанное использование") допускают МКД по ПЗЗ ЕКБ. subcategory=1 (ИЖС) — намеренно исключён. 3. zone_name substring «жил» — fallback для нестандартных источников. Эвристики 1-3 применяются ТОЛЬКО как fallback когда main_vri None/пуст (dump-путь без geoportal main_vri). main_vri по умолчанию None — backward-compat для существующих вызовов/тестов. """ # 0. main_vri — authoritative permitted-uses (overrides all heuristics) if main_vri: verdict = mkd_permitted_from_vri(main_vri) if verdict is not None: return verdict # 1. PKK6-style zone_code: starts with Ж (case-insensitive) if zone_code and _RESIDENTIAL_CODE_RE.match(zone_code): return True # 2. NSPD subcategory from raw_props (primary discriminator for quarter-dump data) if raw_props: sub_raw = raw_props.get("subcategory") if sub_raw is not None: try: if int(sub_raw) in NSPD_RESIDENTIAL_SUBCATEGORIES: return True except (ValueError, TypeError): pass # 3. zone_name substring fallback if zone_name: zn_lower = zone_name.lower() if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS): return True return False # ── Area-gate helpers (engineering/utility-easement ЗОУИТ) ──────────────────── def _coverage_of(overlap: dict[str, Any]) -> float: """coverage_pct overlap'а как float 0..1; отсутствует/None/нечисловое → 0.0. Legacy/synthetic overlaps без ключа `coverage_pct` трактуются как 0.0 → warning, а НЕ blocker. Это намеренно: dump-путь (_get_zouit_overlaps / _get_cad_zouit_overlaps) теперь ВСЕГДА проставляет coverage_pct, поэтому отсутствие ключа = синтетический/ устаревший overlap без гео-данных. Трактовка «0 → warning» рискует НЕ заблокировать реальную полнопокровную охранную зону, у которой нет coverage — но без площади мы и не можем доказать стерилизацию, а ложный hard-block на тонкой полосе (исходный баг) дороже: он прячет финмодель у 90% участков. Порог конфигурируем, dump всегда даёт %. """ raw = overlap.get("coverage_pct") if raw is None: return 0.0 try: val = float(raw) except (TypeError, ValueError): return 0.0 if val != val: # NaN return 0.0 return max(0.0, min(1.0, val)) def _aggregate_coverage(overlaps: list[dict[str, Any]]) -> float: """Суммарное покрытие участка группой ЗОУИТ-overlaps, clamp в [0, 1]. Сумма (а не union) — намеренно КОНСЕРВАТИВНАЯ переоценка: перекрывающиеся полосы считаются дважды, поэтому агрегат ≥ истинного union → ошибка В СТОРОНУ блокировки (safe — скорее заблокируем спорный, чем пропустим стерилизованный). Clamp 1.0 не даёт сумме уйти за 100%. Так участок с множеством мелких полос, дающих в сумме > порога, блокируется; и одна крупная полоса > порога блокирует. """ return min(1.0, sum(_coverage_of(o) for o in overlaps)) def _coverage_pct_label(coverage: float) -> int: """Округлить долю 0..1 до целого процента для человекочитаемой detail-строки.""" return round(coverage * 100) # ── Main aggregator ─────────────────────────────────────────────────────────── def compute_gate_verdict( nspd_zoning: dict | None, nspd_zouit_overlaps: list[dict] | None, nspd_engineering_nearby: list[dict] | None, nspd_dump: dict | None, ) -> GateVerdict: """Aggregate NSPD lookups into can-build-MKD verdict. Checks performed (in order): 1. ПЗЗ residential zone match (blocker if non-residential) 2. ЗОУИТ overlaps — охранные зоны инж.сетей (NSPD sub-17 / cad_zouit сетевое обременение / keyword-blocker) AREA-GATED: блокируют МКД только при суммарном покрытии участка > settings.gate_zouit_engineering_blocker_min_coverage, иначе → warning ZOUIT_ENGINEERING_PARTIAL. СЗЗ и прочие ЗОУИТ → warnings как раньше. 3. Engineering nearby presence check (warning-only, not a blocker) Returns GateVerdict с blockers/warnings/checks_performed и source label. """ blockers: list[Blocker] = [] warnings: list[Warning] = [] checks: list[str] = [] # Guard: no NSPD data at all dump_available = (nspd_dump or {}).get("available", False) dump_stale = (nspd_dump or {}).get("stale", False) if not dump_available: return GateVerdict( can_build_mkd="unknown", verdict_label="Нужна проверка", blockers=[], warnings=[ Warning( code="NO_NSPD_DUMP", detail="Данные NSPD ещё не подгружены", ) ], checks_performed=[], source="no_data", ) source: Literal["nspd_dump", "nspd_dump_partial"] = ( "nspd_dump_partial" if dump_stale else "nspd_dump" ) # Check 1 — ПЗЗ residential zone checks.append("ПЗЗ зональность") if nspd_zoning: if not is_residential_zone( nspd_zoning.get("zone_code"), nspd_zoning.get("zone_name"), nspd_zoning.get("raw_props"), nspd_zoning.get("main_vri"), ): zone_label = ( nspd_zoning.get("zone_name") or nspd_zoning.get("zone_code") or "неизвестна" ) blockers.append( Blocker( code="PZZ_NOT_RESIDENTIAL", detail=f"Зона: {zone_label}", ) ) else: warnings.append( Warning( code="PZZ_UNKNOWN", detail="ПЗЗ зона не определена", ) ) # Check 2 — ЗОУИТ overlaps # # Охранные зоны инж.сетей (ЛЭП/газ/трубопровод/тепло/электро) — AREA-GATED: они # ограничивают застройку ВНУТРИ полосы, а не стерилизуют весь участок, поэтому # блокируют МКД только при суммарном покрытии > порога (settings), иначе → warning. # Накапливаем такие overlaps в bucket'ы и решаем ОДИН blocker/warning на группу # (no more 4× дублирующих блокеров на тонких полосах). СЗЗ/прочие ЗОУИТ остаются # per-overlap warnings как раньше. checks.append("ЗОУИТ пересечения") threshold = settings.gate_zouit_engineering_blocker_min_coverage # NSPD-dump path: sub-17 (охранная зона инж.коммуникаций) overlaps. sub17_overlaps: list[dict[str, Any]] = [] # cad_zouit path: сетевое обременение + keyword-blocker (утилитарная охранная зона). cad_utility_overlaps: list[dict[str, Any]] = [] # Подпись вида сети для cad-detail (первый встреченный network_kind). cad_utility_label: str | None = None for overlap in nspd_zouit_overlaps or []: src = overlap.get("source", "nspd-quarter-dump") if src == "cad_zouit": # cad_zouit fallback path: classify by type_zone keywords (#232). # subcategory = NULL в cad_zouit, поэтому subcategory-based logic не применяется. type_zone_lower = (overlap.get("type_zone") or overlap.get("layer") or "").lower() # #1070: классификация «сетевого обременения» (охранная зона инж.сети). # Флаг приходит из _get_cad_zouit_overlaps (is_network_zone/network_kind); # если overlap из старого пути без флага — определяем здесь же. net_kind = overlap.get("network_kind") if net_kind is None and overlap.get("is_network_zone") is None: net_kind = classify_network_zone(overlap.get("type_zone") or overlap.get("layer")) # Порядок веток: СЗЗ-warning ПЕРВЫМ (#1341) — маркер 'сзз'/'санитарно-защитная' # специфичнее широких blocker-подстрок ('газ'/'электр'), СЗЗ газораспределительной # станции/электроподстанции = ограничение, а не hard-blocker. Затем сетевое # обременение (#1070) и общие blocker-keywords — оба AREA-GATED (копим в bucket), # иначе — generic warning. if any(kw in type_zone_lower for kw in WARNING_TYPE_ZONE_KEYWORDS): warnings.append( Warning( code="ZOUIT_CAD_SZZ", detail=( f"СЗЗ ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}" ), ) ) elif net_kind is not None or any( kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS ): # Утилитарная охранная зона — копим для area-gate (см. ниже). cad_utility_overlaps.append(overlap) if cad_utility_label is None and net_kind is not None: cad_utility_label = overlap.get("network_kind_label") or network_kind_label( net_kind ) else: warnings.append( Warning( code="ZOUIT_CAD_OTHER", detail=( f"ЗОУИТ cad ({overlap.get('type_zone', '')}): " f"{overlap.get('name', '')}" ), ) ) else: # NSPD dump path: subcategory-based logic (backward-compat). # subcategory из _get_zouit_overlaps = `subcategory or type_zone` → может быть # строкой ('17') или fallback на type_zone (#1360). Нормализуем к int как # _get_zouit_engineering_overlaps, иначе sub-17 тихо деградирует. sub_raw = overlap.get("subcategory") sub: int | None if isinstance(sub_raw, bool): sub = None else: try: sub = int(sub_raw) # type: ignore[arg-type] except (ValueError, TypeError): sub = None if sub is not None and sub in BLOCKER_SUBCATEGORIES: # Охранная зона инж.коммуникаций — копим для area-gate (см. ниже). sub17_overlaps.append(overlap) else: # Сохраняем исходную метку (str/int) в warning-коде если нормализация # не дала int — иначе теряется человекочитаемый ярлык subcategory. if sub is not None: sub_label: int | str = sub elif sub_raw is not None: sub_label = sub_raw else: sub_label = "unknown" warnings.append( Warning( code=f"ZOUIT_SUB{sub_label}", detail=f"ЗОУИТ {overlap.get('layer', '')}: {overlap.get('name', '')}", ) ) # Area-gate aggregation — NSPD sub-17 (охранная зона инж.сетей). if sub17_overlaps: coverage = _aggregate_coverage(sub17_overlaps) pct = _coverage_pct_label(coverage) if coverage > threshold: blockers.append( Blocker( code="ZOUIT_OVERLAP_SUB17", detail=( f"Охранная зона инж.сетей покрывает {pct}% участка — " f"застройка МКД невозможна" ), ) ) else: warnings.append( Warning( code="ZOUIT_ENGINEERING_PARTIAL", detail=( f"Охранная зона инж.сетей (ЛЭП/газ/трубопровод) на {pct}% участка — " f"учесть при посадке МКД" ), ) ) # Area-gate aggregation — cad_zouit утилитарная охранная зона (сеть/keyword-blocker). if cad_utility_overlaps: coverage = _aggregate_coverage(cad_utility_overlaps) pct = _coverage_pct_label(coverage) # Код-различение сетевого обременения (#1070) vs общего охранного keyword-blocker: # blocker'у с network_kind отдаём ZOUIT_NETWORK_OBREMENENIE, иначе ZOUIT_CAD_BLOCKER. is_network = cad_utility_label is not None if coverage > threshold: if is_network: blockers.append( Blocker( code="ZOUIT_NETWORK_OBREMENENIE", detail=( f"Сетевое обременение ({cad_utility_label}) покрывает {pct}% " f"участка — застройка МКД невозможна" ), ) ) else: blockers.append( Blocker( code="ZOUIT_CAD_BLOCKER", detail=( f"Охранная зона инж.сетей покрывает {pct}% участка — " f"застройка МКД невозможна" ), ) ) else: warnings.append( Warning( code="ZOUIT_ENGINEERING_PARTIAL", detail=( f"Охранная зона инж.сетей (ЛЭП/газ/трубопровод) на {pct}% участка — " f"учесть при посадке МКД" ), ) ) # Check 3 — Engineering nearby (warning only, not a blocker) checks.append(f"Инженерные сети в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м") if not (nspd_engineering_nearby or []): warnings.append( Warning( code="NO_ENGINEERING_NEARBY", detail=f"Сетей не найдено в радиусе {ENGINEERING_NEARBY_THRESHOLD_M}м", ) ) # Final verdict. # Informational warnings (e.g. NO_ENGINEERING_NEARBY) appear in warnings[] for display # but do NOT by themselves downgrade a clean parcel to «С ограничениями» (#1617). verdict_relevant_warnings = [ w for w in warnings if w["code"] not in INFORMATIONAL_WARNING_CODES ] can_build: bool | Literal["unknown"] if blockers: can_build = False label = "Нельзя" elif verdict_relevant_warnings: can_build = True label = "С ограничениями" else: can_build = True label = "Можно" return GateVerdict( can_build_mkd=can_build, verdict_label=label, blockers=blockers, warnings=warnings, checks_performed=checks, source=source, )