gendesign/backend/app/workers/tasks/opportunity_harvest.py
lekss361 fe7727e338
Some checks are pending
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
fix(sf): per-quarter commit в ird/opportunity harvest — не терять прогресс при краше (#1107)
ird_harvest + opportunity_harvest: commit после КАЖДОГО квартала (внутри per-quarter try) + db.rollback() в except перед continue. Прод-баг: single end-commit терял весь прогресс многочасового grid-walk при краше/таймауте/WorkerLost. Теперь durable поквартально; SAVEPOINT per-row не тронут. +2 теста (commit-count durability, flaky-quarter rollback+continue).

Refs #1067.
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-06 21:47:35 +00:00

109 lines
6 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 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()