Pure-function aggregator collapses nspd_zoning / nspd_zouit_overlaps / nspd_engineering_nearby / nspd_dump в один GateVerdict TypedDict. Logic: - ПЗЗ не Ж-* → BLOCKER - ЗОУИТ sub=17 (инжен. охранная) → BLOCKER - Другие ЗОУИТ → WARNING - Нет инжен. сетей в 200м → WARNING - nspd_dump stale → source: nspd_dump_partial - Нет dump → can_build_mkd: 'unknown' Integration: новое поле gate_verdict в analyze_parcel response. Tests: 15/15 pass (mock-based). Vault: code/modules/Module_Gate_Verdict.md NEW. Closes #140 (sub-PR 2 frontend закроет #32) Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
parent
b42c9dd8f5
commit
76a88b2827
3 changed files with 309 additions and 0 deletions
|
|
@ -22,6 +22,7 @@ from app.services.site_finder.cadastre_fetch import (
|
||||||
from app.services.site_finder.cadastre_fetch import (
|
from app.services.site_finder.cadastre_fetch import (
|
||||||
fetch_status as _fetch_status,
|
fetch_status as _fetch_status,
|
||||||
)
|
)
|
||||||
|
from app.services.site_finder.gate_verdict import compute_gate_verdict
|
||||||
from app.services.site_finder.quarter_dump_lookup import (
|
from app.services.site_finder.quarter_dump_lookup import (
|
||||||
get_quarter_dump_data,
|
get_quarter_dump_data,
|
||||||
make_empty_result,
|
make_empty_result,
|
||||||
|
|
@ -1870,6 +1871,13 @@ def analyze_parcel(
|
||||||
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
"nspd_zouit_overlaps": nspd_dump_data["nspd_zouit_overlaps"],
|
||||||
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
"nspd_engineering_nearby": nspd_dump_data["nspd_engineering_nearby"],
|
||||||
"nspd_dump": nspd_dump_data["nspd_dump"],
|
"nspd_dump": nspd_dump_data["nspd_dump"],
|
||||||
|
# #32 G5: gate verdict — can-build-MKD aggregated signal for UI banner
|
||||||
|
"gate_verdict": compute_gate_verdict(
|
||||||
|
nspd_zoning=nspd_dump_data["nspd_zoning"],
|
||||||
|
nspd_zouit_overlaps=nspd_dump_data["nspd_zouit_overlaps"],
|
||||||
|
nspd_engineering_nearby=nspd_dump_data["nspd_engineering_nearby"],
|
||||||
|
nspd_dump=nspd_dump_data["nspd_dump"],
|
||||||
|
),
|
||||||
# #114: кастомные веса POI — source + applied dict для прозрачности.
|
# #114: кастомные веса POI — source + applied dict для прозрачности.
|
||||||
"weights_profile": {
|
"weights_profile": {
|
||||||
"source": _weights_source,
|
"source": _weights_source,
|
||||||
|
|
|
||||||
184
backend/app/services/site_finder/gate_verdict.py
Normal file
184
backend/app/services/site_finder/gate_verdict.py
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
"""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
|
||||||
|
|
||||||
|
# ── 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 которые блокируют МКД (охранные зоны ЛЭП/газа/трубопровода)
|
||||||
|
BLOCKER_SUBCATEGORIES: dict[int, str] = {
|
||||||
|
17: "Инженерные коммуникации (охранная зона ЛЭП/газа/трубопровода)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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 []:
|
||||||
|
sub = overlap.get("subcategory")
|
||||||
|
if isinstance(sub, int) and sub in BLOCKER_SUBCATEGORIES:
|
||||||
|
blockers.append(
|
||||||
|
Blocker(
|
||||||
|
code=f"ZOUIT_OVERLAP_SUB{sub}",
|
||||||
|
detail=f"{BLOCKER_SUBCATEGORIES[sub]}: {overlap.get('name', '')}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
warnings.append(
|
||||||
|
Warning(
|
||||||
|
code=f"ZOUIT_SUB{sub if sub is not None else 'unknown'}",
|
||||||
|
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,
|
||||||
|
)
|
||||||
117
backend/tests/test_gate_verdict.py
Normal file
117
backend/tests/test_gate_verdict.py
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
"""Tests for gate_verdict aggregator — pure function, no DB."""
|
||||||
|
|
||||||
|
from app.services.site_finder.gate_verdict import compute_gate_verdict, is_residential_zone
|
||||||
|
|
||||||
|
# ── is_residential_zone unit tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_residential_zone_code_zh2():
|
||||||
|
assert is_residential_zone("Ж-2", None) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_residential_zone_code_zh5():
|
||||||
|
assert is_residential_zone("Ж5", None) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_residential_zone_name_fallback():
|
||||||
|
assert is_residential_zone(None, "Жилая зона смешанной застройки") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_residential_zone_code():
|
||||||
|
assert is_residential_zone("П-1", "Производственная П-1") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_residential_zone_recreation():
|
||||||
|
assert is_residential_zone("Р-2", "Рекреационная Р-2") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── compute_gate_verdict ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_dump_returns_unknown():
|
||||||
|
"""Если nspd_dump отсутствует или available=False → unknown + no_data."""
|
||||||
|
verdict = compute_gate_verdict(None, None, None, None)
|
||||||
|
assert verdict["can_build_mkd"] == "unknown"
|
||||||
|
assert verdict["source"] == "no_data"
|
||||||
|
assert verdict["verdict_label"] == "Нужна проверка"
|
||||||
|
assert any(w["code"] == "NO_NSPD_DUMP" for w in verdict["warnings"])
|
||||||
|
assert verdict["checks_performed"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_dump_available_false():
|
||||||
|
verdict = compute_gate_verdict(None, None, None, {"available": False})
|
||||||
|
assert verdict["can_build_mkd"] == "unknown"
|
||||||
|
assert verdict["source"] == "no_data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_residential_zone_no_overlaps_can_build():
|
||||||
|
"""Жилая зона + нет ЗОУИТ + есть сети → Можно."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая зона Ж-2"}
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-5"}], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is True
|
||||||
|
assert verdict["verdict_label"] == "Можно"
|
||||||
|
assert verdict["blockers"] == []
|
||||||
|
assert verdict["source"] == "nspd_dump"
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_zone_blocks():
|
||||||
|
"""Производственная зона → blocker PZZ_NOT_RESIDENTIAL → Нельзя."""
|
||||||
|
nspd_zoning = {"zone_code": "П-1", "zone_name": "Производственная П-1"}
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is False
|
||||||
|
assert verdict["verdict_label"] == "Нельзя"
|
||||||
|
assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_zouit_subcategory_17_blocks():
|
||||||
|
"""ЗОУИТ subcategory=17 (охранная зона ЛЭП) блокирует даже жилую зону."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-3", "zone_name": "Жилая"}
|
||||||
|
overlaps = [{"subcategory": 17, "name": "ЛЭП 110кВ", "layer": "37578"}]
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is False
|
||||||
|
assert any("ZOUIT_OVERLAP_SUB17" == b["code"] for b in verdict["blockers"])
|
||||||
|
assert "ЛЭП 110кВ" in verdict["blockers"][0]["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_zouit_non_blocker_subcategory_is_warning():
|
||||||
|
"""ЗОУИТ с subcategory != 17 → warning, не blocker."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||||
|
overlaps = [{"subcategory": 5, "name": "Придорожная полоса", "layer": "12345"}]
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, overlaps, [{"name": "ТП-1"}], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is True
|
||||||
|
assert verdict["verdict_label"] == "С ограничениями"
|
||||||
|
assert any("ZOUIT_SUB5" == w["code"] for w in verdict["warnings"])
|
||||||
|
assert verdict["blockers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_engineering_warning_not_blocker():
|
||||||
|
"""Отсутствие инженерных сетей в 200м — предупреждение, не блокер."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-2", "zone_name": "Жилая"}
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
|
||||||
|
assert verdict["can_build_mkd"] is True # warnings не блокируют
|
||||||
|
assert any(w["code"] == "NO_ENGINEERING_NEARBY" for w in verdict["warnings"])
|
||||||
|
assert verdict["blockers"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_dump_marks_source_partial():
|
||||||
|
"""stale=True → source='nspd_dump_partial'."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-1", "zone_name": "Жилая"}
|
||||||
|
dump = {"available": True, "stale": True}
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-3"}], dump)
|
||||||
|
assert verdict["source"] == "nspd_dump_partial"
|
||||||
|
|
||||||
|
|
||||||
|
def test_checks_performed_list():
|
||||||
|
"""checks_performed содержит все 3 проверки при наличии данных."""
|
||||||
|
nspd_zoning = {"zone_code": "Ж-1", "zone_name": "Жилая"}
|
||||||
|
verdict = compute_gate_verdict(nspd_zoning, [], [], {"available": True})
|
||||||
|
assert len(verdict["checks_performed"]) == 3
|
||||||
|
assert "ПЗЗ зональность" in verdict["checks_performed"]
|
||||||
|
assert "ЗОУИТ пересечения" in verdict["checks_performed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_pzz_unknown_warning_when_no_zoning():
|
||||||
|
"""Если nspd_zoning=None при наличии dump — PZZ_UNKNOWN warning."""
|
||||||
|
verdict = compute_gate_verdict(None, [], [{"name": "ТП-1"}], {"available": True})
|
||||||
|
assert any(w["code"] == "PZZ_UNKNOWN" for w in verdict["warnings"])
|
||||||
|
assert verdict["can_build_mkd"] is True
|
||||||
Loading…
Add table
Reference in a new issue