fix(site_finder): read real NSPD territorial-zone keys in _get_zoning
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m51s
CI / backend-tests (pull_request) Successful in 8m52s

_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).
This commit is contained in:
Light1YT 2026-06-20 20:29:45 +05:00
parent 06fa5b4910
commit cbe509f092
2 changed files with 112 additions and 10 deletions

View file

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

View file

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