109 lines
6 KiB
Python
109 lines
6 KiB
Python
"""Celery task: harvest opportunity-ЗУ (future-supply) из НСПД → ``ird_overlays`` (#1086).
|
||
|
||
Future-supply / land opportunity — свободная и выставляемая под застройку земля. Источник —
|
||
НСПД (федеральный, единообразный, vault research/NSPD_IRD_Layers_Automation_Recon_Jun06). В центре
|
||
города такие ЗУ редки (точечный probe даёт 0), но по агломерации ЕКБ присутствуют → нужен
|
||
СПЛОШНОЙ grid-walk по кварталам, как в ИРД-harvest (#1067 B5).
|
||
|
||
DIRECT-сигналы «земля под застройку» (ID в nspd_client.LAYERS):
|
||
37299 ЗУ на аукционе · 37294 ЗУ по схеме расположения · 37298 ЗУ свободные от прав ·
|
||
36473 ЗУ по проекту межевания · 37430 территории ККР (комплексное развитие).
|
||
|
||
Переиспользует ДВИЖОК ИРД-harvest (один grid-walk + UPSERT-engine, не дублируем): тот же
|
||
``ird_overlays`` (м.132), тот же ``_upsert_feature`` (ST_Transform 3857→4326, SAVEPOINT per-row,
|
||
ON CONFLICT по (source_layer_id, geom_data_id)), те же кварталы ЕКБ (``_ekb_quarters``). Отличие —
|
||
набор слоёв (opportunity вместо ограничений) и ``layer_kind='opportunity_*'`` для фильтра в analyze.
|
||
|
||
Расписание — ежемесячно (beat_schedule.py: opportunity-harvest-monthly): opportunity-земля
|
||
меняется медленнее ограничений, еженедельный прогон избыточен.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import UTC, datetime
|
||
|
||
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
|
||
from app.workers.tasks.ird_harvest import _ekb_quarters, _upsert_feature
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Opportunity-слои: ключ nspd_client.LAYERS → layer_kind в ird_overlays ('opportunity_*' для
|
||
# фильтра в analyze «рядом свободная/аукционная земля»). НЕ ограничения — отдельный бакет.
|
||
OPPORTUNITY_LAYER_KINDS: dict[str, str] = {
|
||
"auction_parcels": "opportunity_auction",
|
||
"scheme_parcels": "opportunity_scheme",
|
||
"free_parcels": "opportunity_free",
|
||
"future_parcels": "opportunity_future",
|
||
"krt_territories": "opportunity_krt",
|
||
}
|
||
|
||
|
||
@celery_app.task(name="tasks.opportunity_harvest.harvest_opportunity_overlays")
|
||
def harvest_opportunity_overlays(quarters: list[str] | None = None) -> dict[str, int]:
|
||
"""Harvest opportunity-ЗУ НСПД по кварталам ЕКБ → ird_overlays (layer_kind='opportunity_*').
|
||
|
||
Args:
|
||
quarters: явный список cad-номеров кварталов ('66:41:NNNNNNN'). None → distinct
|
||
кварталы ЕКБ из cad_parcels (``_ekb_quarters``).
|
||
|
||
Returns:
|
||
{'quarters': N, 'features': M} — обработано кварталов / записано opportunity-фич.
|
||
"""
|
||
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("opportunity_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("opportunity_harvest: квартал %s пуст в НСПД, скип", quarter)
|
||
continue
|
||
bbox = _geojson_bbox_3857(qfeat.geometry)
|
||
if bbox is None:
|
||
continue
|
||
n_quarters += 1
|
||
for layer_key, layer_kind in OPPORTUNITY_LAYER_KINDS.items():
|
||
layer_id = LAYERS.get(layer_key)
|
||
if layer_id is None:
|
||
logger.warning("opportunity_harvest: нет layer_id для %s", layer_key)
|
||
continue
|
||
try:
|
||
feats = client.get_features_in_bbox_grid(layer_id, bbox)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"opportunity_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_kind,
|
||
feature=feat,
|
||
fetched_at=fetched_at,
|
||
):
|
||
n_features += 1
|
||
# Durable per-quarter commit: длинный grid-walk не теряет прогресс при
|
||
# краше/таймауте середины прогона (commit раз-в-конце терял ВСЁ).
|
||
db.commit()
|
||
except Exception as exc:
|
||
logger.warning("opportunity_harvest: квартал %s failed: %s", quarter, exc)
|
||
db.rollback() # сбросить незакоммиченный tx квартала перед следующим
|
||
continue
|
||
db.commit() # финальный — на случай пустого списка / подстраховка
|
||
logger.info("opportunity_harvest: готово, кварталов=%d фич=%d", n_quarters, n_features)
|
||
return {"quarters": n_quarters, "features": n_features}
|
||
finally:
|
||
db.close()
|