gendesign/backend/app/services/cadastre/bulk_harvest.py
lekss361 5da66bbc93
feat(cadastre): bulk_harvest worker + grid-walker + admin API (#168 PR3/5) (#171)
* feat(cadastre): bulk_harvest_quarter Celery task + grid-walker + saga state (#168 PR3/5)

Add bulk cadastre harvest pipeline:
- services/cadastre/bulk_harvest.py: async harvest_quarter() orchestrator (4 phases)
  + 7 upsert helpers (parcels/buildings/constructions/oncs/enks/zouit/quarter_stats)
  using CAST(:x AS jsonb) pattern, begin_nested() SAVEPOINT per grid-walk upsert
- services/cadastre/grid_geometry.py: quarter_bbox_3857 (PostGIS ST_Extent)
  + generate_grid_click_points (15x15 = 225 sub-bbox grid)
- workers/tasks/scrape_cadastre.py: bulk_harvest_quarter_task (acks_late=True,
  dont_autoretry_for NspdBulkWafError), enqueue_cadastre_harvest, cleanup_zombies
- api/v1/admin_cadastre.py: 5 endpoints behind AdminTokenAuth — create/list/get/cancel/resume
- Tests: 13 service unit + 8 API tests

* fixup(cadastre): drop importorskip (PR2 merged) — imports now top-level (#168 PR3)

* fixup(cadastre): tile_size unified param + register slow marker (#168 PR3)

Blocker #1: rename tile_width/tile_height → tile_size in generate_grid_click_points.
Callers in bulk_harvest.py and test_cadastre_bulk.py used tile_size; def had
tile_width+tile_height → TypeError at runtime.

Blocker #2 (10 failures in test_admin_cadastre.py) was side-effect of #1:
TypeError at import chain (app.main → admin_cadastre → bulk_harvest)
broke FastAPI app load → dependency_overrides AttributeError. Now resolved.

Also register `slow` pytest marker in pyproject.toml to suppress
PytestUnknownMarkWarning.

* fixup(cadastre): use importlib.util.find_spec instead of import (#168 PR3)

Root cause Blocker #2: `import app.workers.tasks.scrape_cadastre` at module
top-level REBOUND `app` from FastAPI instance (line 14) to the Python package.
All subsequent `app.dependency_overrides[get_db] = ...` failed with
AttributeError because `app` was now the namespace module.

Fix: probe module availability with importlib.util.find_spec — does not
import or rebind names. FastAPI `app` instance stays intact.

All 28 cadastre tests pass locally.

---------

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 13:31:32 +03:00

990 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Оркестратор bulk cadastre harvest — pure-async, без Celery декораторов.
Вызывается из Celery-таска (scrape_cadastre.py) через asyncio.run().
Фазы harvest_quarter:
Phase 1: search_by_quarter → upsert первые 20 per категории
Phase 2: определить overflow-категории (meta > 20)
Phase 3: grid_walk для overflow 36368 (ЗУ) и 36369 (Здания)
Phase 4: upsert_quarter_stats из meta квартального snapshot (36381)
Idempotent: все upsert'ы — ON CONFLICT DO UPDATE.
Resumable: phase_state в cadastre_jobs показывает прогресс.
Питфоллы (см. CLAUDE.md):
- CAST(:x AS jsonb) не :x::jsonb (SQLAlchemy парсит :: как параметр)
- psycopg v3 only (не psycopg2)
- SAVEPOINT через db.begin_nested() вокруг каждого upsert
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot
from app.scrapers.nspd_bulk_client import NSPDBulkClient
from app.services.cadastre.grid_geometry import generate_grid_click_points, quarter_bbox_3857
logger = logging.getLogger(__name__)
# Категории NSPD для routing
CAT_PARCEL = 36368 # ЗУ ЕГРН
CAT_BUILDING = 36369 # Здания ЕГРН
CAT_CONSTRUCTION = 36383 # Сооружения
CAT_ONC = 36384 # ОНС
CAT_ENK = 39663 # ЕНК
CAT_QUARTER_STATS = 36381 # Квартальная статистика
CAT_ZOUIT = frozenset({36940, 469039, 469040, 469042}) # ЗОУИТ
# Категории для grid-walk overflow
GRID_WALK_CATS = frozenset({CAT_PARCEL, CAT_BUILDING})
# ── Result type ──────────────────────────────────────────────────────────────
@dataclass
class HarvestResult:
"""Итог harvest одного квартала."""
quarter: str
parcels_upserted: int = 0
buildings_upserted: int = 0
constructions_upserted: int = 0
oncs_upserted: int = 0
enks_upserted: int = 0
zouit_upserted: int = 0
grid_walk_requests: int = 0
phase_state: dict[str, Any] | None = None
# ── Основной оркестратор ─────────────────────────────────────────────────────
async def harvest_quarter(
db: Session,
client: NSPDBulkClient,
quarter: str,
job_id: int,
update_progress: Callable[[dict[str, Any]], None],
) -> HarvestResult:
"""Собрать все NSPD данные для квартала в cad_* таблицы.
Идемпотентен: повторный вызов не создаёт дубликаты (ON CONFLICT DO UPDATE).
Возобновляем: проверяет job.phase_state, пропускает завершённые фазы.
Args:
db: SQLAlchemy session. Caller должен управлять commit/rollback.
client: NSPDBulkClient (async context manager, уже открытый).
quarter: кадастровый номер квартала.
job_id: ID записи в cadastre_jobs для обновления прогресса.
update_progress: callback для записи phase_state в cadastre_jobs.
Returns:
HarvestResult с счётчиками upserted объектов.
Raises:
NspdBulkWafError: при WAF 403 — caller не retry.
NspdBulkRateLimitError: при исчерпании retries — caller может retry.
Exception: прочие ошибки — propagate.
"""
result = HarvestResult(quarter=quarter)
# Читаем текущий phase_state из job (для resumability)
job_row = (
db.execute(
text("SELECT phase_state FROM cadastre_jobs WHERE job_id = :id"),
{"id": job_id},
)
.mappings()
.first()
)
phase_state: dict[str, Any] = {}
if job_row and job_row["phase_state"]:
phase_state = dict(job_row["phase_state"])
# Ранний выход если quarter уже полностью обработан
if phase_state.get("phase") == "done":
logger.info("harvest_quarter: quarter=%s уже done (job_id=%s), skip", quarter, job_id)
return result
# ── Phase 1: search_by_quarter snapshot ──────────────────────────────────
update_progress({"phase": "snapshot_started", "quarter": quarter})
snapshot: QuarterSnapshot = await client.search_by_quarter(quarter)
snapshot_stats = upsert_features(db, snapshot.features, source="search")
result.parcels_upserted += snapshot_stats["parcels"]
result.buildings_upserted += snapshot_stats["buildings"]
result.constructions_upserted += snapshot_stats["constructions"]
result.oncs_upserted += snapshot_stats["oncs"]
result.enks_upserted += snapshot_stats["enks"]
result.zouit_upserted += snapshot_stats["zouit"]
db.commit()
parcels_meta = snapshot.meta_counts.get(CAT_PARCEL, 0)
buildings_meta = snapshot.meta_counts.get(CAT_BUILDING, 0)
update_progress(
{
"phase": "snapshot_done",
"quarter": quarter,
"parcels_total_meta": parcels_meta,
"buildings_total_meta": buildings_meta,
"overflow_cats": snapshot.overflow_categories,
}
)
# ── Phase 2-3: grid-walk для overflow категорий ───────────────────────────
overflow_cats = [c for c in snapshot.overflow_categories if c in GRID_WALK_CATS]
for cat_id in overflow_cats:
cat_label = "parcels" if cat_id == CAT_PARCEL else "buildings"
update_progress({"phase": f"grid_walk_{cat_label}_started", "quarter": quarter})
discovered, n_requests = await _grid_walk_category(
db=db,
client=client,
quarter=quarter,
layer_id=cat_id,
)
result.grid_walk_requests += n_requests
if cat_id == CAT_PARCEL:
result.parcels_upserted += discovered
else:
result.buildings_upserted += discovered
db.commit()
update_progress(
{
"phase": f"grid_walk_{cat_label}_done",
"quarter": quarter,
f"{cat_label}_grid_discovered": discovered,
}
)
# ── Phase 4: quarter stats из snapshot ───────────────────────────────────
stats_features = [f for f in snapshot.features if f.category_id == CAT_QUARTER_STATS]
if stats_features:
upsert_quarter_stats(db, quarter, stats_features[0])
db.commit()
update_progress({"phase": "done", "quarter": quarter})
result.phase_state = {"phase": "done", "quarter": quarter}
logger.info(
"harvest_quarter done: quarter=%s parcels=%d buildings=%d "
"constructions=%d zouit=%d grid_requests=%d",
quarter,
result.parcels_upserted,
result.buildings_upserted,
result.constructions_upserted,
result.zouit_upserted,
result.grid_walk_requests,
)
return result
# ── Grid-walk для одной категории ────────────────────────────────────────────
async def _grid_walk_category(
db: Session,
client: NSPDBulkClient,
quarter: str,
layer_id: int,
grid_size: int = 15,
tile_size: int = 512,
) -> tuple[int, int]:
"""Grid-walk layer_id в bbox квартала.
Генерирует grid_size×grid_size ячеек, для каждой делает wms_feature_info.
Дедуплицирует по cad_num, upsert'ит новые объекты.
Returns:
(upserted_count, requests_count)
"""
bbox = quarter_bbox_3857(db, quarter)
if bbox is None:
logger.warning(
"_grid_walk_category: bbox для квартала %s не найден, пропускаем layer=%d",
quarter,
layer_id,
)
return 0, 0
grid_points = generate_grid_click_points(bbox, grid_size=grid_size, tile_size=tile_size)
discovered_cads: set[str] = set()
upserted = 0
requests = 0
for cell_bbox, click_xy in grid_points:
try:
features = await client.wms_feature_info(
layer_id=layer_id,
bbox=cell_bbox,
click_xy=click_xy,
width=tile_size,
height=tile_size,
)
requests += 1
except Exception as e:
logger.warning(
"_grid_walk_category: wms_feature_info error layer=%d quarter=%s: %s",
layer_id,
quarter,
e,
)
requests += 1
continue
for feature in features:
cad = feature.cad_num
if not cad or cad in discovered_cads:
continue
discovered_cads.add(cad)
# Upsert в соответствующую таблицу
try:
with db.begin_nested():
stats = upsert_features(db, [feature], source="wms_grid_walk")
upserted += sum(stats.values())
except Exception as e:
logger.warning(
"_grid_walk_category: upsert failed cad=%s layer=%d: %s",
cad,
layer_id,
e,
)
return upserted, requests
# ── Routing dispatcher ───────────────────────────────────────────────────────
def upsert_features(
db: Session,
features: list[NSPDBulkFeature],
source: str = "search",
) -> dict[str, int]:
"""Маршрутизировать features по category_id в специфичные upsert-функции.
Returns:
Словарь {table: count} с количеством upserted строк.
"""
counts: dict[str, int] = {
"parcels": 0,
"buildings": 0,
"constructions": 0,
"oncs": 0,
"enks": 0,
"zouit": 0,
"skipped": 0,
}
for feature in features:
cat = feature.category_id
if cat == CAT_PARCEL:
upsert_parcel(db, feature, source=source)
counts["parcels"] += 1
elif cat == CAT_BUILDING:
upsert_building(db, feature, source=source)
counts["buildings"] += 1
elif cat == CAT_CONSTRUCTION:
upsert_construction(db, feature, source=source)
counts["constructions"] += 1
elif cat == CAT_ONC:
upsert_onc(db, feature, source=source)
counts["oncs"] += 1
elif cat == CAT_ENK:
upsert_enk(db, feature, source=source)
counts["enks"] += 1
elif cat in CAT_ZOUIT:
upsert_zouit(db, feature, source=source)
counts["zouit"] += 1
elif cat == CAT_QUARTER_STATS:
pass # обрабатывается отдельно в upsert_quarter_stats
else:
if cat is not None:
logger.debug("upsert_features: неизвестный category_id=%s, skip", cat)
counts["skipped"] += 1
return counts
# ── cad_parcels upsert ───────────────────────────────────────────────────────
def upsert_parcel(db: Session, feature: NSPDBulkFeature, source: str = "search") -> None:
"""Upsert ЗУ ЕГРН в cad_parcels. ON CONFLICT (cad_num) DO UPDATE."""
opts = feature.options
cad_num = opts.effective_cad_num
if not cad_num:
logger.debug("upsert_parcel: пропуск — нет cad_num в feature id=%s", feature.id)
return
raw_opts = feature.properties.get("options") or {}
geom_json = json.dumps(feature.geometry) if feature.geometry else None
quarter_cad = opts.quarter_cad_number or _derive_quarter_cad(cad_num)
db.execute(
text("""
INSERT INTO cad_parcels (
cad_num, quarter_cad_number, category_id,
land_record_area, specified_area, declared_area,
land_record_category_type, land_record_subtype, land_record_type,
permitted_use_established_by_document,
cost_value, cost_index,
ownership_type, right_type,
readable_address, status, previously_posted,
registration_date, subcategory, cadastral_districts_code,
geom, raw_props, source, fetched_at, updated_at
) VALUES (
:cad_num, :quarter_cad_number, 36368,
:land_record_area, :specified_area, :declared_area,
:land_record_category_type, :land_record_subtype, :land_record_type,
:permitted_use,
:cost_value, :cost_index,
:ownership_type, :right_type,
:readable_address, :status, :previously_posted,
CAST(:registration_date AS date),
:subcategory, :cadastral_districts_code,
CASE WHEN :geom IS NOT NULL THEN
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857),
4326
)
ELSE NULL END,
CAST(:raw_props AS jsonb), :source, NOW(), NOW()
)
ON CONFLICT (cad_num) DO UPDATE SET
quarter_cad_number = EXCLUDED.quarter_cad_number,
land_record_area = EXCLUDED.land_record_area,
specified_area = EXCLUDED.specified_area,
declared_area = EXCLUDED.declared_area,
land_record_category_type = EXCLUDED.land_record_category_type,
land_record_subtype = EXCLUDED.land_record_subtype,
land_record_type = EXCLUDED.land_record_type,
permitted_use_established_by_document =
EXCLUDED.permitted_use_established_by_document,
cost_value = EXCLUDED.cost_value,
cost_index = EXCLUDED.cost_index,
ownership_type = EXCLUDED.ownership_type,
right_type = EXCLUDED.right_type,
readable_address = EXCLUDED.readable_address,
status = EXCLUDED.status,
previously_posted = EXCLUDED.previously_posted,
registration_date = EXCLUDED.registration_date,
subcategory = EXCLUDED.subcategory,
cadastral_districts_code = EXCLUDED.cadastral_districts_code,
geom = COALESCE(EXCLUDED.geom, cad_parcels.geom),
raw_props = EXCLUDED.raw_props,
source = EXCLUDED.source,
updated_at = NOW()
"""),
{
"cad_num": cad_num,
"quarter_cad_number": quarter_cad,
"land_record_area": _safe_numeric(raw_opts.get("land_record_area")),
"specified_area": _safe_numeric(raw_opts.get("specified_area")),
"declared_area": _safe_numeric(raw_opts.get("declared_area")),
"land_record_category_type": raw_opts.get("land_record_category_type"),
"land_record_subtype": raw_opts.get("land_record_subtype"),
"land_record_type": raw_opts.get("land_record_type"),
"permitted_use": raw_opts.get("permitted_use_established_by_document"),
"cost_value": _safe_numeric(raw_opts.get("cost_value")),
"cost_index": _safe_numeric(raw_opts.get("cost_index")),
"ownership_type": raw_opts.get("ownership_type"),
"right_type": raw_opts.get("right_type"),
"readable_address": raw_opts.get("readable_address"),
"status": raw_opts.get("status"),
"previously_posted": raw_opts.get("previously_posted"),
"registration_date": raw_opts.get("registration_date"),
"subcategory": _safe_int(raw_opts.get("subcategory")),
"cadastral_districts_code": _safe_int(raw_opts.get("cadastral_districts_code")),
"geom": geom_json,
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
"source": source,
},
)
# ── cad_buildings upsert ─────────────────────────────────────────────────────
def upsert_building(db: Session, feature: NSPDBulkFeature, source: str = "search") -> None:
"""Upsert здание ЕГРН в cad_buildings. ON CONFLICT (cad_num) DO UPDATE."""
opts = feature.options
cad_num = opts.effective_cad_num
if not cad_num:
logger.debug("upsert_building: пропуск — нет cad_num в feature id=%s", feature.id)
return
raw_opts = feature.properties.get("options") or {}
geom_json = json.dumps(feature.geometry) if feature.geometry else None
quarter_cad = opts.quarter_cad_number or _derive_quarter_cad(cad_num)
# floors из NSPD может быть str "5" или int 5
floors_val = _safe_int(raw_opts.get("floors"))
ugnd_floors_val = _safe_int(raw_opts.get("underground_floors"))
# cultural_heritage — строка → bool
ch_str = raw_opts.get("cultural_heritage")
cultural_heritage_bool = bool(ch_str) if ch_str is not None else None
db.execute(
text("""
INSERT INTO cad_buildings (
cad_num, quarter_cad_number,
objdoc_id, registers_id,
purpose, building_name,
floors, underground_floors,
area, build_record_area,
cost_value, cost_index,
year_built, year_commisioning,
build_record_registration_date, registration_date,
ownership_type,
cultural_heritage_object, cultural_heritage_val,
readable_address, status, common_data_status,
type, build_record_type_value,
geom, raw_props, source, fetched_at, updated_at
) VALUES (
:cad_num, :quarter_cad_number,
:objdoc_id, :registers_id,
:purpose, :building_name,
:floors, :underground_floors,
:area, :build_record_area,
:cost_value, :cost_index,
:year_built, :year_commisioning,
CAST(:build_record_registration_date AS date),
CAST(:registration_date AS date),
:ownership_type,
:cultural_heritage_object, :cultural_heritage_val,
:readable_address, :status, :common_data_status,
:type, :build_record_type_value,
CASE WHEN :geom IS NOT NULL THEN
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857),
4326
)
ELSE NULL END,
CAST(:raw_props AS jsonb), :source, NOW(), NOW()
)
ON CONFLICT (cad_num) DO UPDATE SET
quarter_cad_number = EXCLUDED.quarter_cad_number,
objdoc_id = COALESCE(EXCLUDED.objdoc_id, cad_buildings.objdoc_id),
registers_id = COALESCE(EXCLUDED.registers_id, cad_buildings.registers_id),
purpose = EXCLUDED.purpose,
building_name = EXCLUDED.building_name,
floors = EXCLUDED.floors,
underground_floors = EXCLUDED.underground_floors,
area = EXCLUDED.area,
build_record_area = EXCLUDED.build_record_area,
cost_value = EXCLUDED.cost_value,
cost_index = EXCLUDED.cost_index,
year_built = EXCLUDED.year_built,
year_commisioning = EXCLUDED.year_commisioning,
build_record_registration_date = EXCLUDED.build_record_registration_date,
registration_date = EXCLUDED.registration_date,
ownership_type = EXCLUDED.ownership_type,
cultural_heritage_object = EXCLUDED.cultural_heritage_object,
cultural_heritage_val = EXCLUDED.cultural_heritage_val,
readable_address = EXCLUDED.readable_address,
status = EXCLUDED.status,
common_data_status = EXCLUDED.common_data_status,
type = EXCLUDED.type,
build_record_type_value = EXCLUDED.build_record_type_value,
geom = COALESCE(EXCLUDED.geom, cad_buildings.geom),
raw_props = EXCLUDED.raw_props,
source = EXCLUDED.source,
updated_at = NOW()
"""),
{
"cad_num": cad_num,
"quarter_cad_number": quarter_cad,
"objdoc_id": _safe_int(raw_opts.get("objdoc_id")),
"registers_id": _safe_int(raw_opts.get("registers_id")),
"purpose": raw_opts.get("purpose"),
"building_name": raw_opts.get("building_name") or raw_opts.get("name"),
"floors": floors_val,
"underground_floors": ugnd_floors_val,
"area": _safe_numeric(raw_opts.get("area")),
"build_record_area": _safe_numeric(raw_opts.get("build_record_area")),
"cost_value": _safe_numeric(raw_opts.get("cost_value")),
"cost_index": _safe_numeric(raw_opts.get("cost_index")),
"year_built": _safe_int(raw_opts.get("year_built")),
"year_commisioning": _safe_int(raw_opts.get("year_commisioning")),
"build_record_registration_date": raw_opts.get("build_record_registration_date"),
"registration_date": raw_opts.get("registration_date"),
"ownership_type": raw_opts.get("ownership_type"),
"cultural_heritage_object": cultural_heritage_bool,
"cultural_heritage_val": ch_str,
"readable_address": raw_opts.get("readable_address"),
"status": raw_opts.get("status"),
"common_data_status": raw_opts.get("common_data_status"),
"type": raw_opts.get("obj_type") or raw_opts.get("type"),
"build_record_type_value": (
raw_opts.get("build_record_type_value") or raw_opts.get("build_record_type")
),
"geom": geom_json,
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
"source": source,
},
)
# ── cad_constructions upsert ─────────────────────────────────────────────────
def upsert_construction(db: Session, feature: NSPDBulkFeature, source: str = "search") -> None:
"""Upsert сооружение в cad_constructions. ON CONFLICT (cad_num) DO UPDATE."""
raw_props = feature.properties
raw_opts = raw_props.get("options") or {}
cad_num = raw_opts.get("cad_num") or raw_opts.get("cad_number")
if not cad_num:
logger.debug("upsert_construction: пропуск — нет cad_num, feature id=%s", feature.id)
return
cat_id = feature.category_id
# Определить тип: 36383 = сооружение, у NSPD есть 2 варианта
is_egrn = bool(raw_opts.get("is_egrn") or raw_opts.get("registers_id"))
category_name = raw_props.get("categoryName") or str(cat_id or "")
quarter_cad = raw_opts.get("quarter_cad_number") or _derive_quarter_cad(cad_num)
geom_json = json.dumps(feature.geometry) if feature.geometry else None
# params_* поля из NSPD characteristics
params = raw_opts.get("params") or {}
db.execute(
text("""
INSERT INTO cad_constructions (
cad_num, quarter_cad_number,
category_name, is_egrn,
params_purpose, params_name,
params_area, params_built_up_area,
params_year_built, params_year_commisioning,
params_height, params_volume, params_floors, params_underground_floors,
object_type_value, facility_cad_number,
cost_value, cost_index,
ownership_type, right_type,
readable_address, address_readable_address,
status, common_data_status,
registration_date, registers_id, united_cad_number,
geom, raw_props, source, fetched_at, updated_at
) VALUES (
:cad_num, :quarter_cad_number,
:category_name, :is_egrn,
:params_purpose, :params_name,
:params_area, :params_built_up_area,
:params_year_built, :params_year_commisioning,
:params_height, :params_volume, :params_floors, :params_underground_floors,
:object_type_value, :facility_cad_number,
:cost_value, :cost_index,
:ownership_type, :right_type,
:readable_address, :address_readable_address,
:status, :common_data_status,
CAST(:registration_date AS date), :registers_id, :united_cad_number,
CASE WHEN :geom IS NOT NULL THEN
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326)
ELSE NULL END,
CAST(:raw_props AS jsonb), :source, NOW(), NOW()
)
ON CONFLICT (cad_num) DO UPDATE SET
category_name = EXCLUDED.category_name,
is_egrn = EXCLUDED.is_egrn,
params_purpose = EXCLUDED.params_purpose,
params_name = EXCLUDED.params_name,
params_area = EXCLUDED.params_area,
params_built_up_area = EXCLUDED.params_built_up_area,
params_year_built = EXCLUDED.params_year_built,
params_year_commisioning = EXCLUDED.params_year_commisioning,
params_height = EXCLUDED.params_height,
params_volume = EXCLUDED.params_volume,
params_floors = EXCLUDED.params_floors,
params_underground_floors = EXCLUDED.params_underground_floors,
object_type_value = EXCLUDED.object_type_value,
facility_cad_number = EXCLUDED.facility_cad_number,
cost_value = EXCLUDED.cost_value,
cost_index = EXCLUDED.cost_index,
ownership_type = EXCLUDED.ownership_type,
right_type = EXCLUDED.right_type,
readable_address = EXCLUDED.readable_address,
address_readable_address = EXCLUDED.address_readable_address,
status = EXCLUDED.status,
common_data_status = EXCLUDED.common_data_status,
registration_date = EXCLUDED.registration_date,
registers_id = EXCLUDED.registers_id,
united_cad_number = EXCLUDED.united_cad_number,
geom = COALESCE(EXCLUDED.geom, cad_constructions.geom),
raw_props = EXCLUDED.raw_props,
source = EXCLUDED.source,
updated_at = NOW()
"""),
{
"cad_num": cad_num,
"quarter_cad_number": quarter_cad,
"category_name": category_name,
"is_egrn": is_egrn,
"params_purpose": params.get("purpose") or raw_opts.get("purpose"),
"params_name": params.get("name") or raw_opts.get("building_name"),
"params_area": _safe_numeric(params.get("area") or raw_opts.get("area")),
"params_built_up_area": _safe_numeric(raw_opts.get("built_up_area")),
"params_year_built": _safe_int(params.get("year_built") or raw_opts.get("year_built")),
"params_year_commisioning": _safe_int(raw_opts.get("year_commisioning")),
"params_height": _safe_numeric(raw_opts.get("height")),
"params_volume": _safe_numeric(raw_opts.get("volume")),
"params_floors": _safe_int(raw_opts.get("floors")),
"params_underground_floors": _safe_int(raw_opts.get("underground_floors")),
"object_type_value": raw_opts.get("object_type_value"),
"facility_cad_number": raw_opts.get("facility_cad_number"),
"cost_value": _safe_numeric(raw_opts.get("cost_value")),
"cost_index": _safe_numeric(raw_opts.get("cost_index")),
"ownership_type": raw_opts.get("ownership_type"),
"right_type": raw_opts.get("right_type"),
"readable_address": raw_opts.get("readable_address"),
"address_readable_address": raw_opts.get("address_readable_address"),
"status": raw_opts.get("status"),
"common_data_status": raw_opts.get("common_data_status"),
"registration_date": raw_opts.get("registration_date"),
"registers_id": _safe_int(raw_opts.get("registers_id")),
"united_cad_number": raw_opts.get("united_cad_number"),
"geom": geom_json,
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
"source": source,
},
)
# ── cad_oncs upsert ──────────────────────────────────────────────────────────
def upsert_onc(db: Session, feature: NSPDBulkFeature, source: str = "search") -> None:
"""Upsert ОНС в cad_oncs. ON CONFLICT (cad_num) DO UPDATE."""
raw_opts = feature.properties.get("options") or {}
cad_num = raw_opts.get("cad_num") or raw_opts.get("cad_number")
if not cad_num:
logger.debug("upsert_onc: пропуск — нет cad_num, feature id=%s", feature.id)
return
quarter_cad = raw_opts.get("quarter_cad_number") or _derive_quarter_cad(cad_num)
geom_json = json.dumps(feature.geometry) if feature.geometry else None
db.execute(
text("""
INSERT INTO cad_oncs (
cad_num, quarter_cad_number,
purpose, area, build_record_area,
cost_value, status, readable_address,
geom, raw_props, source, fetched_at, updated_at
) VALUES (
:cad_num, :quarter_cad_number,
:purpose, :area, :build_record_area,
:cost_value, :status, :readable_address,
CASE WHEN :geom IS NOT NULL THEN
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326)
ELSE NULL END,
CAST(:raw_props AS jsonb), :source, NOW(), NOW()
)
ON CONFLICT (cad_num) DO UPDATE SET
quarter_cad_number = EXCLUDED.quarter_cad_number,
purpose = EXCLUDED.purpose,
area = EXCLUDED.area,
build_record_area = EXCLUDED.build_record_area,
cost_value = EXCLUDED.cost_value,
status = EXCLUDED.status,
readable_address = EXCLUDED.readable_address,
geom = COALESCE(EXCLUDED.geom, cad_oncs.geom),
raw_props = EXCLUDED.raw_props,
source = EXCLUDED.source,
updated_at = NOW()
"""),
{
"cad_num": cad_num,
"quarter_cad_number": quarter_cad,
"purpose": raw_opts.get("purpose"),
"area": _safe_numeric(raw_opts.get("area")),
"build_record_area": _safe_numeric(raw_opts.get("build_record_area")),
"cost_value": _safe_numeric(raw_opts.get("cost_value")),
"status": raw_opts.get("status"),
"readable_address": raw_opts.get("readable_address"),
"geom": geom_json,
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
"source": source,
},
)
# ── cad_enk upsert ───────────────────────────────────────────────────────────
def upsert_enk(db: Session, feature: NSPDBulkFeature, source: str = "search") -> None:
"""Upsert ЕНК в cad_enk. ON CONFLICT (cad_num) DO UPDATE."""
raw_opts = feature.properties.get("options") or {}
cad_num = raw_opts.get("cad_num") or raw_opts.get("cad_number")
if not cad_num:
logger.debug("upsert_enk: пропуск — нет cad_num, feature id=%s", feature.id)
return
quarter_cad = raw_opts.get("quarter_cad_number") or _derive_quarter_cad(cad_num)
geom_json = json.dumps(feature.geometry) if feature.geometry else None
db.execute(
text("""
INSERT INTO cad_enk (
cad_num, quarter_cad_number,
name, purpose, type,
cost_value, facility_cad_number,
readable_address, registration_date, registers_id,
geom, raw_props, fetched_at, updated_at
) VALUES (
:cad_num, :quarter_cad_number,
:name, :purpose, :type,
:cost_value, :facility_cad_number,
:readable_address, CAST(:registration_date AS date), :registers_id,
CASE WHEN :geom IS NOT NULL THEN
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326)
ELSE NULL END,
CAST(:raw_props AS jsonb), NOW(), NOW()
)
ON CONFLICT (cad_num) DO UPDATE SET
quarter_cad_number = EXCLUDED.quarter_cad_number,
name = EXCLUDED.name,
purpose = EXCLUDED.purpose,
type = EXCLUDED.type,
cost_value = EXCLUDED.cost_value,
facility_cad_number = EXCLUDED.facility_cad_number,
readable_address = EXCLUDED.readable_address,
registration_date = EXCLUDED.registration_date,
registers_id = EXCLUDED.registers_id,
geom = COALESCE(EXCLUDED.geom, cad_enk.geom),
raw_props = EXCLUDED.raw_props,
updated_at = NOW()
"""),
{
"cad_num": cad_num,
"quarter_cad_number": quarter_cad,
"name": raw_opts.get("name"),
"purpose": raw_opts.get("purpose"),
"type": raw_opts.get("type") or raw_opts.get("object_type"),
"cost_value": _safe_numeric(raw_opts.get("cost_value")),
"facility_cad_number": raw_opts.get("facility_cad_number"),
"readable_address": raw_opts.get("readable_address"),
"registration_date": raw_opts.get("registration_date"),
"registers_id": _safe_int(raw_opts.get("registers_id")),
"geom": geom_json,
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
},
)
# ── cad_zouit upsert ─────────────────────────────────────────────────────────
def upsert_zouit(db: Session, feature: NSPDBulkFeature, source: str = "search") -> None:
"""Upsert ЗОУИТ в cad_zouit.
Deduplicate по (reg_numb_border, category_id).
Geom — MultiPolygon; если пришёл Polygon — NSPD иногда так делает.
"""
raw_opts = feature.properties.get("options") or {}
cat_id = feature.category_id
reg_numb_border = raw_opts.get("reg_numb_border") or raw_opts.get("registration_number")
type_zone = raw_opts.get("type_zone") or raw_opts.get("typeZone") or ""
if not reg_numb_border or cat_id is None:
logger.debug(
"upsert_zouit: пропуск — нет reg_numb_border или category_id, feature id=%s",
feature.id,
)
return
geom_json = json.dumps(feature.geometry) if feature.geometry else None
db.execute(
text("""
INSERT INTO cad_zouit (
reg_numb_border, category_id, category_name,
type_zone, type_boundary_value, subcategory,
cadastral_district,
content_restrict_encumbrances, name_by_doc,
legal_act_name, legal_act_date, legal_act_number,
legal_act_issuer, registration_date,
old_account_number,
geom, raw_props, source, fetched_at, updated_at
) VALUES (
:reg_numb_border, :category_id, :category_name,
:type_zone, :type_boundary_value, :subcategory,
:cadastral_district,
:content_restrict_encumbrances, :name_by_doc,
:legal_act_name, CAST(:legal_act_date AS date), :legal_act_number,
:legal_act_issuer, CAST(:registration_date AS date),
:old_account_number,
CASE WHEN :geom IS NOT NULL THEN
ST_Multi(
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(:geom), 3857), 4326)
)
ELSE NULL END,
CAST(:raw_props AS jsonb), :source, NOW(), NOW()
)
ON CONFLICT (reg_numb_border, category_id) DO UPDATE SET
category_name = EXCLUDED.category_name,
type_zone = EXCLUDED.type_zone,
type_boundary_value = EXCLUDED.type_boundary_value,
subcategory = EXCLUDED.subcategory,
cadastral_district = EXCLUDED.cadastral_district,
content_restrict_encumbrances = EXCLUDED.content_restrict_encumbrances,
name_by_doc = EXCLUDED.name_by_doc,
legal_act_name = EXCLUDED.legal_act_name,
legal_act_date = EXCLUDED.legal_act_date,
legal_act_number = EXCLUDED.legal_act_number,
legal_act_issuer = EXCLUDED.legal_act_issuer,
registration_date = EXCLUDED.registration_date,
old_account_number = EXCLUDED.old_account_number,
geom = COALESCE(EXCLUDED.geom, cad_zouit.geom),
raw_props = EXCLUDED.raw_props,
source = EXCLUDED.source,
updated_at = NOW()
"""),
{
"reg_numb_border": reg_numb_border,
"category_id": cat_id,
"category_name": feature.properties.get("categoryName") or str(cat_id),
"type_zone": type_zone,
"type_boundary_value": raw_opts.get("type_boundary_value"),
"subcategory": _safe_int(raw_opts.get("subcategory")),
"cadastral_district": raw_opts.get("cadastral_district"),
"content_restrict_encumbrances": raw_opts.get("content_restrict_encumbrances"),
"name_by_doc": raw_opts.get("name_by_doc"),
"legal_act_name": raw_opts.get("legal_act_name"),
"legal_act_date": raw_opts.get("legal_act_date"),
"legal_act_number": raw_opts.get("legal_act_number"),
"legal_act_issuer": raw_opts.get("legal_act_issuer"),
"registration_date": raw_opts.get("registration_date"),
"old_account_number": raw_opts.get("old_account_number"),
"geom": geom_json,
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
"source": source,
},
)
# ── cad_quarter_stats upsert ─────────────────────────────────────────────────
def upsert_quarter_stats(
db: Session,
quarter: str,
feature: NSPDBulkFeature,
) -> None:
"""Upsert квартальную статистику из feature 36381 в cad_quarter_stats.
cad_quarter_stats имеет FK на cad_quarters_geom(cad_number).
Если квартала ещё нет в cad_quarters_geom — пропускаем тихо
(INSERT не нарушит FK, но строка не появится; при следующем
проходе после добавления квартала — сработает).
"""
raw_opts = feature.properties.get("options") or {}
db.execute(
text("""
INSERT INTO cad_quarter_stats (
cad_number,
cnt_land, cnt_oks,
cnt_land_geom, cnt_land_not_geom,
cnt_oks_area, cnt_oks_geom,
cnt_enk, cnt_pik,
cost_value_total, cost_value_total_geom,
sum_land_area, sum_land_geom_area,
date_cr, real_srid,
raw_props, fetched_at, updated_at
)
SELECT
:cad_number,
:cnt_land, :cnt_oks,
:cnt_land_geom, :cnt_land_not_geom,
:cnt_oks_area, :cnt_oks_geom,
:cnt_enk, :cnt_pik,
:cost_value_total, :cost_value_total_geom,
:sum_land_area, :sum_land_geom_area,
CAST(:date_cr AS date), :real_srid,
CAST(:raw_props AS jsonb), NOW(), NOW()
WHERE EXISTS (
SELECT 1 FROM cad_quarters_geom WHERE cad_number = :cad_number
)
ON CONFLICT (cad_number) DO UPDATE SET
cnt_land = EXCLUDED.cnt_land,
cnt_oks = EXCLUDED.cnt_oks,
cnt_land_geom = EXCLUDED.cnt_land_geom,
cnt_land_not_geom = EXCLUDED.cnt_land_not_geom,
cnt_oks_area = EXCLUDED.cnt_oks_area,
cnt_oks_geom = EXCLUDED.cnt_oks_geom,
cnt_enk = EXCLUDED.cnt_enk,
cnt_pik = EXCLUDED.cnt_pik,
cost_value_total = EXCLUDED.cost_value_total,
cost_value_total_geom = EXCLUDED.cost_value_total_geom,
sum_land_area = EXCLUDED.sum_land_area,
sum_land_geom_area = EXCLUDED.sum_land_geom_area,
date_cr = EXCLUDED.date_cr,
real_srid = EXCLUDED.real_srid,
raw_props = EXCLUDED.raw_props,
updated_at = NOW()
"""),
{
"cad_number": quarter,
"cnt_land": _safe_int(raw_opts.get("cnt_land")),
"cnt_oks": _safe_int(raw_opts.get("cnt_oks")),
"cnt_land_geom": _safe_int(raw_opts.get("cnt_land_geom")),
"cnt_land_not_geom": _safe_int(raw_opts.get("cnt_land_not_geom")),
"cnt_oks_area": _safe_int(raw_opts.get("cnt_oks_area")),
"cnt_oks_geom": _safe_int(raw_opts.get("cnt_oks_geom")),
"cnt_enk": _safe_int(raw_opts.get("cnt_enk")),
"cnt_pik": _safe_int(raw_opts.get("cnt_pik")),
"cost_value_total": _safe_numeric(raw_opts.get("cost_value_total")),
"cost_value_total_geom": _safe_numeric(raw_opts.get("cost_value_total_geom")),
"sum_land_area": _safe_numeric(raw_opts.get("sum_land_area")),
"sum_land_geom_area": _safe_numeric(raw_opts.get("sum_land_geom_area")),
"date_cr": raw_opts.get("date_cr"),
"real_srid": _safe_int(raw_opts.get("real_srid")),
"raw_props": json.dumps(raw_opts, ensure_ascii=False),
},
)
# ── Утилиты ──────────────────────────────────────────────────────────────────
def _derive_quarter_cad(cad_num: str) -> str:
"""Вывести номер квартала из кад. номера: '66:41:0303161:1''66:41:0303161'."""
parts = cad_num.split(":")
if len(parts) >= 3:
return ":".join(parts[:3])
return cad_num
def _safe_numeric(value: Any) -> float | None:
"""Конвертировать значение в float или None (безопасно)."""
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
return None
def _safe_int(value: Any) -> int | None:
"""Конвертировать значение в int или None (безопасно)."""
if value is None:
return None
try:
return int(value)
except (ValueError, TypeError):
return None