From 5f8df655cc2d4de5a1b9148e7ba5e6bb6fb6c2f2 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Fri, 15 May 2026 18:52:38 +0300 Subject: [PATCH] fix(cadastre): cad_parcels.geom Polygon -> MultiPolygon (migration 93) (#185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NSPD returns MultiPolygon for Многоконтурный участок (e.g. 66:41:0105017:4) which crashed upsert_parcel — schema was strict POLYGON. Migration 93: ALTER COLUMN geom Polygon -> MultiPolygon USING ST_Multi(geom), DROP+RECREATE GIST index + back-compat VIEW cad_parcels_geom. upsert_parcel SQL wraps ST_Transform in ST_Multi() — coerces Polygon to MultiPolygon. NSPD returns both types (simple + multi-contour parcels). Co-authored-by: lekss361 --- backend/app/services/cadastre/bulk_harvest.py | 11 +++- backend/tests/services/test_cadastre_bulk.py | 17 ++++++ data/sql/93_cad_parcels_geom_multipolygon.sql | 61 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 data/sql/93_cad_parcels_geom_multipolygon.sql diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index d8530681..58806217 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -445,10 +445,15 @@ def upsert_parcel(db: Session, feature: NSPDBulkFeature, source: str = "search") :readable_address, :status, :previously_posted, CAST(:registration_date AS date), :subcategory, :cadastral_districts_code, + -- ST_Multi: cad_parcels.geom = MULTIPOLYGON (migration 93). + -- NSPD возвращает Polygon ИЛИ MultiPolygon (Многоконтурные участки) — + -- оба нужно сохранить, поэтому ST_Multi() coerce'ит Polygon → MultiPolygon. CASE WHEN CAST(:geom AS text) IS NOT NULL THEN - ST_Transform( - ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857), - 4326 + ST_Multi( + ST_Transform( + ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857), + 4326 + ) ) ELSE NULL END, CAST(:raw_props AS jsonb), :source, NOW(), NOW() diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index fb4c8472..19909e3b 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -345,6 +345,23 @@ def test_upsert_parcel_uses_cast_not_double_colon() -> None: assert "CAST(:raw_props AS jsonb)" in sql +def test_upsert_parcel_sql_uses_st_multi_for_multipolygon_schema() -> None: + """cad_parcels.geom = MULTIPOLYGON (migration 93) — SQL должен ST_Multi() оборачивать + transform, чтобы Polygon coerce'ился в MultiPolygon (NSPD возвращает оба варианта). + """ + from app.services.cadastre.bulk_harvest import upsert_parcel + + db = MagicMock() + executed_sqls: list[str] = [] + db.execute = lambda stmt, params=None: (executed_sqls.append(str(stmt)) or MagicMock()) + + upsert_parcel(db, _make_parcel_feature(), source="search") + + assert len(executed_sqls) == 1 + sql = executed_sqls[0] + assert "ST_Multi(" in sql, "SQL должен содержать ST_Multi для MultiPolygon schema" + + # ── harvest_quarter integration (async, mocked) ─────────────────────────────── diff --git a/data/sql/93_cad_parcels_geom_multipolygon.sql b/data/sql/93_cad_parcels_geom_multipolygon.sql new file mode 100644 index 00000000..8d5d5999 --- /dev/null +++ b/data/sql/93_cad_parcels_geom_multipolygon.sql @@ -0,0 +1,61 @@ +-- 93_cad_parcels_geom_multipolygon.sql +-- --------------------------------------------------------------------------- +-- Контекст: +-- NSPD возвращает MultiPolygon для "Многоконтурный участок" (например +-- 66:41:0105017:4 — гаражный массив с несколькими parts). cad_parcels.geom +-- = geometry(Polygon, 4326) strict → INSERT падал с psycopg.DataError. +-- +-- Pilot v8/v10 умирали на MultiPolygon parcels (worker crash → autoretry +-- exhausted → job hang). +-- +-- Действия: +-- 1. DROP back-compat VIEW cad_parcels_geom (зависит от geom column) +-- 2. ALTER COLUMN cad_parcels.geom TYPE MultiPolygon USING ST_Multi(geom) +-- — лосслесная конверсия 1199 existing Polygon rows → MultiPolygon +-- 3. RECREATE GIST index (ALTER COLUMN не сохраняет тип-специфичный индекс) +-- 4. RECREATE VIEW cad_parcels_geom с тем же SELECT +-- +-- Idempotent: использует ST_GeometryType check + IF NOT EXISTS на индексах. +-- Apply order: после 92_cad_bulk_layers.sql, до bulk ingest jobs. +-- --------------------------------------------------------------------------- + +BEGIN; + +-- 1. DROP back-compat VIEW (зависит от geom column type) +DROP VIEW IF EXISTS cad_parcels_geom; + +-- 2. ALTER column type Polygon → MultiPolygon (idempotent: skip if already MultiPolygon) +DO $$ +DECLARE + current_type TEXT; +BEGIN + SELECT type INTO current_type + FROM geometry_columns + WHERE f_table_name = 'cad_parcels' AND f_geometry_column = 'geom'; + + IF current_type = 'POLYGON' THEN + ALTER TABLE cad_parcels + ALTER COLUMN geom TYPE geometry(MultiPolygon, 4326) + USING ST_Multi(geom); + RAISE NOTICE 'cad_parcels.geom converted Polygon -> MultiPolygon (rows: %)', + (SELECT COUNT(*) FROM cad_parcels WHERE geom IS NOT NULL); + ELSE + RAISE NOTICE 'cad_parcels.geom already type=% — skip ALTER', current_type; + END IF; +END$$; + +-- 3. GIST индекс — DROP + CREATE для надёжности (тип column изменился) +DROP INDEX IF EXISTS cad_parcels_geom_gist; +CREATE INDEX IF NOT EXISTS cad_parcels_geom_gist + ON cad_parcels USING GIST (geom); + +-- 4. RECREATE back-compat VIEW (идентичный SELECT, но новый geom type) +CREATE OR REPLACE VIEW cad_parcels_geom AS +SELECT cad_num, geom, raw_props, fetched_at +FROM cad_parcels; + +COMMENT ON VIEW cad_parcels_geom IS + 'Back-compat VIEW over cad_parcels (geom column now MultiPolygon). ' + 'Legacy callers expecting cad_parcels_geom table continue работать.'; + +COMMIT;