feat(cadastre): per-category NSPD snapshot — Phase 1.5 (ZOUIT/ENK)

Switch base search_by_quarter from thematicSearchId=1 to thematic=1
(includes ZOUIT in features). Add optional category_id param that routes
ZOUIT (36940/469039/469040/469042) -> thematicSearchId=5,
ENK (39663) -> thematicSearchId=15.

Add Phase 1.5 in harvest_quarter: for each PER_CATEGORY_PROBE_CATS
category present in meta_counts (meta_total>0), run dedicated
per-category search call. Skips if category in GRID_WALK_CATS and
bbox is valid (grid-walk gives better coverage).

Curl-probe confirmed NSPD API caps at 20/category with no pagination;
categoryId param is server-ignored; thematic=1 strictly better than
thematicSearchId=1 (includes ZOUIT).

Closes part of #168 (ZOUIT/ENK coverage gap).
This commit is contained in:
lekss361 2026-05-15 16:48:59 +03:00
parent 3beb75d68a
commit df5034fe29
3 changed files with 309 additions and 12 deletions

View file

@ -42,6 +42,21 @@ NSPD_SEARCH_URL = f"{NSPD_BASE}/geoportal/v2/search/geoportal"
NSPD_WMS_BASE = f"{NSPD_BASE}/aeggis/v4"
NSPD_TAB_GROUP_URL = f"{NSPD_BASE}/geoportal/v1/tab-group-data"
# Маппинг category_id → thematicSearchId для per-category override.
# Получен экспериментально (curl-probe, 2026-05-15):
# thematicSearchId=1 → ZU+Здания+Сооружения+Реестр адресов (без ЗОУИТ)
# thematicSearchId=5 → ЗОУИТ (36940, 469039, 469040, 469042)
# thematicSearchId=15 → ЕНК (39663)
# thematicSearchId=2 → Квартальная статистика (36381) — внутренний, не для override
# Ключ None → базовый тип поиска (thematic=1, возвращает все категории включая ЗОУИТ).
_CAT_TO_THEMATIC_SEARCH_ID: dict[int, int] = {
36940: 5, # ЗОУИТ — охранная зона
469039: 5, # ЗОУИТ subtype
469040: 5, # ЗОУИТ subtype
469042: 5, # ЗОУИТ subtype
39663: 15, # ЕНК
}
# Лимит одновременных запросов (глобальный, shared между всеми instance'ами)
_SEMAPHORE = asyncio.Semaphore(3)
@ -190,17 +205,34 @@ class NSPDBulkClient:
# ── 1. search_by_quarter ──────────────────────────────────────────────────
async def search_by_quarter(self, quarter: str) -> QuarterSnapshot:
async def search_by_quarter(
self,
quarter: str,
category_id: int | None = None,
) -> QuarterSnapshot:
"""Поиск объектов в квартале через NSPD search endpoint.
GET /api/geoportal/v2/search/geoportal?thematicSearchId=1&query={quarter}
Базовый вызов (category_id=None):
GET .../search/geoportal?thematic=1&query={quarter}
Возвращает до 20 объектов per категории для всех категорий включая ЗОУИТ.
Возвращает первые 20 объектов на категорию + meta с totalCount.
Категории с totalCount > 20 попадают в overflow_categories
и требуют grid-walk (wms_feature_info) для полноты.
Per-category вызов (category_id задан):
Выбирает thematicSearchId соответствующий группе категории:
- 36940/469039/469040/469042 (ЗОУИТ) thematicSearchId=5
- 39663 (ЕНК) thematicSearchId=15
- прочие (ЗУ/Здания/Сооружения/ОНС) thematic=1 (общий, те же 20 per cat)
Используется в Phase 1.5 bulk_harvest для дозабора overflow-категорий,
которые не покрываются grid-walk из-за broken bbox.
NSPD API особенности (curl-probe 2026-05-15):
- API возвращает строго 20 объектов per категории, pagination недоступна.
- Параметр categoryId игнорируется сервером (не фильтрует категории).
- thematic=1 лучше thematicSearchId=1: включает ЗОУИТ в features и meta.
Args:
quarter: кадастровый номер квартала в формате `66:41:NNNNNN`.
category_id: если задан использовать thematicSearchId специфичный
для группы этой категории. None = базовый тип (thematic=1).
Returns:
QuarterSnapshot с features + meta_counts.
@ -212,18 +244,28 @@ class NSPDBulkClient:
"""
fetched_at = _dt.datetime.now(_dt.UTC).isoformat()
# Выбираем параметры запроса в зависимости от category_id
if category_id is not None and category_id in _CAT_TO_THEMATIC_SEARCH_ID:
# Специфичный thematicSearchId для ЗОУИТ / ЕНК групп
params: dict[str, Any] = {
"thematicSearchId": _CAT_TO_THEMATIC_SEARCH_ID[category_id],
"query": quarter,
}
else:
# Базовый thematic=1: все категории включая ЗОУИТ (лучше thematicSearchId=1)
params = {"thematic": 1, "query": quarter}
try:
data = await self._get_json(
NSPD_SEARCH_URL,
params={"thematicSearchId": 1, "query": quarter},
)
data = await self._get_json(NSPD_SEARCH_URL, params=params)
except NspdBulkError as e:
# 404 или пустой ответ → возвращаем пустой snapshot
err_str = str(e)
if "HTTP 404" in err_str or "HTTP 400" in err_str:
logger.info(
"search_by_quarter: quarter %s not found (404/400), returning empty",
"search_by_quarter: quarter %s not found (404/400), returning empty"
" (category_id=%s)",
quarter,
category_id,
)
return QuarterSnapshot(quarter=quarter, fetched_at=fetched_at)
raise
@ -236,8 +278,10 @@ class NSPDBulkClient:
if isinstance(data_block, str):
# Иногда NSPD оборачивает data в строку (Bug_Nspd_Geo_Str_Object_No_Get_Fixed)
logger.warning(
"search_by_quarter: data block is string for quarter %s, returning empty",
"search_by_quarter: data block is string for quarter %s"
" (category_id=%s), returning empty",
quarter,
category_id,
)
return QuarterSnapshot(quarter=quarter, fetched_at=fetched_at)
@ -257,8 +301,9 @@ class NSPDBulkClient:
meta_counts[int(cat_id)] = int(total)
logger.info(
"search_by_quarter: quarter=%s features=%d meta_cats=%d overflow=%d",
"search_by_quarter: quarter=%s category_id=%s features=%d meta_cats=%d overflow=%d",
quarter,
category_id,
len(features),
len(meta_counts),
len([c for c, cnt in meta_counts.items() if cnt > 20]),

View file

@ -46,6 +46,14 @@ CAT_ZOUIT = frozenset({36940, 469039, 469040, 469042}) # ЗОУИТ
# Категории для grid-walk overflow
GRID_WALK_CATS = frozenset({CAT_PARCEL, CAT_BUILDING})
# Категории с выделенным thematicSearchId в NSPD API (curl-probe 2026-05-15).
# Для них отдельный per-category вызов даёт ДРУГОЙ результат, чем базовый thematic=1:
# - ЗОУИТ: thematicSearchId=5 → до 20 per subtype × 4 subtypes
# - ЕНК: thematicSearchId=15 → до 20 ЕНК объектов
# ЗУ/Здания/Сооружения/ОНС не имеют dedicated thematicSearchId — отдельный вызов
# вернёт те же 20 что и базовый, поэтому per-category probe для них бессмыслен.
PER_CATEGORY_PROBE_CATS = frozenset({*CAT_ZOUIT, CAT_ENK})
# ── Result type ──────────────────────────────────────────────────────────────
@ -142,6 +150,64 @@ async def harvest_quarter(
}
)
# ── Phase 1.5: per-category snapshot для ЗОУИТ и ЕНК ────────────────────
# ЗОУИТ (36940/469039/469040/469042) и ЕНК (39663) имеют dedicated thematicSearchId
# в NSPD API (5 и 15 соответственно) и не всегда попадают в базовый thematic=1 snapshot
# если totalCount в их категории > 20.
# Для ЗУ/Зданий/Сооружений/ОНС — dedicated thematicSearchId нет: отдельный вызов
# вернёт те же 20 что и базовый snapshot, поэтому их пропускаем.
# Если category ∈ GRID_WALK_CATS и bbox валиден — grid-walk даёт лучшее покрытие (225 точек).
for cat_id in PER_CATEGORY_PROBE_CATS:
meta_total = snapshot.meta_counts.get(cat_id, 0)
if meta_total == 0:
# NSPD не вернул meta для этой категории — объектов нет в квартале
continue
# Проверяем: нужен ли per-category probe?
# Условия skip: category ∈ GRID_WALK_CATS И bbox валиден → grid-walk сделает это лучше.
# PER_CATEGORY_PROBE_CATS ∩ GRID_WALK_CATS = ∅ (ZOUIT/ENK не в GRID_WALK_CATS),
# но явная проверка оставлена как safety guard.
if cat_id in GRID_WALK_CATS and quarter_bbox_3857(db, quarter) is not None:
logger.debug(
"harvest_quarter: skip per-cat probe cat=%d quarter=%s — grid-walk preferred",
cat_id,
quarter,
)
continue
update_progress({"phase": f"per_cat_probe_{cat_id}_started", "quarter": quarter})
try:
cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id)
except Exception as e:
logger.warning(
"harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s",
cat_id,
quarter,
e,
)
continue
if not cat_snapshot.features:
continue
cat_stats = upsert_features(db, cat_snapshot.features, source="search_per_cat")
result.parcels_upserted += cat_stats["parcels"]
result.buildings_upserted += cat_stats["buildings"]
result.constructions_upserted += cat_stats["constructions"]
result.oncs_upserted += cat_stats["oncs"]
result.enks_upserted += cat_stats["enks"]
result.zouit_upserted += cat_stats["zouit"]
db.commit()
new_total = sum(cat_stats.values())
logger.info(
"harvest_quarter per-cat probe: quarter=%s cat=%d new_features=%d",
quarter,
cat_id,
new_total,
)
# ── Phase 2-3: grid-walk для overflow категорий ───────────────────────────
overflow_cats = [c for c in snapshot.overflow_categories if c in GRID_WALK_CATS]

