fix(site-finder): разгейтить geoportal зон-резолвер от gappy NSPD-дампа — починить видимость финмодели (#1881) #1891

Merged
bot-backend merged 3 commits from fix/zoning-resolution-decouple into main 2026-06-23 23:55:53 +00:00
6 changed files with 547 additions and 22 deletions

BIN
backend/.coverage Normal file

Binary file not shown.

View file

@ -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)

View file

@ -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 "неизвестна"

View file

@ -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

View file

@ -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 (для проверки synthgate пути).
"""
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()

View file

@ -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"] == []