Merge pull request 'fix(site_finder): read real NSPD territorial-zone keys (nspd_zoning.zone_code was always null)' (#1844) from fix/nspd-zoning-zone-code-keys into main
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled

Reviewed-on: #1844
This commit is contained in:
lekss361 2026-06-20 18:02:19 +00:00
commit b57bd2e3be
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"] == []