gendesign/backend/app/workers/tasks/objective_etl.py
lekss361 7c05d0a0d8 feat(objective): full sync pipeline + dynamic admin config
Objective API (api.objctv.ru) интегрирован как новый source of truth для
per-flat данных по новостройкам УрФО. Заменяет промежуточный Anton-SQLite
(legacy bootstrap-ETL остался как fallback в свёрнутом блоке UI).

Schema (data/sql/68_v2 — applied на проде, 6 таблиц):
- objective_lots          (UPSERT по lot_id; 303 677 rows)
- objective_corpus_room_month (long-формат месяц×корпус×room_bucket; 19 738 rows)
- objective_lots_history  (append-only weekly snapshots для elasticity)
- objective_complex_mapping (Objective ComplexName ↔ domrf_kn_objects.obj_id)
- objective_raw_reports   (jsonb страховка на смену схемы API)
- objective_scrape_runs   (журнал прогонов)
+ data/sql/72_objective_sync_config (single-row динамический конфиг)

Backend:
- services/scrapers/objective.py: ObjectiveClient — Bearer-токен (Redis +
  in-memory fallback), retry на 401/429/5xx, Retry-After header support
- services/objective_etl.py: ETL SQLite Антона → PG (legacy)
- services/objective_sync_config.py: read/update single-row config
- workers/tasks/scrape_objective.py:
  * sync_objective_group: 2 рабочих отчёта (corp_sum, lots_pf), inline-парсинг
  * sync_all_groups: wrapper, перебирает группы из БД-конфига с
    inter-group паузой; PATCH-merge explicit args > DB config
