feat(site-finder): ПЗЗ territorial zones from Rosreestr PKK6 + zoning in analyze
This commit is contained in:
parent
5b03d6d799
commit
8be539ddc6
6 changed files with 283 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
145
backend/app/services/site_finder/pzz_loader.py
Normal file
145
backend/app/services/site_finder/pzz_loader.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
51
backend/app/workers/tasks/pzz_sync.py
Normal file
51
backend/app/workers/tasks/pzz_sync.py
Normal 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
|
||||
33
data/sql/85_pzz_zones_ekb.sql
Normal file
33
data/sql/85_pzz_zones_ekb.sql
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue