gendesign/backend/app/workers/tasks/ird_harvest.py
bot-backend fe452e3f31
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 6s
CI / backend-tests (push) Successful in 5m47s
CI / backend-tests (pull_request) Successful in 5m44s
fix(sf): per-quarter commit в ird/opportunity harvest — не терять прогресс при краше (#1067)
2026-06-07 00:43:04 +03:00

225 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Celery task: harvest ИРД-overlay геометрии из НСПД → ``ird_overlays`` (м.132).
#1067 (Site Finder v2 / слой ИРД для analyze, «GG-форсайт»), foundation B5. Для каждого
квартала ЕКБ (66:41:*) grid-walk'ит готовые НСПД-слои ИРД и UPSERT-ит фичи в ``ird_overlays``
по ключу ``uq_ird_overlays_feature`` (source_layer_id, geom_data_id). analyze читает overlap
участка через ``ST_Intersects``.
НСПД отдаёт по фиче ТОЛЬКО геометрию (EPSG:3857) + reg_numb_border + category/subcategory
(разведка 2026-06-06, vault research/NSPD_IRD_Layers_Automation_Recon_Jun06). content_* (тип/
ВРИ/параметры) дорезолвятся отдельно из ЕКБ-геопортала (C8/D9) и здесь НЕ трогаются.
Harvest-набор = готовые ИРД-слои (ID зашиты в nspd_client.LAYERS):
ЗОУИТ 37577-37581 (ОКН/инж/природа/охрана/иные) · 875838 терзоны ПЗЗ (геометрия) ·
875840 территории ОКН · 875845 ООПТ · 875866 лесничества · 875832/875835 береговые линии.
Mirror conventions (supply_layers_refresh.py / backend.md):
• SessionLocal() + try/finally close; logger (не print).
• SAVEPOINT per-row (``with db.begin_nested():``) — одна битая фича не валит батч; commit в конце.
• CAST(:x AS type) — НИКОГДА :x::type (psycopg v3). Геометрия: ST_Transform(3857→4326).
• ON CONFLICT обновляет геометрию/reg/category/subcategory/raw_props/fetched_at; content_*
НЕ трогает (их пишет резолвер D9 — re-harvest не затирает резолвленный тип/регламент).
• Сбой одного квартала/слоя не валит весь прогон.
Расписание — еженедельно (beat_schedule.py: ird-harvest-weekly). Геометрия зон стабильна,
чаще не нужно.
"""
from __future__ import annotations
import json
import logging
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
from app.services.scrapers.nspd_client import (
LAYERS,
NSPDClient,
_geojson_bbox_3857,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# Готовые ИРД-слои для harvest — ключи nspd_client.LAYERS. layer_kind в ird_overlays = этот ключ.
IRD_HARVEST_LAYER_KEYS: tuple[str, ...] = (
"zouit_okn",
"zouit_engineering",
"zouit_natural",
"zouit_protected",
"zouit_other",
"territorial_zones",
"okn_territory",
"protected_areas",
"forestries",
"coast_lines_river",
"coast_lines_sea",
)
# Источник кварталов ЕКБ: distinct quarter_cad_number из cad_parcels (66:41:*). quarter_cad_number
# деривится триггером (м.129). LIKE-паттерн — кадастровый район ЕКБ. CAST(:x AS type) psycopg v3.
_EKB_QUARTERS_SQL = text(
"""
SELECT DISTINCT quarter_cad_number
FROM cad_parcels
WHERE quarter_cad_number LIKE CAST(:prefix AS text)
AND quarter_cad_number IS NOT NULL
ORDER BY quarter_cad_number
"""
)
_EKB_QUARTER_PREFIX = "66:41:%"
# UPSERT фичи. Геометрия НСПД (GeoJSON 3857) → ST_GeomFromGeoJSON → SetSRID 3857 → Transform 4326.
# ON CONFLICT (source_layer_id, geom_data_id): обновляем геометрию/реквизиты/fetched_at;
# content_* НЕ трогаем (их пишет резолвер D9).
_UPSERT_SQL = text(
"""
INSERT INTO ird_overlays (
source_layer_id, layer_kind, geom_data_id, reg_numb_border,
category, subcategory, geom, raw_props, fetched_at
) VALUES (
CAST(:source_layer_id AS integer),
CAST(:layer_kind AS text),
CAST(:geom_data_id AS bigint),
CAST(:reg_numb_border AS text),
CAST(:category AS integer),
CAST(:subcategory AS integer),
ST_Transform(ST_SetSRID(ST_GeomFromGeoJSON(CAST(:geojson AS text)), 3857), 4326),
CAST(:raw_props AS jsonb),
CAST(:fetched_at AS timestamptz)
)
ON CONFLICT (source_layer_id, geom_data_id) DO UPDATE SET
layer_kind = EXCLUDED.layer_kind,
reg_numb_border = EXCLUDED.reg_numb_border,
category = EXCLUDED.category,
subcategory = EXCLUDED.subcategory,
geom = EXCLUDED.geom,
raw_props = EXCLUDED.raw_props,
fetched_at = EXCLUDED.fetched_at
"""
)
def _ekb_quarters(db: Session) -> list[str]:
"""Distinct кварталы ЕКБ (66:41:*) из cad_parcels."""
rows = db.execute(_EKB_QUARTERS_SQL, {"prefix": _EKB_QUARTER_PREFIX}).fetchall()
return [r[0] for r in rows if r[0]]
def _upsert_feature(
db: Session,
*,
layer_id: int,
layer_kind: str,
feature: Any,
fetched_at: str,
) -> bool:
"""UPSERT одной НСПД-фичи в ird_overlays под SAVEPOINT. True если записана.
Пропускает фичи без geometry или без geom_data_id (дедуп-ключ) — defensive,
одна битая не валит батч.
"""
geom = feature.geometry
props = feature.properties or {}
geom_data_id = props.get("geom_data_id")
if not geom or geom_data_id is None:
return False
opts = props.get("options") or {}
reg = props.get("reg_numb_border") or opts.get("reg_numb_border") or props.get("descr")
try:
with db.begin_nested():
db.execute(
_UPSERT_SQL,
{
"source_layer_id": layer_id,
"layer_kind": layer_kind,
"geom_data_id": int(geom_data_id),
"reg_numb_border": str(reg) if reg is not None else None,
"category": props.get("category"),
"subcategory": props.get("subcategory"),
"geojson": json.dumps(geom),
"raw_props": json.dumps(props, ensure_ascii=False),
"fetched_at": fetched_at,
},
)
return True
except Exception as exc:
logger.warning(
"ird_harvest: upsert failed layer=%s geom_data_id=%s: %s",
layer_kind,
geom_data_id,
exc,
)
return False
@celery_app.task(name="tasks.ird_harvest.harvest_ird_overlays")
def harvest_ird_overlays(quarters: list[str] | None = None) -> dict[str, int]:
"""Harvest ИРД-слоёв НСПД по кварталам ЕКБ → upsert ird_overlays.
Args:
quarters: явный список cad-номеров кварталов (3-сегментные '66:41:NNNNNNN').
None → берём distinct кварталы ЕКБ из cad_parcels.
Returns:
{'quarters': N, 'features': M} — обработано кварталов / записано фич.
"""
fetched_at = datetime.now(UTC).isoformat()
client = NSPDClient()
db = SessionLocal()
n_quarters = 0
n_features = 0
try:
quarter_list = quarters if quarters is not None else _ekb_quarters(db)
logger.info("ird_harvest: старт, кварталов=%d", len(quarter_list))
for quarter in quarter_list:
try:
qres = client.search_by_cad(quarter, thematic_id=2)
qfeat = qres.first
if not qfeat or not qfeat.geometry:
logger.info("ird_harvest: квартал %s пуст в НСПД, скип", quarter)
continue
bbox = _geojson_bbox_3857(qfeat.geometry)
if bbox is None:
continue
n_quarters += 1
for layer_key in IRD_HARVEST_LAYER_KEYS:
layer_id = LAYERS.get(layer_key)
if layer_id is None:
logger.warning("ird_harvest: нет layer_id для %s", layer_key)
continue
try:
feats = client.get_features_in_bbox_grid(layer_id, bbox)
except Exception as exc:
logger.warning(
"ird_harvest: grid-walk failed quarter=%s layer=%s: %s",
quarter,
layer_key,
exc,
)
continue
for feat in feats:
if _upsert_feature(
db,
layer_id=layer_id,
layer_kind=layer_key,
feature=feat,
fetched_at=fetched_at,
):
n_features += 1
# Durable per-quarter commit: длинный grid-walk (часы) не теряет прогресс при
# краше/таймауте/WorkerLost середины прогона (commit раз-в-конце терял ВСЁ).
db.commit()
except Exception as exc:
logger.warning("ird_harvest: квартал %s failed: %s", quarter, exc)
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
continue
db.commit() # финальный — на случай пустого списка / подстраховка
logger.info("ird_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features)
return {"quarters": n_quarters, "features": n_features}
finally:
db.close()