- workers/tasks/objective_etl.py: Celery task для legacy bootstrap
- workers/celery_app.py: beat читает cron из БД при старте (fallback на env)
- api/v1/admin_scrape.py: 5 новых endpoints для /objective/*

Frontend (frontend/src/app/admin/scrape/objective/page.tsx):
- PRIMARY blue блок «🌐 Наш sync» с input для override групп
- Collapsible «⚙️ Настройки» с формой (cron + 8 параметров) → PUT в БД
- Coverage-панель с PG counts + строка про SQLite Антона как legacy
- Collapsible «🛠 Bootstrap ETL» — legacy-инструмент

Beat schedule: вторник 06:00 МСК, ~10-15 мин на 4 группы (Свердл.обл +
Челябинск + Тюмень + Пермь = ~700K квартир УрФО). Расписание и параметры
меняются через админку без редеплоя (cron требует restart beat).

Эмпирические находки об API (probe 2026-05-10):
- 13 из 21 проверенных group_name доступны на тарифе (включая «Свердловская
  область», «Челябинск», «Тюмень», «Пермь», но НЕ «Свердловская обл» —
  формат имени строгий)
- ComplexName требует БЕЗ префикса «ЖК» и БЕЗ кавычек
- Поле «Банк» для всех 303k = NULL (тариф не отдаёт), но «Тип обременения»
  работает (36% строк = ипотека) → ipoteka_share возможен, банковская
  атрибуция — нет

Docker:
- bind-mount /opt/gendesign/site-finder:/data/anton-sqlite:ro в worker

GitHub backlog: добавлены #22-25 (recommend_mix v3 — 4 сабтаска по
улучшению алгоритма на основе фидбэка про POI / границы районов /
конкурентов / окно данных / success-driven mix).

Knowledge graph (memory/memory-gendesign.jsonl): обновлены entities
Objective_Integration_May07_2026, Schema_Objective_v2_May07,
Objective_API_Findings_May07, Module_Objective_Client + новый
Session_End_May07_2026.

TODO для прода: прописать OBJECTIVE_API_KEY=<key> в backend/.env +
docker compose restart worker beat.
2026-05-10 19:54:15 +03:00

169 lines
6.1 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.

"""LEGACY/BOOTSTRAP Celery task: ETL Антоновского SQLite → наша PG.
⚠️ В обычной работе НЕ нужен — наш `tasks.scrape_objective.sync_all_groups`
тянет данные с api.objctv.ru напрямую в PostgreSQL (см. celery_app.py beat).
Этот ETL остаётся как ОДНОРАЗОВЫЙ инструмент для:
- bootstrap PG из Антоновского SQLite (например при первом запуске,
до того как наш beat отработал)
- аварийного восстановления если наш sync долго не работал, а Антоновский
/sf/ продолжал тянуть свою SQLite
Триггерится только вручную через POST /api/v1/admin/scrape/objective (UI).
Beat-расписания НЕТ.
Файл-источник: settings.objective_anton_sqlite_path
(на проде смонтирован bind-mount-ом docker-compose из /opt/gendesign/site-finder).
Логирует прогресс в objective_scrape_runs с group_name='anton-etl'.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.db import SessionLocal
from app.services.objective_etl import get_sqlite_info, run_etl
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
def _start_run(db: Session, triggered_by: str) -> int:
row = db.execute(
text(
"""
INSERT INTO objective_scrape_runs (group_name, triggered_by, status)
VALUES (:gn, :tb, 'running')
RETURNING run_id
"""
),
{"gn": "anton-etl", "tb": triggered_by},
).scalar_one()
db.commit()
return int(row)
def _heartbeat(db: Session, run_id: int, **counts: int) -> None:
sets = ["heartbeat_at = NOW()"]
params: dict[str, Any] = {"rid": run_id}
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
try:
db.execute(
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
params,
)
db.commit()
except Exception as e:
logger.warning("heartbeat failed for run=%s: %s", run_id, e)
try:
db.rollback()
except Exception:
pass
def _finish_run(
db: Session, run_id: int, *, status: str, error: str | None = None, **counts: int
) -> None:
sets = ["finished_at = NOW()", "status = :status", "error = :error"]
params: dict[str, Any] = {"rid": run_id, "status": status, "error": error}
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
db.execute(
text(f"UPDATE objective_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
params,
)
db.commit()
@celery_app.task(
bind=True,
name="tasks.objective_etl.import_anton_objective",
max_retries=0, # ETL идемпотентный, но при сбое лучше человек посмотрит
)
def import_anton_objective(
self: Any,
triggered_by: str = "manual",
sqlite_path: str | None = None,
) -> dict[str, Any]:
"""Полный ETL из Антоновского SQLite в нашу PG.
Args:
triggered_by: 'manual' | 'beat' | 'api' — для журнала
sqlite_path: переопределить settings.objective_anton_sqlite_path
Возвращает counts по таблицам + run_id.
"""
sqlite_path_eff = sqlite_path or settings.objective_anton_sqlite_path
db = SessionLocal()
run_id: int | None = None
try:
run_id = _start_run(db, triggered_by)
logger.info("import_anton_objective started, run_id=%s, sqlite=%s", run_id, sqlite_path_eff)
# Преобразуем sqlalchemy-style db_url в psycopg2-style
# (psycopg2 не принимает 'postgresql+psycopg://').
db_url = settings.database_url.replace("postgresql+psycopg://", "postgresql://")
# Прогресс-callback пишет в БД счётчики + heartbeat
# Stages: mapping_start/done, crm_start/progress/done, lots_start/progress/done
def _cb(stage: str, n: int) -> None:
kw: dict[str, int] = {}
if stage.startswith("mapping"):
kw["rows_history"] = n # переиспользуем поле; mapping count
elif stage.startswith("crm"):
kw["rows_corpus_room"] = n
elif stage.startswith("lots"):
kw["rows_lots"] = n
_heartbeat(db, run_id, **kw)
result = run_etl(
sqlite_path_eff,
db_url,
batch_size=2000,
progress_cb=_cb,
)
_finish_run(
db,
run_id,
status="done",
error=None,
rows_lots=result["lots"],
rows_corpus_room=result["corp_room_month"],
rows_history=result["mappings"], # переиспользуем поле под mappings
reports_ok=3,
reports_failed=0,
)
logger.info("import_anton_objective done, run_id=%s, result=%s", run_id, result)
return {"run_id": run_id, "triggered_by": triggered_by, **result}
except FileNotFoundError as e:
logger.error("SQLite not found: %s", e)
if run_id:
try:
_finish_run(db, run_id, status="failed", error=f"SQLite не найден: {e}")
except Exception:
pass
# Дополним результат полезной диагностикой
info = get_sqlite_info(sqlite_path_eff)
return {"run_id": run_id, "error": "sqlite_not_found", "sqlite_info": info}
except Exception as e:
logger.exception("import_anton_objective failed: %s", e)
if run_id:
try:
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
except Exception:
pass
raise
finally:
db.close()