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 8e329fe5..1488cb92 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2060,18 +2060,32 @@ def analyze_parcel( # Independent dict per request — never mutate module singleton. nspd_dump_data = make_empty_result() - # 9e-bis) Реальный ПЗЗ-градрегламент зоны → merge в nspd_zoning (Route 2, #1067). + # 9e-bis) Реальный ПЗЗ-градрегламент зоны → merge/synth в nspd_zoning (Route 2, #1067). # Резолвим числовые предельные параметры (КСИТ/max_far, высота, этажность, # %застройки, min площадь ЗУ) по centroid через get_or_fetch_zone_regulation: # cache-first (кэш прогрет для ЕКБ), live-geoportal только на miss и закапан # коротким timeout'ом (_ZONE_REGULATION_TIMEOUT_S), чтобы медленный геопортал не # вешал analyze. Hot-path-safe: любой сбой/таймаут → поля остаются None, analyze - # не падает. Существующие ключи nspd_zoning (zone_code/zone_name/source/raw_props) - # не трогаем — только ДОБАВЛЯЕМ regulation-поля. + # не падает. + # + # PR-A (#financial-zoning-decouple): резолвер геопортала РАЗГЕЙЧЕН от dump-зоны. + # nspd_quarter_dumps территориальные зоны НЕ замощают квартал плотно — участки + # сидят в 45-86м ЗАЗОРАХ между валидными зонами → _get_zoning=None на 94% + # участков, что глушило РАБОТАЮЩИЙ geoportal-резолвер (он резолвит gap-участки). + # Теперь запускаем резолвер ВСЕГДА при наличии centroid. Когда dump зону НЕ дал — + # СИНТЕЗИРУЕМ минимальный nspd_zoning из geoportal-результата (zone_code = индекс + # зоны напр. "Ж-2", чтобы gate_verdict.is_residential_zone ^Ж regex сработал) и + # ПИШЕМ ОБРАТНО в nspd_dump_data, чтобы gate/финмост/ответ увидели зону. + # Когда dump зону ДАЛ — zone_code НЕ перетираем (он несёт кадастровый рег-номер + + # raw_props.subcategory, по которым gate детектит жильё), только ДОБАВЛЯЕМ + # regulation-поля. Расширение dump-резидентности — OUT OF SCOPE (PR-B). _nspd_zoning = nspd_dump_data.get("nspd_zoning") + # Region-guard: EKB-геопортал покрывает ТОЛЬКО Екатеринбург (КН 66:41). Для + # участков вне ЕКБ zone_index_at всё равно вернул бы None — экономим WFS-round-trip + # (резолвер теперь запускается на каждом analyze, а не только при dump-зоне). if ( settings.enable_zoning_regulation_in_analyze - and _nspd_zoning is not None + and cad_num.startswith("66:41") and centroid_lat is not None and centroid_lon is not None ): @@ -2085,6 +2099,20 @@ def analyze_parcel( logger.warning("zone regulation resolve failed for %s: %s", cad_num, e) _regulation = None if _regulation is not None: + _synthesized = _nspd_zoning is None + if _synthesized: + # Dump зону не дал (gap между терзонами) — синтезируем из geoportal. + # zone_code = индекс зоны ("Ж-2") → is_residential_zone ^Ж сработает. + _nspd_zoning = { + "zone_code": _regulation.get("zone_index"), + "zone_name": None, + "source": "ekb-geoportal-urbancard", + "raw_props": None, + } + # Write-back — gate (compute_gate_verdict), финмост + # (synthesize_parcel_financial) и ответ читают nspd_dump_data["nspd_zoning"] + # ПО КЛЮЧУ, поэтому замена ссылки доходит до всех трёх. + nspd_dump_data["nspd_zoning"] = _nspd_zoning _nspd_zoning["max_far"] = _regulation.get("max_far") _nspd_zoning["max_height_m"] = _regulation.get("max_height_m") _nspd_zoning["max_floors"] = _regulation.get("max_floors") @@ -2092,11 +2120,18 @@ 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" - logger.debug( - "zone regulation hit for %s: zone_index=%s max_far=%s", + # #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, _regulation.get("zone_index"), _regulation.get("max_far"), + _synthesized, ) else: logger.debug("zone regulation miss for %s (no resolve)", 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/conftest.py b/backend/tests/api/v1/conftest.py new file mode 100644 index 00000000..463d56ea --- /dev/null +++ b/backend/tests/api/v1/conftest.py @@ -0,0 +1,39 @@ +"""Общие фикстуры для api/v1 analyze-тестов. + +PR-A (#1891) сделал geoportal зон-резолвер (``get_or_fetch_zone_regulation``) +БЕЗУСЛОВНЫМ в ``analyze_parcel`` — раньше он был gated за наличием dump-зоны +(``_nspd_zoning is not None``), теперь вызывается для любого ЕКБ-участка с centroid. + +Analyze-тесты с ПОЗИЦИОННЫМ DB-моком (``_make_db_for_analyze`` + ``call_idx``/ +``responses[...]``) НЕ мокали резолвер (до PR-A он не вызывался). Новый безусловный +вызов добавляет ``db.execute`` (get_cached) → сдвигает последовательность ответов +мока → последующие запросы (напр. ``parcel_meta``) получают чужой/пустой ответ и +становятся ``None``. Локально проходило (резолвер мог не разрезолвить точку), в CI +падало (``test_parcel_meta_found_in_cad_parcels``). + +Авто-фикстура по умолчанию глушит резолвер в no-op (``return None``) для ВСЕХ тестов +в этом каталоге → блок 9e-bis ничего не синтезирует и не трогает БД (поведение +идентично до-PR-A). Тесты, которые проверяют САМ резолвер +(``test_analyze_zoning_regulation.py``), переопределяют этот же target своим +per-test ``patch`` — он применяется ПОВЕРХ авто-фикстуры (вложенный mock-scope), так +что их ожидаемые значения резолвера сохраняются. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def _stub_zone_regulation_resolver() -> Iterator[None]: + """No-op geoportal зон-резолвер по умолчанию (без сети, без desync DB-мока). + + ``return None`` → ``analyze`` не синтезирует ``nspd_zoning`` и не делает лишних + ``db.execute``. Тесты резолвера переопределяют ``get_or_fetch_zone_regulation`` + своим ``patch`` per-test. + """ + with patch("app.api.v1.parcels.get_or_fetch_zone_regulation", return_value=None): + yield diff --git a/backend/tests/api/v1/test_analyze_zoning_regulation.py b/backend/tests/api/v1/test_analyze_zoning_regulation.py index a8154cae..e0ddcbdd 100644 --- a/backend/tests/api/v1/test_analyze_zoning_regulation.py +++ b/backend/tests/api/v1/test_analyze_zoning_regulation.py @@ -8,9 +8,21 @@ (hot-path-safe, не падаем) 3. флаг enable_zoning_regulation_in_analyze=False → resolver НЕ вызывается, полей нет +PR-A (#financial-zoning-decouple) — резолвер РАЗГЕЙЧЕН от dump-зоны (раньше был +gated за `_nspd_zoning is not None`, из-за чего gap-участки без dump-зоны не получали +max_far → financial_estimate ~0%). Новые тесты на synth-путь: + + (a) dump nspd_zoning=None + geoportal резолвит "Ж-2"+max_far → nspd_zoning + СИНТЕЗИРОВАН (zone_code="Ж-2", source="ekb-geoportal-urbancard", max_far), + gate видит зону как жилую (can_build_mkd=True). + (b) dump nspd_zoning=None + geoportal вернул None → nspd_zoning остаётся None, + analyze не падает, financial_estimate=None. + (c) dump ДАЛ nspd_zoning (рег-номер zone_code + raw_props) + geoportal резолвит → + regulation-поля добавлены, zone_code НЕ перетёрт (остаётся рег-номер). + (d) geoportal кинул → деградирует в None, analyze не падает. + Стратегия mock: как в test_analyze_parcel_meta.py — DB mock через dependency_overrides, -тяжёлые сервисы патчим через unittest.mock.patch. get_quarter_dump_data замокан так, чтобы -вернуть непустой nspd_zoning (иначе merge пропускается по guard `_nspd_zoning is not None`). +тяжёлые сервисы патчим через unittest.mock.patch. """ from __future__ import annotations @@ -43,6 +55,44 @@ _REGULATION = { "source": "ekb_geoportal", } +# 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, + "max_height_m": 75.0, + "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", +} + +# Dump-provided зона с кадастровым рег-номером в zone_code (реальная форма dump). +_NSPD_ZONING_REGNUM = { + "zone_code": "66:41-7.14", + "zone_name": "66:41-7.14", + "source": "nspd-quarter-dump", + "raw_props": {"subcategory": 2, "descr": "66:41-7.14"}, +} + def _make_mapping(data: dict[str, Any]) -> MagicMock: m = MagicMock() @@ -123,27 +173,48 @@ def _override_db(db: MagicMock): return _get_db_override -def _base_patches() -> list[Any]: - """Патчи тяжёлых сервисов; get_quarter_dump_data отдаёт непустой nspd_zoning.""" - return [ +def _dump_result( + nspd_zoning: dict[str, Any] | None, + *, + dump_available: bool = False, +) -> dict[str, Any]: + """Сборка результата get_quarter_dump_data. nspd_zoning — свежая копия (merge мутирует).""" + return { + "nspd_zoning": dict(nspd_zoning) if nspd_zoning is not None else None, + "nspd_zouit_overlaps": [], + "nspd_engineering_nearby": [], + "nspd_risk_zones": [], + "nspd_opportunity_parcels": [], + "nspd_red_lines": [], + "nspd_dump": { + "available": dump_available, + "stale": False, + "harvest_triggered": False, + }, + } + + +def _base_patches( + *, + dump_zoning: dict[str, Any] | None = _NSPD_ZONING, + dump_available: bool = False, + mock_gate: bool = True, +) -> list[Any]: + """Патчи тяжёлых сервисов. + + dump_zoning — что вернёт get_quarter_dump_data в nspd_zoning (None = gap-участок). + dump_available — флаг свежести dump (gate evaluates зону ТОЛЬКО при available=True). + mock_gate — если False, реальный compute_gate_verdict (для проверки synth→gate пути). + """ + patches = [ patch("app.api.v1.parcels.get_air_quality_cached", return_value=None), patch("app.api.v1.parcels.get_weather_cached", return_value=None), patch("app.api.v1.parcels.get_seasonal_weather_cached", return_value=None), patch( "app.api.v1.parcels.get_quarter_dump_data", - return_value={ - # dict(_NSPD_ZONING) — свежая копия per-test, merge мутирует её in-place - "nspd_zoning": dict(_NSPD_ZONING), - "nspd_zouit_overlaps": [], - "nspd_engineering_nearby": [], - "nspd_risk_zones": [], - "nspd_opportunity_parcels": [], - "nspd_red_lines": [], - "nspd_dump": {"available": False, "stale": False, "harvest_triggered": False}, - }, + return_value=_dump_result(dump_zoning, dump_available=dump_available), ), patch("app.api.v1.parcels.compute_velocity", return_value=None), - patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}), patch( "app.api.v1.parcels.build_ird_analyze_block", return_value={ @@ -157,6 +228,11 @@ def _base_patches() -> list[Any]: }, ), ] + if mock_gate: + patches.append( + patch("app.api.v1.parcels.compute_gate_verdict", return_value={"verdict": "unknown"}) + ) + return patches def test_zoning_regulation_merged_when_resolved() -> None: @@ -259,3 +335,173 @@ def test_zoning_regulation_skipped_when_flag_off() -> None: app.dependency_overrides.clear() for p in patches: p.stop() + + +# ── PR-A: synth-путь (dump зону не дал, geoportal резолвит) ──────────────────── + + +def test_synth_when_dump_zoning_none_and_geoportal_resolves() -> None: + """(a) dump nspd_zoning=None + geoportal "Ж-2"+main_vri(МКД) → synth + gate видит жилую.""" + from app.core.db import get_db + + db = _make_db_for_analyze() + app.dependency_overrides[get_db] = _override_db(db) + patches = [ + # dump_available=True + реальный gate → проверяем synth→gate путь end-to-end. + *_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_ZH2), + ), + ] + 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"] + # synth построил минимальный dict из geoportal-результата + assert zoning is not None + assert zoning["zone_code"] == "Ж-2" # zone_index → zone_code (для ^Ж regex) + assert zoning["source"] == "ekb-geoportal-urbancard" + assert zoning["zone_name"] is None + assert zoning["max_far"] == 2.5 + assert zoning["regulation_zone_index"] == "Ж-2" + assert zoning["regulation_source"] == "ekb-geoportal-urbancard" + # #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() + for p in patches: + 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 + + 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=None), + ] + 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() + assert body["nspd_zoning"] is None # synth НЕ сработал (geoportal вернул None) + assert body["financial_estimate"] is None # нет чисел зоны → финмодель None + finally: + app.dependency_overrides.clear() + for p in patches: + p.stop() + + +def test_dump_provided_zone_code_not_overwritten_by_synth() -> None: + """(c) dump ДАЛ зону (рег-номер) + geoportal резолвит → regulation добавлен, code цел.""" + 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=_NSPD_ZONING_REGNUM, dump_available=True, mock_gate=True), + patch( + "app.api.v1.parcels.get_or_fetch_zone_regulation", + return_value=dict(_REGULATION_ZH2), + ), + ] + 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 + # zone_code НЕ перетёрт synth'ом — остаётся dump-рег-номер + raw_props + assert zoning["zone_code"] == "66:41-7.14" + assert zoning["source"] == "nspd-quarter-dump" + assert zoning["raw_props"]["subcategory"] == 2 + # regulation-поля ДОБАВЛЕНЫ из geoportal + assert zoning["max_far"] == 2.5 + assert zoning["regulation_zone_index"] == "Ж-2" + assert zoning["regulation_source"] == "ekb-geoportal-urbancard" + finally: + app.dependency_overrides.clear() + for p in patches: + p.stop() + + +def test_synth_path_graceful_when_geoportal_raises() -> None: + """(d) dump None + geoportal кинул → деградирует в None, analyze не падает.""" + 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", + side_effect=RuntimeError("geoportal timeout"), + ), + ] + 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() + assert body["nspd_zoning"] is None # synth НЕ сработал — резолвер кинул + assert body["financial_estimate"] is None + finally: + app.dependency_overrides.clear() + for p in patches: + p.stop() diff --git a/backend/tests/test_gate_verdict.py b/backend/tests/test_gate_verdict.py index 701fdaf6..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 ──────────────────────────────────────────── @@ -28,6 +39,112 @@ def test_non_residential_zone_recreation(): assert is_residential_zone("Р-2", "Рекреационная Р-2") is False +# PR-A synth-path lock: эти индексы геопортала кладёт synth в zone_code; gate-регекс +# ^Ж должен пускать только жилые. "ПК-1"/"ЦС-3" — реальные gap-зоны ЕКБ (#decouple). +def test_residential_synth_zh2_zone_index_only(): + # synth кладёт zone_index в zone_code, zone_name=None, raw_props=None + assert is_residential_zone("Ж-2", None, None) is True + + +def test_non_residential_synth_pk1(): + assert is_residential_zone("ПК-1", None, None) is False + + +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 ────────────────────────────────────────────────────── @@ -359,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"] == []