View file

@ -13,6 +13,8 @@ import pytest
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
from app.services.cadastre.bulk_harvest import (
CAT_ENK,
CAT_ZOUIT,
_derive_quarter_cad,
_safe_int,
_safe_numeric,
@ -537,3 +539,187 @@ def test_zouit_dedup_different_category_ids() -> None:
# Обе записи маршрутируются в upsert_zouit
assert mock_zouit.call_count == 2
assert counts["zouit"] == 2
# ── per-category snapshot (Phase 1.5) ────────────────────────────────────────
@pytest.mark.asyncio
async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero() -> None:
"""Phase 1.5: если в meta snapshot есть ЗОУИТ (36940), делаем per-cat probe."""
from app.services.cadastre.bulk_harvest import harvest_quarter
zouit_cat = next(iter(CAT_ZOUIT)) # один из ЗОУИТ categoryId
# Snapshot Phase 1: 1 парцел + meta с ЗОУИТ
parcel = _make_parcel_feature()
zouit_feature = _make_zouit_feature()
base_snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:00+00:00",
features=[parcel],
meta_counts={36368: 2, zouit_cat: 5}, # ЗОУИТ totalCount=5 > 0
)
# Per-category probe snapshot для ЗОУИТ
cat_snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:01+00:00",
features=[zouit_feature],
meta_counts={zouit_cat: 5},
)
db = MagicMock()
db.execute = MagicMock(
return_value=MagicMock(
mappings=MagicMock(
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
)
)
)
db.commit = MagicMock()
client = AsyncMock()
# Первый вызов (без category_id) → базовый snapshot
# Последующие — per-category probes
client.search_by_quarter = AsyncMock(side_effect=[base_snapshot, cat_snapshot])
with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert:
mock_upsert.return_value = {
"parcels": 0,
"buildings": 0,
"constructions": 0,
"oncs": 0,
"enks": 0,
"zouit": 1,
"skipped": 0,
}
result = await harvest_quarter(
db=db,
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=lambda s: None,
)
# search_by_quarter должен быть вызван минимум дважды:
# 1) без category_id (базовый)
# 2) с category_id=zouit_cat (per-cat probe)
assert client.search_by_quarter.await_count >= 2
call_args_list = client.search_by_quarter.await_args_list
# Первый вызов без category_id
first_call_kwargs = call_args_list[0].kwargs
assert first_call_kwargs.get("category_id") is None
# Один из последующих вызовов с category_id ∈ CAT_ZOUIT
per_cat_calls = [c for c in call_args_list[1:] if c.kwargs.get("category_id") in CAT_ZOUIT]
assert len(per_cat_calls) >= 1, "Ожидался per-cat probe для ЗОУИТ"
assert result.zouit_upserted >= 1
@pytest.mark.asyncio
async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None:
"""Phase 1.5: если meta не содержит ЗОУИТ/ЕНК — per-cat probe не делается."""
from app.services.cadastre.bulk_harvest import harvest_quarter
parcel = _make_parcel_feature()
# Snapshot без ЗОУИТ/ЕНК в meta (только ЗУ и Здания)
base_snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:00+00:00",
features=[parcel],
meta_counts={36368: 2, 36369: 3}, # нет ЗОУИТ / ЕНК
)
db = MagicMock()
db.execute = MagicMock(
return_value=MagicMock(
mappings=MagicMock(
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
)
)
)
db.commit = MagicMock()
client = AsyncMock()
client.search_by_quarter = AsyncMock(return_value=base_snapshot)
with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert:
mock_upsert.return_value = {
"parcels": 1,
"buildings": 0,
"constructions": 0,
"oncs": 0,
"enks": 0,
"zouit": 0,
"skipped": 0,
}
await harvest_quarter(
db=db,
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=lambda s: None,
)
# Только один вызов search_by_quarter (базовый), никаких per-cat probe
assert client.search_by_quarter.await_count == 1
call_kwargs = client.search_by_quarter.await_args_list[0].kwargs
assert call_kwargs.get("category_id") is None
@pytest.mark.asyncio
async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> None:
"""Phase 1.5: если в meta есть ЕНК (39663) — вызываем per-cat probe с category_id=39663."""
from app.services.cadastre.bulk_harvest import harvest_quarter
parcel = _make_parcel_feature()
base_snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:00+00:00",
features=[parcel],
meta_counts={36368: 2, CAT_ENK: 3}, # ЕНК в meta
)
enk_snapshot = QuarterSnapshot(
quarter="66:41:0303161",
fetched_at="2026-05-15T10:00:01+00:00",
features=[],
meta_counts={CAT_ENK: 3},
)
db = MagicMock()
db.execute = MagicMock(
return_value=MagicMock(
mappings=MagicMock(
return_value=MagicMock(first=MagicMock(return_value={"phase_state": None}))
)
)
)
db.commit = MagicMock()
client = AsyncMock()
client.search_by_quarter = AsyncMock(side_effect=[base_snapshot, enk_snapshot])
with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert:
mock_upsert.return_value = {
"parcels": 1,
"buildings": 0,
"constructions": 0,
"oncs": 0,
"enks": 0,
"zouit": 0,
"skipped": 0,
}
await harvest_quarter(
db=db,
client=client,
quarter="66:41:0303161",
job_id=1,
update_progress=lambda s: None,
)
# Ожидаем вызов с category_id=CAT_ENK
per_cat_enk_calls = [
c
for c in client.search_by_quarter.await_args_list
if c.kwargs.get("category_id") == CAT_ENK
]
assert len(per_cat_enk_calls) >= 1, "Ожидался per-cat probe с category_id=39663 (ENK)"