From e3359c4f0355f4a8c406d22348c63590271ddbc3 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Fri, 15 May 2026 18:37:29 +0300 Subject: [PATCH] fix(cadastre): filter Point/LineString geometry in upsert_parcel/zouit (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cad_parcels.geom is geometry(Polygon, 4326) — strict schema. NSPD occasionally returns Point geometry for parcels without detailed boundary, causing INSERT failure: psycopg.errors.InvalidParameterValue: Geometry type (Point) does not match column type (Polygon) This killed pilot v8 at quarter 66:41:0104002 — worker autoretry exhausted, job hung at 25/50 (heartbeat stale). Fix: filter geometry.type at Python level in upsert_parcel + upsert_zouit (same pattern as upsert_quarter_geom_from_feature). Non-Polygon geometry → geom=NULL, raw_props preserved. Tables with permissive GEOMETRY schema (cad_buildings, cad_constructions, cad_enk, cad_oncs) unaffected — they accept any geometry type. Tests: 3 new (Point parcel → geom=NULL, Polygon happy path, LineString zouit). Co-authored-by: lekss361 --- backend/app/services/cadastre/bulk_harvest.py | 31 +++++++- backend/tests/services/test_cadastre_bulk.py | 79 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 40e7dcae..d8530681 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -406,7 +406,21 @@ def upsert_parcel(db: Session, feature: NSPDBulkFeature, source: str = "search") return raw_opts = feature.properties.get("options") or {} - geom_json = json.dumps(feature.geometry) if feature.geometry else None + # cad_parcels.geom — geometry(Polygon, 4326). NSPD изредка возвращает Point + # для участков без detailed boundary — INSERT fail'нит на schema check. + # Сохраняем raw_props (есть координаты), geom=NULL. Не блокирует Phase 1. + geom = feature.geometry or {} + geom_type = geom.get("type") if isinstance(geom, dict) else None + if geom_type in ("Polygon", "MultiPolygon"): + geom_json: str | None = json.dumps(feature.geometry) + else: + if geom_type: + logger.info( + "upsert_parcel: cad_num=%s имеет geometry type=%s (не Polygon) — geom=NULL", + cad_num, + geom_type, + ) + geom_json = None quarter_cad = opts.quarter_cad_number or _derive_quarter_cad(cad_num) db.execute( @@ -880,7 +894,20 @@ def upsert_zouit(db: Session, feature: NSPDBulkFeature, source: str = "search") ) return - geom_json = json.dumps(feature.geometry) if feature.geometry else None + # cad_zouit.geom — geometry(MultiPolygon, 4326). Polygon допустим (ST_Multi + # обернёт в SQL), но Point/LineString → INSERT fail. Фильтруем как в upsert_parcel. + geom = feature.geometry or {} + geom_type = geom.get("type") if isinstance(geom, dict) else None + if geom_type in ("Polygon", "MultiPolygon"): + geom_json: str | None = json.dumps(feature.geometry) + else: + if geom_type: + logger.info( + "upsert_zouit: reg=%s имеет geometry type=%s (не Polygon) — geom=NULL", + reg_numb_border, + geom_type, + ) + geom_json = None db.execute( text(""" diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index 5431bfe6..fb4c8472 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -923,6 +923,85 @@ def test_upsert_quarter_geom_sql_uses_cast_not_double_colon() -> None: assert "CAST(:geom AS text)" in sql +def test_upsert_parcel_skips_geom_for_point_geometry() -> None: + """NSPD изредка возвращает Point для ЗУ — cad_parcels.geom strict Polygon, + INSERT fail'нул бы. Фильтр на Python level → geom=NULL, raw_props сохранён. + """ + from app.services.cadastre.bulk_harvest import upsert_parcel + + point_parcel = NSPDBulkFeature.model_validate( + { + "id": 99, + "type": "Feature", + "geometry": {"type": "Point", "coordinates": [6753892.91, 7747664.31]}, + "properties": { + "category": 36368, + "options": { + "cad_num": "66:41:0104002:18", + "quarter_cad_number": "66:41:0104002", + "declared_area": 200.0, + "readable_address": "г Екатеринбург, ул Тестовая", + }, + }, + } + ) + + db = MagicMock() + upsert_parcel(db, point_parcel, source="search") + + # db.execute должен быть вызван (parcel НЕ skipped, только geom=NULL) + db.execute.assert_called_once() + call_params = db.execute.call_args[0][1] + assert call_params["cad_num"] == "66:41:0104002:18" + # geom параметр должен быть None + assert call_params["geom"] is None + + +def test_upsert_parcel_keeps_polygon_geometry() -> None: + """Polygon geometry для ЗУ — обычный happy path.""" + from app.services.cadastre.bulk_harvest import upsert_parcel + + parcel = _make_parcel_feature() # Polygon + db = MagicMock() + upsert_parcel(db, parcel, source="search") + + db.execute.assert_called_once() + call_params = db.execute.call_args[0][1] + assert call_params["geom"] is not None + assert '"type": "Polygon"' in call_params["geom"] + + +def test_upsert_zouit_skips_geom_for_linestring() -> None: + """ЗОУИТ schema MultiPolygon — LineString не валиден, geom=NULL.""" + from app.services.cadastre.bulk_harvest import upsert_zouit + + line_zouit = NSPDBulkFeature.model_validate( + { + "id": 1, + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [[6090000, 7590000], [6090100, 7590100]], + }, + "properties": { + "category": 36940, + "options": { + "reg_numb_border": "66-0.1-2.1-99", + "type_zone": "Linear", + "registration_date": "2020-01-01", + }, + }, + } + ) + + db = MagicMock() + upsert_zouit(db, line_zouit, source="search") + + db.execute.assert_called_once() + call_params = db.execute.call_args[0][1] + assert call_params["geom"] is None + + def test_upsert_quarter_geom_custom_width_bounds() -> None: """min_width_m и max_width_m пробрасываются как параметры SQL (не hardcoded).""" db = _mock_db_execute_scalar(None)