diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8e329fe5..674f5366 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,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" - logger.debug( - "zone regulation hit for %s: zone_index=%s max_far=%s", + 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/tests/api/v1/test_analyze_zoning_regulation.py b/backend/tests/api/v1/test_analyze_zoning_regulation.py index a8154cae..45a7733b 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,25 @@ _REGULATION = { "source": "ekb_geoportal", } +# PR-A synth fixtures: жилая зона Ж-2 (gate должен пустить МКД) с числами для финмодели. +_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, + "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 +154,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 +209,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 +316,133 @@ 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"+max_far → 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" + # gate увидел synth-зону как жилую → 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_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..a9d39fea 100644 --- a/backend/tests/test_gate_verdict.py +++ b/backend/tests/test_gate_verdict.py @@ -28,6 +28,21 @@ 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 + + # ── compute_gate_verdict ──────────────────────────────────────────────────────