fix(site_finder): read real NSPD territorial-zone keys (nspd_zoning.zone_code was always null) #1844
2 changed files with 112 additions and 10 deletions
|
|
@ -303,8 +303,43 @@ def _get_zoning(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
props: dict[str, Any] = row[0] if isinstance(row[0], dict) else {}
|
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 {
|
return {
|
||||||
"zone_code": zone_code,
|
"zone_code": zone_code,
|
||||||
"zone_name": zone_name,
|
"zone_name": zone_name,
|
||||||
|
|
|
||||||
|
|
@ -197,13 +197,21 @@ def _make_db_mock_with_spatial(
|
||||||
|
|
||||||
|
|
||||||
def test_get_quarter_dump_fresh_zoning() -> None:
|
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)
|
fresh_date = datetime.now(UTC) - timedelta(days=30)
|
||||||
dump_row = ("66:41:0204016", fresh_date, 100, None, 5, 0, 0, 0, 0, 0)
|
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] = {
|
zone_props: dict[str, Any] = {
|
||||||
"reg_numb_border": "Ж-3",
|
"descr": "66:41-7.14",
|
||||||
"type_zone": "Зона жилой застройки средней этажности",
|
"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 = MagicMock()
|
||||||
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None
|
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_dump"]["available"] is True
|
||||||
assert result["nspd_zoning"] is not None
|
assert result["nspd_zoning"] is not None
|
||||||
assert result["nspd_zoning"]["zone_code"] == "Ж-3"
|
# Регресс #fix nspd-zoning-zone-code-keys: оба поля больше не None.
|
||||||
assert result["nspd_zoning"]["zone_name"] == "Зона жилой застройки средней этажности"
|
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"
|
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:
|
def test_get_quarter_dump_fresh_zoning_no_match() -> None:
|
||||||
|
|
@ -448,7 +510,12 @@ def test_early_exit_partial_counts() -> None:
|
||||||
zouit и engineering — нет."""
|
zouit и engineering — нет."""
|
||||||
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
fresh_date = datetime.now(UTC) - timedelta(days=5)
|
||||||
dump_row = ("66:41:0204016", fresh_date, 30, None, 3, 0, 0, 0, 0, 0)
|
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 = MagicMock()
|
||||||
zoning_row_mock.__getitem__ = lambda self, i: zone_props if i == 0 else None
|
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 пуст).
|
# который возвращает [] (mock fetchall пуст).
|
||||||
assert db.execute.call_count == 3
|
assert db.execute.call_count == 3
|
||||||
assert result["nspd_zoning"] is not None
|
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_zouit_overlaps"] == []
|
||||||
assert result["nspd_engineering_nearby"] == []
|
assert result["nspd_engineering_nearby"] == []
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue