feat(cadastre): per-category NSPD snapshot — Phase 1.5 (ZOUIT/ENK) #180
3 changed files with 309 additions and 12 deletions
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue