diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 2b480d1a..6e928144 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -22,6 +22,7 @@ from app.services.site_finder.cadastre_fetch import ( from app.services.site_finder.cadastre_fetch import ( 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 ( get_quarter_dump_data, make_empty_result, @@ -1870,6 +1871,13 @@ def analyze_parcel( "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"], + # #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 для прозрачности. "weights_profile": { "source": _weights_source, diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py new file mode 100644 index 00000000..209cbeda --- /dev/null +++ b/backend/app/services/site_finder/gate_verdict.py @@ -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, + ) diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py new file mode 100644 index 00000000..d5eb04e9 --- /dev/null +++ b/backend/tests/test_gate_verdict.py @@ -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