feat(site-finder): ПЗЗ territorial zones from Rosreestr PKK6 + zoning in analyze

This commit is contained in:
lekss361 2026-05-11 21:51:51 +03:00
parent 5b03d6d799
commit 8be539ddc6
6 changed files with 283 additions and 1 deletions

View file

@ -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,

View file

@ -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),
}

View file

@ -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,
}

View file

@ -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"

View file

@ -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

View file

@ -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;