"""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()