diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index 88094fe4..8c85bc44 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -19,6 +19,7 @@ Resumable: phase_state в cadastre_jobs показывает прогресс. from __future__ import annotations +import hashlib import json import logging from collections.abc import Callable @@ -1210,10 +1211,16 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) geom = f.get("geometry") geom_geojson: str | None = json.dumps(geom) if geom else None - # zone_id — NSPD feature id или синтетический fallback - zone_id = str( - props.get("id") or props.get("zone_id") or f.get("id") or f"{quarter_cad}_{inserted}" - ) + # zone_id — NSPD feature id или стабильный fallback на основе md5 от properties. + # md5 гарантирует идемпотентность между runs (счётчик inserted сбрасывается). + _raw_id = props.get("id") or props.get("zone_id") or f.get("id") + if _raw_id: + zone_id = str(_raw_id) + else: + _props_hash = hashlib.md5( + json.dumps(props, sort_keys=True).encode("utf-8") + ).hexdigest()[:12] + zone_id = f"{quarter_cad}_{_props_hash}" zone_code = ( props.get("zone_code") or props.get("zone_index") or props.get("reg_numb_border") ) @@ -1221,6 +1228,9 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) permitted_use = props.get("permitted_use") or props.get("vri") try: + # begin_nested() требует активной outer-транзакции для SAVEPOINT. + # SQLAlchemy Session (autobegin=True) автоматически начинает tx при первом + # db.execute() в этом loop — outer tx гарантирована. with db.begin_nested(): db.execute( text(""" @@ -1235,7 +1245,10 @@ def _save_territorial_zones(db: Session, quarter_cad: str, features: list[dict]) CAST(:permitted_use AS text), CAST(:raw_props AS jsonb), CASE WHEN CAST(:geom AS text) IS NOT NULL - THEN ST_GeogFromGeoJSON(CAST(:geom AS text)) + THEN ST_Transform( + ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geom AS text)), 3857), + 4326 + )::geography ELSE NULL END ) diff --git a/backend/tests/services/cadastre/test_bulk_harvest_territorial.py b/backend/tests/services/cadastre/test_bulk_harvest_territorial.py index 556c9949..df40b3ed 100644 --- a/backend/tests/services/cadastre/test_bulk_harvest_territorial.py +++ b/backend/tests/services/cadastre/test_bulk_harvest_territorial.py @@ -102,7 +102,7 @@ class TestSaveTerritorialZones: assert call_kwargs["geom"] is None def test_feature_without_zone_id_uses_fallback(self) -> None: - """Feature без id → синтетический fallback zone_id не вызывает краш.""" + """Feature без id → md5-based fallback zone_id (stable между runs).""" db = _make_db_mock() features = [ { @@ -116,8 +116,18 @@ class TestSaveTerritorialZones: assert result == 1 call_kwargs = db.execute.call_args[0][1] - # fallback zone_id содержит quarter_cad - assert "66:41:0204016" in call_kwargs["zone_id"] + zone_id: str = call_kwargs["zone_id"] + # fallback zone_id содержит quarter_cad и стабильный hash (12 hex chars) + assert zone_id.startswith("66:41:0204016_") + suffix = zone_id.split("_", 3)[-1] + assert len(suffix) == 12 + assert all(c in "0123456789abcdef" for c in suffix) + + # Второй вызов с теми же данными → тот же zone_id (идемпотентность) + db2 = _make_db_mock() + _save_territorial_zones(db2, "66:41:0204016", features) + call_kwargs2 = db2.execute.call_args[0][1] + assert call_kwargs2["zone_id"] == zone_id def test_zone_id_from_props_id(self) -> None: """Если feature.id=None, но props['id'] есть — используется props['id'].""" diff --git a/data/sql/101_cad_nspd_overlay_tables.sql b/data/sql/102_cad_nspd_overlay_tables.sql similarity index 100% rename from data/sql/101_cad_nspd_overlay_tables.sql rename to data/sql/102_cad_nspd_overlay_tables.sql