diff --git a/backend/app/api/v1/admin_scrape.py b/backend/app/api/v1/admin_scrape.py index c5eb1965..33a8f77d 100644 --- a/backend/app/api/v1/admin_scrape.py +++ b/backend/app/api/v1/admin_scrape.py @@ -384,6 +384,22 @@ def trigger_noise_sync( return {"task_id": result.id, "queued_at": "now"} +@router.post("/pzz-sync") +def trigger_pzz_sync( + x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, +) -> dict[str, Any]: + """Manual trigger для импорта ПЗЗ территориальных зон ЕКБ из Росреестр PKK6. + + Запускать после деплоя миграции 85_pzz_zones_ekb.sql и при необходимости + обновить данные о зонировании (обычно раз в несколько месяцев). + """ + _check_token(x_admin_token) + from app.workers.tasks.pzz_sync import sync_pzz_zones_ekb + + result = sync_pzz_zones_ekb.apply_async() + return {"task_id": result.id, "queued_at": "now"} + + @router.post("/poi-sync") def trigger_poi_sync( x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 05bdb5a5..abcfa6df 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -754,7 +754,42 @@ def analyze_parcel( logger.warning("market_trend query failed for %s: %s", cad_num, e) market_trend = None - # 10b) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS + # 10b) Zoning — территориальная зона ПЗЗ из Росреестр PKK6 + zoning: dict[str, Any] | None = None + try: + zoning_row = ( + db.execute( + text(""" + SELECT zone_code, zone_name, description, rosreestr_id + FROM pzz_zones_ekb + WHERE ST_Within( + ST_Centroid(ST_GeomFromText(:wkt, 4326)), + geom + ) + LIMIT 1 + """), + {"wkt": geom_wkt}, + ) + .mappings() + .first() + ) + if zoning_row: + zoning = { + "zone_code": zoning_row["zone_code"], + "zone_name": zoning_row["zone_name"], + "description": zoning_row["description"], + "rosreestr_id": zoning_row["rosreestr_id"], + "source": "rosreestr-pkk6", + "note": ( + "Зона из ЕГРН (Росреестр). Регламенты (макс. высота, КЗ, ВРИ) " + "не хранятся в ЕГРН — для них нужен текст ПЗЗ муниципалитета." + ), + } + except Exception as e: + logger.warning("zoning query failed for %s: %s", cad_num, e) + zoning = None + + # 10c) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS karpinsky_url = ( f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/" f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12" @@ -806,6 +841,7 @@ def analyze_parcel( "utilities": utilities, "geotech_risk": _geotech_risk(66, db, geom_wkt), "market_trend": market_trend, + "zoning": zoning, "isochrones_available": bool(settings.openrouteservice_api_key), } diff --git a/backend/app/services/site_finder/pzz_loader.py b/backend/app/services/site_finder/pzz_loader.py new file mode 100644 index 00000000..7da7140a --- /dev/null +++ b/backend/app/services/site_finder/pzz_loader.py @@ -0,0 +1,145 @@ +"""Импорт ПЗЗ территориальных зон ЕКБ из Росреестр PKK6.""" + +import asyncio +import json +import logging + +import httpx +from sqlalchemy import text + +from app.core.db import SessionLocal + +logger = logging.getLogger(__name__) + +PKK6_URL = "https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/" "MapServer/5/query" +# bbox ЕКБ: (xmin, ymin, xmax, ymax) в WGS84 +EKB_BBOX = (60.5, 56.7, 60.75, 56.95) + + +async def fetch_pkk6_zones() -> list[dict]: + """Запросить ВСЕ территориальные зоны для bbox ЕКБ с пагинацией. + + PKK6 maxRecordCount = 1000 per request. Пагинируем через resultOffset. + Rate-limit: asyncio.sleep(0.5) между страницами. + """ + xmin, ymin, xmax, ymax = EKB_BBOX + geometry = json.dumps( + { + "xmin": xmin, + "ymin": ymin, + "xmax": xmax, + "ymax": ymax, + "spatialReference": {"wkid": 4326}, + } + ) + + headers = { + "User-Agent": "GenDesign-SiteFinder/1.0 (+https://gendsgn.ru)", + "Accept": "application/json", + } + all_features: list[dict] = [] + offset = 0 + page_size = 1000 + + async with httpx.AsyncClient(timeout=60, headers=headers) as client: + while True: + params = { + "where": "1=1", + "geometry": geometry, + "geometryType": "esriGeometryEnvelope", + "inSR": "4326", + "outSR": "4326", + "outFields": "*", + "f": "geojson", + "resultOffset": offset, + "resultRecordCount": page_size, + } + r = await client.get(PKK6_URL, params=params) + r.raise_for_status() + data = r.json() + features = data.get("features", []) + if not features: + break + all_features.extend(features) + logger.info("PKK6 page offset=%d → %d features", offset, len(features)) + if len(features) < page_size: + break + offset += page_size + await asyncio.sleep(0.5) # вежливый rate-limit + + logger.info("PKK6: total %d zones for EKB bbox", len(all_features)) + return all_features + + +def sync_pzz_zones_to_db() -> dict[str, int]: + """Загрузить зоны из PKK6 и UPSERT в pzz_zones_ekb. + + Возвращает словарь {fetched, inserted, updated, skipped}. + """ + features = asyncio.run(fetch_pkk6_zones()) + inserted = 0 + updated = 0 + skipped = 0 + db = SessionLocal() + try: + for f in features: + props = f.get("properties") or {} + geom = f.get("geometry") + if not geom: + skipped += 1 + continue + rosreestr_id = props.get("OBJECTID") or props.get("objectid") + zone_code = ( + props.get("zone_number") + or props.get("zone_code") + or props.get("identification_number") + or None + ) + zone_name = ( + props.get("description") or props.get("name") or props.get("zone_name") or None + ) + description = props.get("description") + try: + result = db.execute( + text(""" + INSERT INTO pzz_zones_ekb + (rosreestr_id, zone_code, zone_name, description, raw_props, geom) + VALUES ( + :rid, :code, :nm, :desc, CAST(:props AS jsonb), + ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(:g), 4326)) + ) + ON CONFLICT (rosreestr_id) DO UPDATE + SET zone_code = EXCLUDED.zone_code, + zone_name = EXCLUDED.zone_name, + description = EXCLUDED.description, + raw_props = EXCLUDED.raw_props, + geom = EXCLUDED.geom, + fetched_at = NOW() + RETURNING (xmax = 0) AS is_insert + """), + { + "rid": rosreestr_id, + "code": zone_code, + "nm": zone_name, + "desc": description, + "props": json.dumps(props, ensure_ascii=False), + "g": json.dumps(geom), + }, + ).scalar() + if result: + inserted += 1 + else: + updated += 1 + except Exception as e: + logger.warning("PZZ insert failed for rosreestr_id=%s: %s", rosreestr_id, e) + db.rollback() + skipped += 1 + db.commit() + finally: + db.close() + return { + "fetched": len(features), + "inserted": inserted, + "updated": updated, + "skipped": skipped, + } diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index d624a52a..a7d64213 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -55,6 +55,7 @@ celery_app = Celery( "app.workers.tasks.nspd_geo", "app.workers.tasks.poi_sync", "app.workers.tasks.noise_sync", + "app.workers.tasks.pzz_sync", ], ) celery_app.conf.timezone = "Europe/Moscow" diff --git a/backend/app/workers/tasks/pzz_sync.py b/backend/app/workers/tasks/pzz_sync.py new file mode 100644 index 00000000..83f01aa7 --- /dev/null +++ b/backend/app/workers/tasks/pzz_sync.py @@ -0,0 +1,51 @@ +"""Celery task: импорт ПЗЗ территориальных зон ЕКБ из Росреестр PKK6.""" + +import logging + +from sqlalchemy import text + +from app.workers.celery_app import celery_app + +logger = logging.getLogger(__name__) + + +def _log_breadcrumb(level: str, message: str) -> None: + """Записать строку в nspd_geo_log (job_id=NULL, stage='pzz_sync').""" + try: + from app.core.db import SessionLocal + + db = SessionLocal() + try: + db.execute( + text( + "INSERT INTO nspd_geo_log (job_id, level, stage, message) " + "VALUES (NULL, :lvl, 'pzz_sync', :msg)" + ), + {"lvl": level, "msg": message[:500]}, + ) + db.commit() + finally: + db.close() + except Exception as e: + logger.warning("pzz_sync breadcrumb failed: %s", e) + + +@celery_app.task(name="tasks.pzz_sync.sync_pzz_zones_ekb", queue="celery") +def sync_pzz_zones_ekb() -> dict: + """Импорт ПЗЗ территориальных зон ЕКБ из Росреестр PKK6. + + Запускается вручную через POST /api/v1/admin/scrape/pzz-sync. + Persistent breadcrumbs в nspd_geo_log (stage='pzz_sync') для диагностики. + """ + _log_breadcrumb("info", "task started") + try: + from app.services.site_finder.pzz_loader import sync_pzz_zones_to_db + + result = sync_pzz_zones_to_db() + logger.info("pzz_sync done: %s", result) + _log_breadcrumb("info", f"done: {result}") + return result + except Exception as e: + logger.exception("pzz_sync failed: %s", e) + _log_breadcrumb("error", f"{type(e).__name__}: {e}") + raise diff --git a/data/sql/85_pzz_zones_ekb.sql b/data/sql/85_pzz_zones_ekb.sql new file mode 100644 index 00000000..c8ff80e5 --- /dev/null +++ b/data/sql/85_pzz_zones_ekb.sql @@ -0,0 +1,33 @@ +-- 85_pzz_zones_ekb.sql +-- Таблица территориальных зон ЕКБ из Росреестр PKK6 (Публичная кадастровая карта). +-- +-- Данные: ArcGIS REST https://pkk.rosreestr.ru/arcgis/rest/services/PKK6/ZONES/MapServer/5/query +-- Загружается через Celery task tasks.pzz_sync.sync_pzz_zones_ekb +-- (ad-hoc: POST /api/v1/admin/scrape/pzz-sync). +-- +-- Зависимости: PostGIS (уже установлен). +-- Применять: psql gendesign < data/sql/85_pzz_zones_ekb.sql + +BEGIN; + +CREATE TABLE IF NOT EXISTS pzz_zones_ekb ( + id BIGSERIAL PRIMARY KEY, + rosreestr_id BIGINT, -- OBJECTID из PKK6 + zone_code TEXT, -- "Ж-2.1", "ОД-1" и т.д. + zone_name TEXT, -- полное наименование зоны + description TEXT, + raw_props JSONB DEFAULT '{}'::jsonb, + geom GEOMETRY(MultiPolygon, 4326) NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(rosreestr_id) +); + +CREATE INDEX IF NOT EXISTS pzz_zones_ekb_geom_idx ON pzz_zones_ekb USING GIST(geom); +CREATE INDEX IF NOT EXISTS pzz_zones_ekb_code_idx ON pzz_zones_ekb (zone_code); + +COMMENT ON TABLE pzz_zones_ekb IS + 'Территориальные зоны ЕКБ из Росреестр PKK6 (Public Cadastral Map). ' + 'Содержит границы зон + код/имя. ' + 'Регламенты (max_height, FAR) — отдельно через pzz_regulations.'; + +COMMIT;