ZOUIT_OVERLAP_SUB17 (единственный subcategory-blocker = охранная зона ЛЭП/газа/трубопровода) был ХАРД-блокером на ЛЮБОЕ пересечение → блокировал 434/574 (75.6%) участков как «нельзя строить МКД». Прод-замер: реальное покрытие участка этими зонами median=6%, p90=47%, только 1/17 >60% — тонкие инж-коридоры. По РФ охранная зона ограничивает застройку ВНУТРИ полосы, а не весь участок (МКД сажается на незанятой части). Хард-блок на 6%-покрытии неверен и (после фикса резолва зон #1891) прятал financial_estimate у ~90% участков. Фикс: area-gate. - quarter_dump_lookup._get_zouit_overlaps + _get_cad_zouit_overlaps: добавлен coverage_pct (доля площади участка под зоной, в geography м², NULLIF-guard). - gate_verdict.compute_gate_verdict: sub-17 (и cad network/blocker) АГРЕГИРУЮТСЯ (sum-capped, консервативно к блоку) → блокер ТОЛЬКО если покрытие > settings.gate_zouit_engineering_blocker_min_coverage (0.6, env-tunable), иначе WARNING ZOUIT_ENGINEERING_PARTIAL с % покрытия. Больше нет 4× дубль-блокеров. СЗЗ/OKN/прочие ЗОУИТ — без изменений (warnings). main_vri/резидентность — без изменений. - coverage_pct отсутствует (legacy/synthetic) → трактуем как 0 → warning (документировано: дамп всегда даёт coverage; ложный хард-блок дороже). Тесты: +помесь gate/aggregation/coverage (131 combined passed); правка _make_zouit_row (7-я колонка coverage_pct) в tests/test_quarter_dump_lookup.py. Полный бэкенд локально: 2 failed → fixed → перепроверка. ruff чисто. Схема не менялась (gate_verdict/overlaps — dict[str,Any], api-types regen не нужен). Refs #1881 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
508 lines
26 KiB
Python
508 lines
26 KiB
Python
"""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,
|
||
)
|