diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 00000000..1370b437 Binary files /dev/null and b/backend/.coverage differ diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 674f5366..1488cb92 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2120,6 +2120,12 @@ def analyze_parcel( _nspd_zoning["min_parcel_area_m2"] = _regulation.get("min_parcel_area_m2") _nspd_zoning["regulation_zone_index"] = _regulation.get("zone_index") _nspd_zoning["regulation_source"] = "ekb-geoportal-urbancard" + # #1891: авторитетный список разрешённых ВРИ зоны → gate.is_residential_zone + # принимает по нему МКД-решение (main_vri перебивает ^Ж regex/subcategory). + # Применяется к ОБОИМ путям: synth (gap-зона) И dump-augment (даёт dump-зоне + # авторитетный сигнал — чинит ИЖС-false-positive Ж-1/Ж-2 и Ц-2-false-negative). + # main_vri из regulation dict — уже python list (zone_regulation _SELECT_SQL). + _nspd_zoning["main_vri"] = _regulation.get("main_vri") logger.info( "zone regulation hit for %s: zone_index=%s max_far=%s synthesized=%s", cad_num, diff --git a/backend/app/services/site_finder/gate_verdict.py b/backend/app/services/site_finder/gate_verdict.py index 6981b992..239eaaec 100644 --- a/backend/app/services/site_finder/gate_verdict.py +++ b/backend/app/services/site_finder/gate_verdict.py @@ -26,6 +26,46 @@ _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 объектов — Жилые зоны малоэтажной застройки (ИЖС/таунхаусы) @@ -98,11 +138,17 @@ 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") — он не начинается на «Ж», ложных срабатываний нет. @@ -113,7 +159,17 @@ def is_residential_zone( допускают МКД по ПЗЗ ЕКБ. 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 @@ -188,6 +244,7 @@ def compute_gate_verdict( 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 "неизвестна" diff --git a/backend/tests/api/v1/test_analyze_zoning_regulation.py b/backend/tests/api/v1/test_analyze_zoning_regulation.py index 45a7733b..e0ddcbdd 100644 --- a/backend/tests/api/v1/test_analyze_zoning_regulation.py +++ b/backend/tests/api/v1/test_analyze_zoning_regulation.py @@ -56,6 +56,8 @@ _REGULATION = { } # PR-A synth fixtures: жилая зона Ж-2 (gate должен пустить МКД) с числами для финмодели. +# #1891: main_vri теперь авторитетный сигнал для gate — Ж-2 здесь МКД-зона (код 2.5), +# поэтому добавляем main_vri с МКД-кодом, чтобы synth→gate дал can_build_mkd=True. _REGULATION_ZH2 = { "zone_index": "Ж-2", "max_far": 2.5, @@ -63,6 +65,23 @@ _REGULATION_ZH2 = { "max_floors": 25, "max_building_pct": 40.0, "min_parcel_area_m2": 600.0, + "main_vri": ["2.5 Среднеэтажная жилая застройка", "2.6 Многоэтажная жилая застройка"], + "source": "ekb_geoportal", +} + +# #1891: ИЖС-зона — main_vri БЕЗ МКД-кодов (только 2.1/2.2). gate.is_residential_zone +# по main_vri должен дать False, подавляя ^Ж-regex false-positive (Ж-2 ИЖС ≠ МКД). +_REGULATION_IZHS = { + "zone_index": "Ж-2", + "max_far": 0.4, + "max_height_m": 12.0, + "max_floors": 3, + "max_building_pct": 30.0, + "min_parcel_area_m2": 400.0, + "main_vri": [ + "2.1 Для индивидуального жилищного строительства", + "2.2 Для ведения личного подсобного хозяйства (приусадебный земельный участок)", + ], "source": "ekb_geoportal", } @@ -322,7 +341,7 @@ def test_zoning_regulation_skipped_when_flag_off() -> None: def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None: - """(a) dump nspd_zoning=None + geoportal "Ж-2"+max_far → synth + gate видит жилую.""" + """(a) dump nspd_zoning=None + geoportal "Ж-2"+main_vri(МКД) → synth + gate видит жилую.""" from app.core.db import get_db db = _make_db_for_analyze() @@ -351,7 +370,9 @@ def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None: assert zoning["max_far"] == 2.5 assert zoning["regulation_zone_index"] == "Ж-2" assert zoning["regulation_source"] == "ekb-geoportal-urbancard" - # gate увидел synth-зону как жилую → can_build_mkd=True + # #1891: main_vri проброшен из regulation в synth-зону + assert "2.5 Среднеэтажная жилая застройка" in zoning["main_vri"] + # gate увидел synth-зону как жилую по main_vri → can_build_mkd=True assert body["gate_verdict"]["can_build_mkd"] is True finally: app.dependency_overrides.clear() @@ -359,6 +380,44 @@ def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None: p.stop() +def test_synth_izhs_main_vri_suppresses_residential_false_positive() -> None: + """#1891: dump None + geoportal "Ж-2" ИЖС (main_vri без МКД-кодов) → gate НЕ жилая. + + Раньше synth клал zone_code="Ж-2" → gate ^Ж regex давал can_build_mkd=True даже на + ИЖС-зоне (FALSE POSITIVE). Теперь main_vri (авторитетный) перебивает regex → gate + видит non-residential → can_build_mkd is NOT True. + """ + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + patches = [ + *_base_patches(dump_zoning=None, dump_available=True, mock_gate=False), + patch( + "app.api.v1.parcels.get_or_fetch_zone_regulation", + return_value=dict(_REGULATION_IZHS), + ), + ] + for p in patches: + p.start() + try: + client = TestClient(app) + resp = client.post(f"/api/v1/parcels/{_CAD}/analyze") + assert resp.status_code == 200, resp.text + body = resp.json() + zoning = body["nspd_zoning"] + assert zoning is not None + assert zoning["zone_code"] == "Ж-2" # display-индекс цел + # main_vri проброшен — авторитетный сигнал «ИЖС, МКД нельзя» + assert "2.1 Для индивидуального жилищного строительства" in zoning["main_vri"] + # FIX: false-positive подавлен — gate НЕ даёт can_build_mkd True на ИЖС + assert body["gate_verdict"]["can_build_mkd"] is not True + finally: + app.dependency_overrides.clear() + for p in patches: + p.stop() + + def test_no_synth_when_dump_none_and_geoportal_none() -> None: """(b) dump nspd_zoning=None + geoportal None → nspd_zoning None, financial None, не падаем.""" from app.core.db import get_db diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index a9d39fea..6553c598 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -3,8 +3,19 @@ from app.services.site_finder.gate_verdict import ( compute_gate_verdict, is_residential_zone, + mkd_permitted_from_vri, ) +# Реальные формы main_vri из zone_regulation_cache (JSON list of strings). +_VRI_IZHS_ONLY = [ + "2.1 Для индивидуального жилищного строительства", + "2.2 Для ведения личного подсобного хозяйства (приусадебный земельный участок)", +] +_VRI_HAS_MKD_25 = ["2.5 Среднеэтажная жилая застройка"] +_VRI_HAS_MKD_26 = ["2.6 Многоэтажная жилая застройка (высотная застройка)"] +_VRI_HAS_MKD_211 = ["2.1.1\n Малоэтажная многоквартирная жилая застройка"] +_VRI_NO_MKD = ["1.13 Овощеводство / ИЖС", "2.7.1 Хранение автотранспорта"] + # ── is_residential_zone unit tests ──────────────────────────────────────────── @@ -43,6 +54,97 @@ def test_non_residential_synth_cs3(): assert is_residential_zone("ЦС-3", None, None) is False +# ── mkd_permitted_from_vri (authoritative main_vri matching, #1891) ─────────── + + +def test_mkd_vri_true_for_2_5(): + assert mkd_permitted_from_vri(_VRI_HAS_MKD_25) is True + + +def test_mkd_vri_true_for_2_6(): + assert mkd_permitted_from_vri(_VRI_HAS_MKD_26) is True + + +def test_mkd_vri_true_for_2_1_1_with_newline(): + """Код может склеиться с \\n — split берёт первый токен '2.1.1'.""" + assert mkd_permitted_from_vri(_VRI_HAS_MKD_211) is True + + +def test_mkd_vri_false_for_izhs_only(): + assert mkd_permitted_from_vri(_VRI_IZHS_ONLY) is False + + +def test_mkd_vri_false_for_no_mkd_codes(): + assert mkd_permitted_from_vri(_VRI_NO_MKD) is False + + +def test_mkd_vri_none_for_none(): + assert mkd_permitted_from_vri(None) is None + + +def test_mkd_vri_none_for_empty_list(): + assert mkd_permitted_from_vri([]) is None + + +def test_mkd_vri_no_false_prefix_2_50(): + """'2.50' НЕ должен матчить '2.5' (точный токен-матч, не prefix).""" + assert mkd_permitted_from_vri(["2.50 Какой-то ВРИ"]) is False + + +def test_mkd_vri_no_false_prefix_2_59(): + assert mkd_permitted_from_vri(["2.59 Какой-то ВРИ"]) is False + + +def test_mkd_vri_no_false_prefix_12_0(): + """'12.0' НЕ должен матчить '2.6'/'2.5' (substring guard).""" + assert mkd_permitted_from_vri(["12.0 Земельные участки общего пользования"]) is False + + +def test_mkd_vri_code_only_element(): + """Элемент без названия (просто код) тоже матчит.""" + assert mkd_permitted_from_vri(["2.6"]) is True + + +# ── is_residential_zone: main_vri authoritative override (#1891) ────────────── + + +def test_residential_main_vri_suppresses_izhs_false_positive(): + """Ж-2 ИЖС: ^Ж regex дал бы True, но main_vri (ИЖС-only) перебивает → False (FIX).""" + assert is_residential_zone("Ж-2", None, None, main_vri=_VRI_IZHS_ONLY) is False + + +def test_residential_main_vri_enables_ts2_false_negative(): + """Ц-2: нет префикса Ж (^Ж дал бы False), но main_vri содержит 2.6 → True (FIX).""" + assert is_residential_zone("Ц-2", None, None, main_vri=_VRI_HAS_MKD_26) is True + + +def test_residential_main_vri_zh5_with_mkd_true(): + """Ж-5 с main_vri 2.5 → True (authoritative совпадает с regex).""" + assert is_residential_zone("Ж-5", None, None, main_vri=_VRI_HAS_MKD_25) is True + + +def test_residential_main_vri_overrides_subcategory(): + """main_vri ИЖС перебивает даже raw_props subcategory=2 (authoritative wins).""" + assert ( + is_residential_zone("Ж-2", None, {"subcategory": 2}, main_vri=_VRI_IZHS_ONLY) is False + ) + + +def test_residential_empty_main_vri_falls_back_to_regex(): + """main_vri=[] (нет инфо) → fallback на ^Ж regex → True.""" + assert is_residential_zone("Ж-2", None, None, main_vri=[]) is True + + +def test_residential_none_main_vri_fallback_regex_unchanged(): + """main_vri=None → regex fallback: Ж-2 остаётся True (backward-compat).""" + assert is_residential_zone("Ж-2", None, None) is True + + +def test_residential_none_main_vri_fallback_subcategory_unchanged(): + """main_vri=None → subcategory fallback продолжает работать.""" + assert is_residential_zone(None, None, {"subcategory": 2}) is True + + # ── compute_gate_verdict ────────────────────────────────────────────────────── @@ -374,3 +476,34 @@ def test_verdict_nspd_zoning_no_rawprops_no_zone_blocks(): assert verdict["can_build_mkd"] is False assert verdict["verdict_label"] == "Нельзя" assert any(b["code"] == "PZZ_NOT_RESIDENTIAL" for b in verdict["blockers"]) + + +# ── compute_gate_verdict: main_vri authoritative (#1891) ────────────────────── + + +def test_verdict_main_vri_izhs_blocks_despite_zh_code(): + """nspd_zoning Ж-2 + main_vri ИЖС-only → gate видит non-residential → Нельзя (FIX).""" + nspd_zoning = { + "zone_code": "Ж-2", + "zone_name": "Зона Ж-2", + "raw_props": None, + "main_vri": _VRI_IZHS_ONLY, + } + 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_verdict_main_vri_enables_ts2_can_build(): + """nspd_zoning Ц-2 (нет ^Ж) + main_vri 2.6 → gate видит жилую → Можно (FIX).""" + nspd_zoning = { + "zone_code": "Ц-2", + "zone_name": "Общественно-деловая Ц-2", + "raw_props": None, + "main_vri": _VRI_HAS_MKD_26, + } + verdict = compute_gate_verdict(nspd_zoning, [], [{"name": "ТП-7"}], {"available": True}) + assert verdict["can_build_mkd"] is True + assert verdict["verdict_label"] == "Можно" + assert verdict["blockers"] == []