gendesign/backend/app/services/site_finder/gate_verdict.py
bot-backend 479f01ae1a
Some checks failed
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (push) Successful in 1m7s
CI / openapi-codegen-check (push) Failing after 2m9s
CI / frontend-tests (pull_request) Successful in 1m4s
CI / openapi-codegen-check (pull_request) Failing after 1m37s
CI / backend-tests (pull_request) Failing after 9m7s
CI / backend-tests (push) Failing after 9m11s
Merge remote-tracking branch 'forgejo/main' into HEAD
# Conflicts:
#	backend/app/services/site_finder/gate_verdict.py
2026-06-15 21:07:05 +03:00

281 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
from typing import Literal, TypedDict
from app.services.site_finder.network_obremenenie import (
classify_network_zone,
network_kind_label,
)
# ── Zone matching ─────────────────────────────────────────────────────────────
# Residential zone codes — точные ПЗЗ Свердловска (Ж-1..Ж-5)
RESIDENTIAL_ZONE_PREFIXES = ("Ж-", "Ж1", "Ж2", "Ж3", "Ж4", "Ж5")
# Fallback по zone_name (lowercase substring)
RESIDENTIAL_KEYWORDS = ("жил",)
# ── ЗОУИТ taxonomy ────────────────────────────────────────────────────────────
# Subcategory codes которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода).
# Используется для overlaps из NSPD dump (subcategory INT там заполнен).
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
# ── 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) -> bool:
"""Check if ПЗЗ zone permits residential (МКД) building.
Checks zone_code prefix first (exact ПЗЗ codes), then zone_name lowercase
substring match as fallback for non-standard zone labels.
"""
if zone_code:
if any(zone_code.startswith(p) for p in RESIDENTIAL_ZONE_PREFIXES):
return True
if zone_name:
zn_lower = zone_name.lower()
if any(kw in zn_lower for kw in RESIDENTIAL_KEYWORDS):
return True
return False
# ── 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. ЗОУИТ subcategory 17 overlap (blocker); other subcategories → 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")):
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
checks.append("ЗОУИТ пересечения")
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, иначе — 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:
kind_label = overlap.get("network_kind_label") or network_kind_label(net_kind)
blockers.append(
Blocker(
code="ZOUIT_NETWORK_OBREMENENIE",
detail=(
f"Сетевое обременение ({kind_label}): "
f"{overlap.get('type_zone', '')} {overlap.get('name', '')}".strip()
),
)
)
elif any(kw in type_zone_lower for kw in BLOCKER_TYPE_ZONE_KEYWORDS):
blockers.append(
Blocker(
code="ZOUIT_CAD_BLOCKER",
detail=(
f"Охранная зона ({overlap.get('type_zone', '')}): "
f"{overlap.get('name', '')}"
),
)
)
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, иначе blocker subcategory-17 тихо
# деградирует до warning.
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:
blockers.append(
Blocker(
code=f"ZOUIT_OVERLAP_SUB{sub}",
detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}",
)
)
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', '')}",
)
)
# 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
can_build: bool | Literal["unknown"]
if blockers:
can_build = False
label = "Нельзя"
elif 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,
)