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"}
|
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")
|
@router.post("/poi-sync")
|
||||||
def trigger_poi_sync(
|
def trigger_poi_sync(
|
||||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
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)
|
logger.warning("market_trend query failed for %s: %s", cad_num, e)
|
||||||
market_trend = None
|
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 = (
|
karpinsky_url = (
|
||||||
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
|
f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/"
|
||||||
f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12"
|
f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12"
|
||||||
|
|
@ -806,6 +841,7 @@ def analyze_parcel(
|
||||||
"utilities": utilities,
|
"utilities": utilities,
|
||||||
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||||||
"market_trend": market_trend,
|
"market_trend": market_trend,
|
||||||
|
"zoning": zoning,
|
||||||
"isochrones_available": bool(settings.openrouteservice_api_key),
|
"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.nspd_geo",
|
||||||
"app.workers.tasks.poi_sync",
|
"app.workers.tasks.poi_sync",
|
||||||
"app.workers.tasks.noise_sync",
|
"app.workers.tasks.noise_sync",
|
||||||
|
"app.workers.tasks.pzz_sync",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
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