From cbe509f09264cb7d91827e8ee6131c2967c87078 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 20 Jun 2026 20:29:45 +0500 Subject: [PATCH] fix(site_finder): read real NSPD territorial-zone keys in _get_zoning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _get_zoning() read non-existent props (top-level reg_numb_border / zone_code / type_zone / name) so nspd_zoning.zone_code & zone_name were NULL on every parcel. Real dump props (verified prod, 796 features) carry only the registration number, duplicated across descr / label / externalKey / options.reg_numb_border (no human zone name exists in the NSPD dump). Map both fields to that reg-number with safe nested narrowing (isinstance options dict) + legacy fallbacks; honest — no fabricated name. Surfacing reg_numb_border also unblocks the КСИТ crosswalk (#1843). Regression tests use the real prop shape (full / nested-only / opaque→None). --- .../site_finder/quarter_dump_lookup.py | 39 ++++++++- backend/tests/test_quarter_dump_lookup.py | 83 +++++++++++++++++-- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/backend/app/services/site_finder/quarter_dump_lookup.py b/backend/app/services/site_finder/quarter_dump_lookup.py index c4e99875..0aa6ab18 100644 --- a/backend/app/services/site_finder/quarter_dump_lookup.py +++ b/backend/app/services/site_finder/quarter_dump_lookup.py @@ -303,8 +303,43 @@ def _get_zoning( return None props: dict[str, Any] = row[0] if isinstance(row[0], dict) else {} - zone_code = props.get("reg_numb_border") or props.get("zone_code") or props.get("name") - zone_name = props.get("type_zone") or props.get("zone_name") or props.get("name") + + # Реальная форма props у territorial_zones features (verified на prod, + # 796 features, ЕКБ/Сверд.обл): зона несёт ТОЛЬКО регистрационный номер + # границы, который продублирован в 4 местах и всегда идентичен: + # descr == label == externalKey == options.reg_numb_border (e.g. «66:41-7.14») + # Человекочитаемого имени («Зона застройки…») и индекса («Ж-2») в дампе + # НЕТ — `category`/`subcategory` это опаковые числовые коды НСПД. + # Старый код читал несуществующие ключи (top-level reg_numb_border / + # zone_code / type_zone / name) → zone_code и zone_name были None на + # КАЖДОМ участке (#fix nspd-zoning-zone-code-keys). + options = props.get("options") + reg_numb = options.get("reg_numb_border") if isinstance(options, dict) else None + # Регистрационный номер — единственный пригодный идентификатор зоны. + zone_code = ( + props.get("descr") + or props.get("label") + or reg_numb + or props.get("externalKey") + # legacy/fallback ключи на случай смены формы НСПД + or props.get("zone_code") + or props.get("name") + ) + # В дампе нет отдельного описательного имени — отдаём тот же рег-номер + # как zone_name (fallback на legacy-ключи, если они появятся). + zone_name = ( + props.get("zone_name") + or props.get("type_zone") + or props.get("label") + or props.get("descr") + or zone_code + ) + logger.debug( + "nspd zoning resolved quarter=%s zone_code=%s zone_name=%s", + quarter, + zone_code, + zone_name, + ) return { "zone_code": zone_code, "zone_name": zone_name, diff --git a/backend/tests/test_quarter_dump_lookup.py b/backend/tests/test_quarter_dump_lookup.py index 7d7b3c88..94fcc2f2 100644 --- a/backend/tests/test_quarter_dump_lookup.py +++ b/backend/tests/test_quarter_dump_lookup.py @@ -197,13 +197,21 @@ def _make_db_mock_with_spatial( def test_get_quarter_dump_fresh_zoning() -> None: - """Свежий дамп с territorial_zone → nspd_zoning populated.""" + """Свежий дамп с territorial_zone → nspd_zoning populated. + + Использует РЕАЛЬНУЮ форму props (verified на prod): зона несёт только + рег-номер границы, продублированный в descr/label/externalKey/options. + """ fresh_date = datetime.now(UTC) - timedelta(days=30) dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0, 0, 0, 0) - # zone_props JSONB в виде dict (SQLAlchemy вернёт dict) + # zone_props JSONB в виде dict (SQLAlchemy вернёт dict) — реальная форма НСПД. zone_props: dict[str, Any] = { - "reg_numb_border": "Ж-3", - "type_zone": "Зона жилой застройки средней этажности", + "descr": "66:41-7.14", + "label": "66:41-7.14", + "options": {"reg_numb_border": "66:41-7.14"}, + "category": 472819, + "externalKey": "66:41-7.14", + "subcategory": 1, } zoning_row_mock = MagicMock() zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None @@ -220,9 +228,63 @@ def test_get_quarter_dump_fresh_zoning() -> None: assert result["nspd_dump"]["available"] is True assert result["nspd_zoning"] is not None - assert result["nspd_zoning"]["zone_code"] == "Ж-3" - assert result["nspd_zoning"]["zone_name"] == "Зона жилой застройки средней этажности" + # Регресс #fix nspd-zoning-zone-code-keys: оба поля больше не None. + assert result["nspd_zoning"]["zone_code"] == "66:41-7.14" + assert result["nspd_zoning"]["zone_name"] == "66:41-7.14" assert result["nspd_zoning"]["source"] == "nspd-quarter-dump" + # raw_props сохраняет полный объект для дебага. + assert result["nspd_zoning"]["raw_props"]["options"]["reg_numb_border"] == "66:41-7.14" + + +def test_get_quarter_dump_zoning_reads_real_nspd_keys() -> None: + """Регресс: zone_code/zone_name резолвятся из РЕАЛЬНЫХ ключей дампа. + + До фикла _get_zoning читал несуществующие top-level reg_numb_border / + zone_code / type_zone / name → оба поля были None на КАЖДОМ участке. + Здесь проверяем три варианта формы и nested-fallback на options. + """ + fresh_date = datetime.now(UTC) - timedelta(days=10) + + def run(zone_props: dict[str, Any]) -> dict[str, Any] | None: + dump_row = ("66:41:0702048", fresh_date, 80, None, 2, 0, 0, 0, 0, 0) + zoning_row_mock = MagicMock() + zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None + db = _make_db_mock_with_spatial( + dump_row=dump_row, + zoning_row=zoning_row_mock, + zouit_rows=[], + engineering_rows=[], + ) + result = get_quarter_dump_data( + db, "66:41:0702048", parcel_wkt="POLYGON ((60.6 56.8, 60.7 56.8, 60.7 56.9, 60.6 56.8))" + ) + return result["nspd_zoning"] + + # 1. Полная реальная форма — берём top-level descr. + full = run( + { + "descr": "66:41-7.19", + "label": "66:41-7.19", + "options": {"reg_numb_border": "66:41-7.19"}, + "externalKey": "66:41-7.19", + "subcategory": 3, + } + ) + assert full is not None + assert full["zone_code"] == "66:41-7.19" + assert full["zone_name"] == "66:41-7.19" + + # 2. Top-level descr/label отсутствуют — narrow safely на options.reg_numb_border. + nested_only = run({"options": {"reg_numb_border": "66:01-7.114"}, "category": 472819}) + assert nested_only is not None + assert nested_only["zone_code"] == "66:01-7.114" + assert nested_only["zone_name"] == "66:01-7.114" + + # 3. options не-dict / отсутствует и нет descr — graceful, не KeyError, zone_code=None. + empty = run({"category": 472819, "subcategory": 6}) + assert empty is not None + assert empty["zone_code"] is None + assert empty["zone_name"] is None def test_get_quarter_dump_fresh_zoning_no_match() -> None: @@ -448,7 +510,12 @@ def test_early_exit_partial_counts() -> None: zouit и engineering — нет.""" fresh_date = datetime.now(UTC) - timedelta(days=5) dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0, 0, 0, 0) - zone_props: dict[str, Any] = {"reg_numb_border": "Ж-1", "type_zone": "Зона жилая"} + # Реальная форма props НСПД (см. test_get_quarter_dump_zoning_reads_real_nspd_keys). + zone_props: dict[str, Any] = { + "descr": "66:41-7.2", + "label": "66:41-7.2", + "options": {"reg_numb_border": "66:41-7.2"}, + } zoning_row_mock = MagicMock() zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None @@ -469,6 +536,6 @@ def test_early_exit_partial_counts() -> None: # который возвращает [] (mock fetchall пуст). assert db.execute.call_count == 3 assert result["nspd_zoning"] is not None - assert result["nspd_zoning"]["zone_code"] == "Ж-1" + assert result["nspd_zoning"]["zone_code"] == "66:41-7.2" assert result["nspd_zouit_overlaps"] == [] assert result["nspd_engineering_nearby"] == []