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.
This commit is contained in:
parent
12732949b9
commit
7c05d0a0d8
13 changed files with 2663 additions and 15 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -38,6 +38,10 @@ data/raw/prinzip_crm.sqlite
|
||||||
data/raw/domrf_photos/
|
data/raw/domrf_photos/
|
||||||
# Objective API smoke dumps (ходят в backend/data/raw когда скрипт запускают из backend/)
|
# Objective API smoke dumps (ходят в backend/data/raw когда скрипт запускают из backend/)
|
||||||
backend/data/raw/
|
backend/data/raw/
|
||||||
|
|
||||||
|
# Antonov SQLite snapshot для ETL (340 MB)
|
||||||
|
sf_anton_snapshot.db
|
||||||
|
sf_anton_snapshot.db-*
|
||||||
*.csv
|
*.csv
|
||||||
*.csv.zip
|
*.csv.zip
|
||||||
!backend/db/init/*.sql
|
!backend/db/init/*.sql
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,241 @@ def nspd_coverage(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Objective ETL (Антоновский SQLite → PG) ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerObjectiveEtlRequest(BaseModel):
|
||||||
|
sqlite_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Override settings.objective_anton_sqlite_path (abs file path)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/objective")
|
||||||
|
def trigger_objective_etl(
|
||||||
|
payload: TriggerObjectiveEtlRequest,
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Запустить ETL Антоновского SQLite → наша PG.
|
||||||
|
|
||||||
|
SQLite-файл должен быть доступен внутри Celery-worker контейнера:
|
||||||
|
- на проде: смонтирован bind-mount-ом из /opt/gendesign/site-finder/
|
||||||
|
- локально: путь передать через payload.sqlite_path
|
||||||
|
"""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
from app.workers.tasks.objective_etl import import_anton_objective
|
||||||
|
|
||||||
|
result = import_anton_objective.apply_async(
|
||||||
|
kwargs={
|
||||||
|
"triggered_by": "manual",
|
||||||
|
"sqlite_path": payload.sqlite_path,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"task_id": result.id,
|
||||||
|
"sqlite_path": payload.sqlite_path or settings.objective_anton_sqlite_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/objective/runs")
|
||||||
|
def list_objective_runs(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT run_id, started_at, finished_at, heartbeat_at,
|
||||||
|
group_name, reports_ok, reports_failed,
|
||||||
|
rows_corpus_room, rows_lots, rows_history,
|
||||||
|
requests_count, status, error, triggered_by
|
||||||
|
FROM objective_scrape_runs
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT :lim
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"lim": min(limit, 100)},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"run_id": r["run_id"],
|
||||||
|
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
|
||||||
|
"finished_at": r["finished_at"].isoformat() if r["finished_at"] else None,
|
||||||
|
"heartbeat_at": r["heartbeat_at"].isoformat() if r["heartbeat_at"] else None,
|
||||||
|
"group_name": r["group_name"],
|
||||||
|
"reports_ok": r["reports_ok"],
|
||||||
|
"reports_failed": r["reports_failed"],
|
||||||
|
"rows_corpus_room": r["rows_corpus_room"],
|
||||||
|
"rows_lots": r["rows_lots"],
|
||||||
|
# Поле в Schema 68 v2 называется rows_history — мы переиспользуем его
|
||||||
|
# под количество загруженных mapping'ов из jk_objective_match.
|
||||||
|
"rows_mappings": r["rows_history"],
|
||||||
|
"requests_count": r["requests_count"],
|
||||||
|
"status": r["status"],
|
||||||
|
"error": r["error"],
|
||||||
|
"triggered_by": r["triggered_by"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerObjectiveSyncRequest(BaseModel):
|
||||||
|
"""Ручной триггер нашего sync_all_groups (платный API Объектива)."""
|
||||||
|
|
||||||
|
groups: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Override settings.objective_sync_groups (csv). "
|
||||||
|
"Если None — берёт дефолт из settings.",
|
||||||
|
max_length=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/objective/sync-our")
|
||||||
|
def trigger_objective_sync_our(
|
||||||
|
payload: TriggerObjectiveSyncRequest,
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Запустить НАШ sync_all_groups (тратит limits Объектива).
|
||||||
|
|
||||||
|
В отличие от ETL (POST /objective) — это вызовы api.objctv.ru напрямую,
|
||||||
|
с парсингом payload через app.workers.tasks.scrape_objective.
|
||||||
|
Полезно когда нужно расширить покрытие за пределы Антоновского ЕКБ
|
||||||
|
(Свердл.обл целиком + Челябинск + Тюмень + Пермь).
|
||||||
|
"""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
if not settings.objective_api_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="OBJECTIVE_API_KEY не задан (settings.objective_api_key пуст)",
|
||||||
|
)
|
||||||
|
from app.workers.tasks.scrape_objective import sync_all_groups
|
||||||
|
|
||||||
|
groups_eff = payload.groups or [
|
||||||
|
g.strip() for g in settings.objective_sync_groups.split(",") if g.strip()
|
||||||
|
]
|
||||||
|
result = sync_all_groups.apply_async(
|
||||||
|
kwargs={
|
||||||
|
"groups": payload.groups, # None → task возьмёт дефолт
|
||||||
|
"triggered_by": "manual",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"task_id": result.id,
|
||||||
|
"groups_planned": groups_eff,
|
||||||
|
"estimate_minutes": len(groups_eff)
|
||||||
|
* 3, # ~3 мин на группу (corp_sum + lots_pf + 30s pause)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectiveConfigUpdateRequest(BaseModel):
|
||||||
|
"""PATCH-style — обновляет только переданные поля."""
|
||||||
|
|
||||||
|
cron_schedule: str | None = None
|
||||||
|
groups_csv: str | None = None
|
||||||
|
use_ddu: bool | None = None
|
||||||
|
use_dkp: bool | None = None
|
||||||
|
period_months_back: int | None = Field(default=None, ge=1, le=24)
|
||||||
|
inter_group_delay_s: int | None = Field(default=None, ge=0, le=300)
|
||||||
|
rate_ms: int | None = Field(default=None, ge=0, le=10000)
|
||||||
|
retries: int | None = Field(default=None, ge=0, le=5)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/objective/config")
|
||||||
|
def get_objective_config(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Текущая динамическая конфигурация Objective sync (single row)."""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
from app.services.objective_sync_config import get_config
|
||||||
|
|
||||||
|
return get_config(db).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/objective/config")
|
||||||
|
def update_objective_config(
|
||||||
|
payload: ObjectiveConfigUpdateRequest,
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""PATCH-style update. После изменения cron_schedule нужен restart beat,
|
||||||
|
остальные поля применяются на следующем sync-вызове автоматически."""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
from app.services.objective_sync_config import update_config
|
||||||
|
|
||||||
|
cfg = update_config(
|
||||||
|
db,
|
||||||
|
cron_schedule=payload.cron_schedule,
|
||||||
|
groups_csv=payload.groups_csv,
|
||||||
|
use_ddu=payload.use_ddu,
|
||||||
|
use_dkp=payload.use_dkp,
|
||||||
|
period_months_back=payload.period_months_back,
|
||||||
|
inter_group_delay_s=payload.inter_group_delay_s,
|
||||||
|
rate_ms=payload.rate_ms,
|
||||||
|
retries=payload.retries,
|
||||||
|
updated_by="admin-ui",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
**cfg.to_dict(),
|
||||||
|
"_note": (
|
||||||
|
"cron_schedule applied только после restart beat-контейнера; "
|
||||||
|
"остальные параметры подхватятся при следующем запуске sync_all_groups"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/objective/coverage")
|
||||||
|
def objective_coverage(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Что у нас в БД сейчас + что лежит в SQLite Антона (size, last modified).
|
||||||
|
|
||||||
|
Помогает понять стоит ли запускать ETL: если SQLite старше последнего
|
||||||
|
успешного run'а — данные не изменятся.
|
||||||
|
"""
|
||||||
|
_check_token(x_admin_token)
|
||||||
|
from app.services.objective_etl import get_sqlite_info
|
||||||
|
|
||||||
|
pg_row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM objective_lots) AS lots,
|
||||||
|
(SELECT COUNT(*) FROM objective_corpus_room_month) AS crm,
|
||||||
|
(SELECT COUNT(*) FROM objective_complex_mapping) AS mappings,
|
||||||
|
(SELECT MAX(snapshot_date) FROM objective_lots) AS last_lot_snapshot,
|
||||||
|
(SELECT MAX(finished_at) FROM objective_scrape_runs
|
||||||
|
WHERE status = 'done'
|
||||||
|
AND triggered_by IN ('manual','beat','api')) AS last_run_at
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
sqlite_info = get_sqlite_info(settings.objective_anton_sqlite_path)
|
||||||
|
return {
|
||||||
|
"pg": {
|
||||||
|
"lots": int(pg_row["lots"] or 0),
|
||||||
|
"corp_room_month": int(pg_row["crm"] or 0),
|
||||||
|
"mappings": int(pg_row["mappings"] or 0),
|
||||||
|
"last_lot_snapshot": pg_row["last_lot_snapshot"].isoformat()
|
||||||
|
if pg_row["last_lot_snapshot"]
|
||||||
|
else None,
|
||||||
|
"last_run_at": pg_row["last_run_at"].isoformat() if pg_row["last_run_at"] else None,
|
||||||
|
},
|
||||||
|
"sqlite": sqlite_info,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── DomRF (kn) — runs list ───────────────────────────────────────────────────
|
# ── DomRF (kn) — runs list ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,35 @@ class Settings(BaseSettings):
|
||||||
# Группа = «настройка территории» внутри Объектива. Чаще всего совпадает с
|
# Группа = «настройка территории» внутри Объектива. Чаще всего совпадает с
|
||||||
# городом/областью. Доступные группы определяются тарифом ключа.
|
# городом/областью. Доступные группы определяются тарифом ключа.
|
||||||
objective_default_group: str = "Екатеринбург"
|
objective_default_group: str = "Екатеринбург"
|
||||||
|
# Список групп для еженедельного auto-sync (csv). Эмпирически проверено
|
||||||
|
# 2026-05-10 что эти группы доступны на нашем тарифе:
|
||||||
|
# - "Свердловская область" → 1626 corp_sum/3д (= ЕКБ + спутники + регион)
|
||||||
|
# - "Челябинск" → 355
|
||||||
|
# - "Тюмень" → 985
|
||||||
|
# - "Пермь" → 423
|
||||||
|
# Стратегия Variant B (включить наш sync параллельно Антоновому ETL):
|
||||||
|
# Антон тянет только Екб (303k lots). Наш sync даёт +30k Свердл.обл
|
||||||
|
# за пределами Екб + ~700k квартир УрФО (Челябинск, Тюмень, Пермь).
|
||||||
|
# Итог в PG: ~1М квартир уральского + западносибирского рынка.
|
||||||
|
# Между группами task делает паузу _OBJECTIVE_INTER_GROUP_DELAY = 30с
|
||||||
|
# чтобы не упереться в rate-limit Объектива.
|
||||||
|
objective_sync_groups: str = "Свердловская область,Челябинск,Тюмень,Пермь"
|
||||||
# Токен Bearer короткоживущий (предположительно 30 мин — уточнить).
|
# Токен Bearer короткоживущий (предположительно 30 мин — уточнить).
|
||||||
# Кешируем в Redis с этим TTL (с запасом 5 мин).
|
# Кешируем в Redis с этим TTL (с запасом 5 мин).
|
||||||
objective_token_ttl_seconds: int = 25 * 60
|
objective_token_ttl_seconds: int = 25 * 60
|
||||||
# Расписание sync — еженедельно по понедельникам в 05:00 МСК (после kn-sweep).
|
# Расписание sync — еженедельно по вторникам в 06:00 МСК.
|
||||||
objective_sync_cron: str = "0 5 * * mon"
|
# ПОЧЕМУ ВТОРНИК (а не понедельник как раньше): Антон тянет в понедельник,
|
||||||
|
# к утру вторника его SQLite уже обновлён — наш sync захватывает свежий
|
||||||
|
# snapshot за тот же отчётный период.
|
||||||
|
objective_sync_cron: str = "0 6 * * tue"
|
||||||
|
|
||||||
|
# ETL Антоновского /sf/api/* SQLite → нашу PG (objective_* таблицы).
|
||||||
|
# На проде монтируется bind-mount-ом docker-compose:
|
||||||
|
# /opt/gendesign/site-finder/analysis.db -> /data/anton-sqlite/analysis.db
|
||||||
|
# Триггерится вручную через POST /api/v1/admin/scrape/objective.
|
||||||
|
# Для локальной разработки — указать абсолютный путь к скачанному snapshot:
|
||||||
|
# OBJECTIVE_ANTON_SQLITE_PATH=C:/Users/user/source/repos/gendesign/sf_anton_snapshot.db
|
||||||
|
objective_anton_sqlite_path: str = "/data/anton-sqlite/analysis.db"
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
537
backend/app/services/objective_etl.py
Normal file
537
backend/app/services/objective_etl.py
Normal file
|
|
@ -0,0 +1,537 @@
|
||||||
|
"""ETL: SQLite Антона (/sf/) → наша PG (objective_* таблицы).
|
||||||
|
|
||||||
|
Reusable библиотека вокруг логики `data/sql/71_etl_anton_sqlite_to_pg.py`.
|
||||||
|
Используется Celery-task `tasks.objective_etl.import_anton_objective` и
|
||||||
|
admin-endpoint `POST /api/v1/admin/scrape/objective`.
|
||||||
|
|
||||||
|
Файл-источник по умолчанию: settings.objective_anton_sqlite_path
|
||||||
|
(на проде смонтирован bind-mount-ом docker-compose из /opt/gendesign/site-finder).
|
||||||
|
|
||||||
|
Стратегия:
|
||||||
|
- lots: INSERT … ON CONFLICT (objective_lot_id) DO UPDATE
|
||||||
|
- crm: INSERT … ON CONFLICT (report_month, group_name, project_name,
|
||||||
|
corpus_name, room_bucket) DO UPDATE
|
||||||
|
- mapping: дедупликация по (objective_complex_name, objective_group),
|
||||||
|
потом INSERT … ON CONFLICT DO UPDATE
|
||||||
|
|
||||||
|
Heartbeat / прогресс (для UI) выставляется через callback `progress_cb`,
|
||||||
|
который Celery-task передаёт со UPDATE objective_scrape_runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GROUP_NAME = "Екатеринбург" # У Антона в SQLite нет колонки group_name
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s) -> date | None:
|
||||||
|
if s is None or s == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(s)[:10])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_month_to_first_day(s) -> date | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
y, m = str(s).split("-")
|
||||||
|
return date(int(y), int(m), 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_rooms(s) -> int | None:
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
sl = str(s).strip().lower()
|
||||||
|
if "студ" in sl:
|
||||||
|
return 0
|
||||||
|
m = re.match(r"^(\d+)", sl)
|
||||||
|
if m:
|
||||||
|
return min(int(m.group(1)), 5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_from_yes(v) -> bool | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
s = str(v).strip().lower()
|
||||||
|
if s in ("да", "true", "1", "yes"):
|
||||||
|
return True
|
||||||
|
if s in ("нет", "false", "0", "no"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _site_id_to_obj_id(s) -> int | None:
|
||||||
|
"""'jk:49854' → 49854. 'parcel:...' → None."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
s = str(s)
|
||||||
|
if s.startswith("jk:"):
|
||||||
|
try:
|
||||||
|
return int(s[3:])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(v) -> int | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_num(v) -> float | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── row mappings ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_lot(r: sqlite3.Row, snapshot_date: date) -> tuple:
|
||||||
|
return (
|
||||||
|
r["lot_id"],
|
||||||
|
r["project_id"],
|
||||||
|
r["project"],
|
||||||
|
r["developer"],
|
||||||
|
r["city"],
|
||||||
|
r["district"],
|
||||||
|
r["corpus"],
|
||||||
|
r["address"],
|
||||||
|
r["obj_class"],
|
||||||
|
r["section"],
|
||||||
|
_to_int(r["floor"]),
|
||||||
|
r["lot_num"],
|
||||||
|
r["room_kind"],
|
||||||
|
_parse_date(r["sales_start"]),
|
||||||
|
_parse_date(r["plan_date"]),
|
||||||
|
_parse_date(r["fact_date"]),
|
||||||
|
_to_int(r["readiness_pct"]),
|
||||||
|
r["construction_stage"],
|
||||||
|
r["finish_type"],
|
||||||
|
r["status"],
|
||||||
|
_bool_from_yes(r["sold"]),
|
||||||
|
r["rooms_dev"],
|
||||||
|
r["rooms_pd"],
|
||||||
|
r["rooms_obj"],
|
||||||
|
_normalize_rooms(r["rooms_obj"]),
|
||||||
|
_to_num(r["area_dev"]),
|
||||||
|
_to_num(r["area_pd"]),
|
||||||
|
_to_num(r["budget_rub"]),
|
||||||
|
_to_num(r["price_per_m2"]),
|
||||||
|
_to_num(r["offer_price"]),
|
||||||
|
_to_num(r["delta_price_rub"]),
|
||||||
|
_to_num(r["delta_price_pct"]),
|
||||||
|
r["price_method"],
|
||||||
|
_parse_date(r["price_set_date"]),
|
||||||
|
_parse_date(r["price_actual_date"]),
|
||||||
|
_parse_date(r["contract_date"]),
|
||||||
|
_parse_date(r["register_date"]),
|
||||||
|
r["deal_type"],
|
||||||
|
r["buyer_type"],
|
||||||
|
r["register_num"],
|
||||||
|
r["encumbrance"],
|
||||||
|
r["bank"],
|
||||||
|
_parse_date(r["encumbrance_start"]),
|
||||||
|
_parse_date(r["egrn_actual_date"]),
|
||||||
|
snapshot_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LOT_INSERT = """
|
||||||
|
INSERT INTO objective_lots (
|
||||||
|
objective_lot_id, objective_project_id, project_name, developer, city,
|
||||||
|
district, corpus_name, address, class, section, floor, lot_number,
|
||||||
|
premise_kind, sales_start_date, plan_completion_date, actual_completion_date,
|
||||||
|
readiness_pct, construction_stage, finishing, status, is_sold,
|
||||||
|
rooms_dev_site, rooms_pd, rooms_objective, rooms_int,
|
||||||
|
area_dev_site, area_pd,
|
||||||
|
price_calculated_total_rub, price_per_m2_rub, price_offer_total_rub,
|
||||||
|
price_delta_rub, price_delta_pct, pricing_method, price_set_date,
|
||||||
|
price_actual_date, contract_date, registration_date, contract_type,
|
||||||
|
buyer_type, registration_number, encumbrance_type, bank_name,
|
||||||
|
encumbrance_start_date, egrn_actual_date, snapshot_date
|
||||||
|
) VALUES %s
|
||||||
|
ON CONFLICT (objective_lot_id) DO UPDATE SET
|
||||||
|
project_name = EXCLUDED.project_name,
|
||||||
|
developer = EXCLUDED.developer, district = EXCLUDED.district,
|
||||||
|
corpus_name = EXCLUDED.corpus_name, address = EXCLUDED.address,
|
||||||
|
class = EXCLUDED.class, section = EXCLUDED.section, floor = EXCLUDED.floor,
|
||||||
|
lot_number = EXCLUDED.lot_number, premise_kind = EXCLUDED.premise_kind,
|
||||||
|
sales_start_date = EXCLUDED.sales_start_date,
|
||||||
|
plan_completion_date = EXCLUDED.plan_completion_date,
|
||||||
|
actual_completion_date = EXCLUDED.actual_completion_date,
|
||||||
|
readiness_pct = EXCLUDED.readiness_pct,
|
||||||
|
construction_stage = EXCLUDED.construction_stage,
|
||||||
|
finishing = EXCLUDED.finishing,
|
||||||
|
status = EXCLUDED.status, is_sold = EXCLUDED.is_sold,
|
||||||
|
rooms_dev_site = EXCLUDED.rooms_dev_site,
|
||||||
|
rooms_pd = EXCLUDED.rooms_pd,
|
||||||
|
rooms_objective = EXCLUDED.rooms_objective,
|
||||||
|
rooms_int = EXCLUDED.rooms_int,
|
||||||
|
area_dev_site = EXCLUDED.area_dev_site, area_pd = EXCLUDED.area_pd,
|
||||||
|
price_calculated_total_rub = EXCLUDED.price_calculated_total_rub,
|
||||||
|
price_per_m2_rub = EXCLUDED.price_per_m2_rub,
|
||||||
|
price_offer_total_rub = EXCLUDED.price_offer_total_rub,
|
||||||
|
price_delta_rub = EXCLUDED.price_delta_rub,
|
||||||
|
price_delta_pct = EXCLUDED.price_delta_pct,
|
||||||
|
pricing_method = EXCLUDED.pricing_method,
|
||||||
|
price_set_date = EXCLUDED.price_set_date,
|
||||||
|
price_actual_date = EXCLUDED.price_actual_date,
|
||||||
|
contract_date = EXCLUDED.contract_date,
|
||||||
|
registration_date = EXCLUDED.registration_date,
|
||||||
|
contract_type = EXCLUDED.contract_type, buyer_type = EXCLUDED.buyer_type,
|
||||||
|
registration_number = EXCLUDED.registration_number,
|
||||||
|
encumbrance_type = EXCLUDED.encumbrance_type,
|
||||||
|
bank_name = EXCLUDED.bank_name,
|
||||||
|
encumbrance_start_date = EXCLUDED.encumbrance_start_date,
|
||||||
|
egrn_actual_date = EXCLUDED.egrn_actual_date,
|
||||||
|
snapshot_date = EXCLUDED.snapshot_date,
|
||||||
|
updated_at = NOW()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_crm(r: sqlite3.Row) -> tuple:
|
||||||
|
return (
|
||||||
|
_parse_month_to_first_day(r["month"]),
|
||||||
|
GROUP_NAME,
|
||||||
|
r["project"],
|
||||||
|
r["developer"],
|
||||||
|
r["district"],
|
||||||
|
r["obj_class"],
|
||||||
|
r["corpus"],
|
||||||
|
r["rooms_bucket"],
|
||||||
|
_normalize_rooms(r["rooms_bucket"]),
|
||||||
|
_parse_date(r["sales_start"]),
|
||||||
|
_parse_date(r["plan_date"]),
|
||||||
|
_parse_date(r["fact_date"]),
|
||||||
|
_to_int(r["months_in_sales"]),
|
||||||
|
_to_int(r["lots_pd"]),
|
||||||
|
_to_num(r["area_pd"]),
|
||||||
|
# ДДУ — у Антона нет
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
# ДКП
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
# Всего
|
||||||
|
_to_int(r["deals_total"]),
|
||||||
|
_to_int(r["deals_priced"]),
|
||||||
|
_to_num(r["sold_volume_m2"]),
|
||||||
|
_to_num(r["sold_volume_m2"]),
|
||||||
|
None,
|
||||||
|
_to_num(r["avg_price_m2"]),
|
||||||
|
_to_num(r["avg_area_m2"]),
|
||||||
|
# Offer
|
||||||
|
_to_int(r["stock_lots"]),
|
||||||
|
_to_num(r["stock_m2"]),
|
||||||
|
None,
|
||||||
|
_to_num(r["stock_avg_price_m2"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CRM_INSERT = """
|
||||||
|
INSERT INTO objective_corpus_room_month (
|
||||||
|
report_month, group_name, project_name, developer, district, class,
|
||||||
|
corpus_name, room_bucket, rooms_int,
|
||||||
|
sales_start_date, plan_completion_date, actual_completion_date,
|
||||||
|
months_in_realization, lots_pd_count, lots_pd_area,
|
||||||
|
deals_ddu_count, deals_ddu_count_priced, deals_ddu_vol_m2,
|
||||||
|
deals_ddu_vol_m2_priced, deals_ddu_sum_mln_rub,
|
||||||
|
deals_ddu_avg_price_thousand_rub_per_m2, deals_ddu_avg_area_m2,
|
||||||
|
deals_dkp_count, deals_dkp_count_priced, deals_dkp_vol_m2,
|
||||||
|
deals_dkp_vol_m2_priced, deals_dkp_sum_mln_rub,
|
||||||
|
deals_dkp_avg_price_thousand_rub_per_m2, deals_dkp_avg_area_m2,
|
||||||
|
deals_total_count, deals_total_count_priced, deals_total_vol_m2,
|
||||||
|
deals_total_vol_m2_priced, deals_total_sum_mln_rub,
|
||||||
|
deals_total_avg_price_thousand_rub_per_m2, deals_total_avg_area_m2,
|
||||||
|
offer_count, offer_area_m2, offer_sum_mln_rub,
|
||||||
|
offer_avg_price_thousand_rub_per_m2
|
||||||
|
) VALUES %s
|
||||||
|
ON CONFLICT (report_month, group_name, project_name, corpus_name, room_bucket)
|
||||||
|
DO UPDATE SET
|
||||||
|
developer = EXCLUDED.developer, district = EXCLUDED.district,
|
||||||
|
class = EXCLUDED.class, rooms_int = EXCLUDED.rooms_int,
|
||||||
|
sales_start_date = EXCLUDED.sales_start_date,
|
||||||
|
plan_completion_date = EXCLUDED.plan_completion_date,
|
||||||
|
actual_completion_date = EXCLUDED.actual_completion_date,
|
||||||
|
months_in_realization = EXCLUDED.months_in_realization,
|
||||||
|
lots_pd_count = EXCLUDED.lots_pd_count,
|
||||||
|
lots_pd_area = EXCLUDED.lots_pd_area,
|
||||||
|
deals_total_count = EXCLUDED.deals_total_count,
|
||||||
|
deals_total_count_priced = EXCLUDED.deals_total_count_priced,
|
||||||
|
deals_total_vol_m2 = EXCLUDED.deals_total_vol_m2,
|
||||||
|
deals_total_vol_m2_priced = EXCLUDED.deals_total_vol_m2_priced,
|
||||||
|
deals_total_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_total_avg_price_thousand_rub_per_m2,
|
||||||
|
deals_total_avg_area_m2 = EXCLUDED.deals_total_avg_area_m2,
|
||||||
|
offer_count = EXCLUDED.offer_count,
|
||||||
|
offer_area_m2 = EXCLUDED.offer_area_m2,
|
||||||
|
offer_avg_price_thousand_rub_per_m2 = EXCLUDED.offer_avg_price_thousand_rub_per_m2,
|
||||||
|
fetched_at = NOW()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_mapping(r: sqlite3.Row) -> tuple | None:
|
||||||
|
obj_id = _site_id_to_obj_id(r["site_id"])
|
||||||
|
if obj_id is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
r["project"],
|
||||||
|
None,
|
||||||
|
GROUP_NAME,
|
||||||
|
obj_id,
|
||||||
|
False,
|
||||||
|
r["method"],
|
||||||
|
_to_num(r["score"]),
|
||||||
|
"imported_from_anton_sf",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_MAP_INSERT = """
|
||||||
|
INSERT INTO objective_complex_mapping (
|
||||||
|
objective_complex_name, objective_project_id, objective_group,
|
||||||
|
domrf_obj_id, is_reviewed, match_method, match_score, note
|
||||||
|
) VALUES %s
|
||||||
|
ON CONFLICT (objective_complex_name, objective_group) DO UPDATE SET
|
||||||
|
domrf_obj_id = EXCLUDED.domrf_obj_id,
|
||||||
|
match_method = EXCLUDED.match_method,
|
||||||
|
match_score = EXCLUDED.match_score,
|
||||||
|
note = EXCLUDED.note,
|
||||||
|
updated_at = NOW()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── orchestration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _etl_lots(
|
||||||
|
sl: sqlite3.Connection, pg, batch_size: int, progress_cb: Callable[[int], None] | None
|
||||||
|
) -> int:
|
||||||
|
snap = date.today()
|
||||||
|
cur = sl.execute("SELECT * FROM objective_lots")
|
||||||
|
n = 0
|
||||||
|
batch: list[tuple] = []
|
||||||
|
pg_cur = pg.cursor()
|
||||||
|
while True:
|
||||||
|
rows = cur.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for r in rows:
|
||||||
|
batch.append(_row_to_lot(r, snap))
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=batch_size)
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
batch.clear()
|
||||||
|
if progress_cb and n % (batch_size * 5) == 0:
|
||||||
|
progress_cb(n)
|
||||||
|
if batch:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=len(batch))
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
pg_cur.close()
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(n)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def _etl_crm(
|
||||||
|
sl: sqlite3.Connection, pg, batch_size: int, progress_cb: Callable[[int], None] | None
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
cur = sl.execute("SELECT * FROM objective_corp_month")
|
||||||
|
n = 0
|
||||||
|
skipped = 0
|
||||||
|
batch: list[tuple] = []
|
||||||
|
pg_cur = pg.cursor()
|
||||||
|
while True:
|
||||||
|
rows = cur.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for r in rows:
|
||||||
|
t = _row_to_crm(r)
|
||||||
|
if not t[0] or not t[2] or not t[6] or not t[7]:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
batch.append(t)
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=batch_size)
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
batch.clear()
|
||||||
|
if batch:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=len(batch))
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
pg_cur.close()
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(n)
|
||||||
|
return n, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def _etl_mapping(sl: sqlite3.Connection, pg) -> tuple[int, int]:
|
||||||
|
cur = sl.execute("SELECT * FROM jk_objective_match ORDER BY score DESC NULLS LAST")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
seen: set[tuple] = set()
|
||||||
|
batch: list[tuple] = []
|
||||||
|
skipped_dups = 0
|
||||||
|
for r in rows:
|
||||||
|
t = _row_to_mapping(r)
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
key = (t[0], t[2]) # (complex_name, group)
|
||||||
|
if key in seen:
|
||||||
|
skipped_dups += 1
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
batch.append(t)
|
||||||
|
if not batch:
|
||||||
|
return 0, skipped_dups
|
||||||
|
pg_cur = pg.cursor()
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _MAP_INSERT, batch, page_size=500)
|
||||||
|
pg.commit()
|
||||||
|
pg_cur.close()
|
||||||
|
return len(batch), skipped_dups
|
||||||
|
|
||||||
|
|
||||||
|
# ── public API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_sqlite_info(sqlite_path: str | Path) -> dict[str, Any]:
|
||||||
|
"""Метаданные SQLite-файла без полного запуска ETL.
|
||||||
|
|
||||||
|
Используется в admin-coverage endpoint чтобы UI знал свежесть данных,
|
||||||
|
размер файла и сколько потенциально rows будет залито.
|
||||||
|
"""
|
||||||
|
p = Path(sqlite_path)
|
||||||
|
info: dict[str, Any] = {
|
||||||
|
"path": str(p),
|
||||||
|
"exists": p.exists(),
|
||||||
|
}
|
||||||
|
if not p.exists():
|
||||||
|
return info
|
||||||
|
st = p.stat()
|
||||||
|
info["size_bytes"] = st.st_size
|
||||||
|
info["modified_at"] = st.st_mtime # epoch seconds
|
||||||
|
try:
|
||||||
|
c = sqlite3.connect(p)
|
||||||
|
info["lots"] = c.execute("SELECT COUNT(*) FROM objective_lots").fetchone()[0]
|
||||||
|
info["corp_room_month"] = c.execute("SELECT COUNT(*) FROM objective_corp_month").fetchone()[
|
||||||
|
0
|
||||||
|
]
|
||||||
|
info["mappings"] = c.execute("SELECT COUNT(*) FROM jk_objective_match").fetchone()[0]
|
||||||
|
c.close()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
info["error"] = str(e)
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def run_etl(
|
||||||
|
sqlite_path: str | Path,
|
||||||
|
db_url: str,
|
||||||
|
*,
|
||||||
|
batch_size: int = 2000,
|
||||||
|
progress_cb: Callable[[str, int], None] | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Полный ETL: SQLite → PG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sqlite_path: путь к analysis.db (на проде — bind-mount /data/anton-sqlite/...)
|
||||||
|
db_url: postgresql://… connection string
|
||||||
|
batch_size: размер batch для execute_values (default 2000)
|
||||||
|
progress_cb: optional callback(stage: str, n_rows: int) для UI прогресса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict с counts: {'lots', 'corp_room_month', 'mappings', 'crm_skipped',
|
||||||
|
'mapping_dups_skipped'}
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError если SQLite не найден
|
||||||
|
psycopg2.Error при ошибках БД
|
||||||
|
"""
|
||||||
|
p = Path(sqlite_path)
|
||||||
|
if not p.exists():
|
||||||
|
raise FileNotFoundError(f"SQLite не найден: {p}")
|
||||||
|
|
||||||
|
logger.info("ETL Anton SQLite → PG: %s", p)
|
||||||
|
sl = sqlite3.connect(p)
|
||||||
|
sl.row_factory = sqlite3.Row
|
||||||
|
pg = psycopg2.connect(db_url)
|
||||||
|
try:
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("mapping_start", 0)
|
||||||
|
n_map, dups = _etl_mapping(sl, pg)
|
||||||
|
logger.info(" mapping: %d upserted, %d dups skipped", n_map, dups)
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("mapping_done", n_map)
|
||||||
|
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("crm_start", 0)
|
||||||
|
n_crm, crm_skipped = _etl_crm(
|
||||||
|
sl,
|
||||||
|
pg,
|
||||||
|
batch_size,
|
||||||
|
lambda n: progress_cb("crm_progress", n) if progress_cb else None,
|
||||||
|
)
|
||||||
|
logger.info(" crm: %d upserted, %d skipped", n_crm, crm_skipped)
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("crm_done", n_crm)
|
||||||
|
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("lots_start", 0)
|
||||||
|
n_lots = _etl_lots(
|
||||||
|
sl,
|
||||||
|
pg,
|
||||||
|
batch_size,
|
||||||
|
lambda n: progress_cb("lots_progress", n) if progress_cb else None,
|
||||||
|
)
|
||||||
|
logger.info(" lots: %d upserted", n_lots)
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb("lots_done", n_lots)
|
||||||
|
finally:
|
||||||
|
sl.close()
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"lots": n_lots,
|
||||||
|
"corp_room_month": n_crm,
|
||||||
|
"mappings": n_map,
|
||||||
|
"crm_skipped": crm_skipped,
|
||||||
|
"mapping_dups_skipped": dups,
|
||||||
|
}
|
||||||
178
backend/app/services/objective_sync_config.py
Normal file
178
backend/app/services/objective_sync_config.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""Read/update Objective sync config (single-row БД-конфигурация).
|
||||||
|
|
||||||
|
Используется:
|
||||||
|
- tasks.scrape_objective.sync_all_groups: читает groups/use_ddu/use_dkp/
|
||||||
|
period_months_back/inter_group_delay_s/rate_ms/retries при каждом запуске
|
||||||
|
- workers.celery_app: читает cron_schedule при старте beat (один раз)
|
||||||
|
- api.v1.admin_scrape: GET/PUT /admin/scrape/objective/config
|
||||||
|
|
||||||
|
Если таблица objective_sync_config недоступна (БД не поднята / таблицы нет /
|
||||||
|
ошибка) — все методы возвращают defaults на основе settings.objective_*.
|
||||||
|
Это обеспечивает безопасный fallback для cold-start и unit-тестов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObjectiveSyncConfig:
|
||||||
|
cron_schedule: str
|
||||||
|
groups_csv: str
|
||||||
|
use_ddu: bool
|
||||||
|
use_dkp: bool
|
||||||
|
period_months_back: int
|
||||||
|
inter_group_delay_s: int
|
||||||
|
rate_ms: int
|
||||||
|
retries: int
|
||||||
|
updated_at: str | None = None
|
||||||
|
updated_by: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def groups(self) -> list[str]:
|
||||||
|
return [g.strip() for g in self.groups_csv.split(",") if g.strip()]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"cron_schedule": self.cron_schedule,
|
||||||
|
"groups_csv": self.groups_csv,
|
||||||
|
"groups": self.groups,
|
||||||
|
"use_ddu": self.use_ddu,
|
||||||
|
"use_dkp": self.use_dkp,
|
||||||
|
"period_months_back": self.period_months_back,
|
||||||
|
"inter_group_delay_s": self.inter_group_delay_s,
|
||||||
|
"rate_ms": self.rate_ms,
|
||||||
|
"retries": self.retries,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"updated_by": self.updated_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults_from_settings() -> ObjectiveSyncConfig:
|
||||||
|
"""Fallback когда БД-таблицы нет / ошибка."""
|
||||||
|
return ObjectiveSyncConfig(
|
||||||
|
cron_schedule=settings.objective_sync_cron,
|
||||||
|
groups_csv=settings.objective_sync_groups,
|
||||||
|
use_ddu=True,
|
||||||
|
use_dkp=True,
|
||||||
|
period_months_back=1,
|
||||||
|
inter_group_delay_s=30,
|
||||||
|
rate_ms=3000,
|
||||||
|
retries=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_config(db) -> ObjectiveSyncConfig:
|
||||||
|
"""Прочитать config (single row id=1). Fallback на settings при ошибке."""
|
||||||
|
try:
|
||||||
|
r = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT cron_schedule, groups_csv, use_ddu, use_dkp,
|
||||||
|
period_months_back, inter_group_delay_s, rate_ms, retries,
|
||||||
|
updated_at, updated_by
|
||||||
|
FROM objective_sync_config WHERE id = 1
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("get_config: fallback на settings (БД недоступна?): %s", e)
|
||||||
|
return _defaults_from_settings()
|
||||||
|
|
||||||
|
if not r:
|
||||||
|
# Таблица есть но пустая — посеем default и вернём
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
text("INSERT INTO objective_sync_config (id) VALUES (1) ON CONFLICT DO NOTHING")
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _defaults_from_settings()
|
||||||
|
|
||||||
|
return ObjectiveSyncConfig(
|
||||||
|
cron_schedule=r["cron_schedule"],
|
||||||
|
groups_csv=r["groups_csv"],
|
||||||
|
use_ddu=bool(r["use_ddu"]),
|
||||||
|
use_dkp=bool(r["use_dkp"]),
|
||||||
|
period_months_back=int(r["period_months_back"]),
|
||||||
|
inter_group_delay_s=int(r["inter_group_delay_s"]),
|
||||||
|
rate_ms=int(r["rate_ms"]),
|
||||||
|
retries=int(r["retries"]),
|
||||||
|
updated_at=r["updated_at"].isoformat() if r["updated_at"] else None,
|
||||||
|
updated_by=r["updated_by"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_config(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
cron_schedule: str | None = None,
|
||||||
|
groups_csv: str | None = None,
|
||||||
|
use_ddu: bool | None = None,
|
||||||
|
use_dkp: bool | None = None,
|
||||||
|
period_months_back: int | None = None,
|
||||||
|
inter_group_delay_s: int | None = None,
|
||||||
|
rate_ms: int | None = None,
|
||||||
|
retries: int | None = None,
|
||||||
|
updated_by: str | None = None,
|
||||||
|
) -> ObjectiveSyncConfig:
|
||||||
|
"""PATCH-style update — обновляет только переданные поля."""
|
||||||
|
sets: list[str] = ["updated_at = NOW()"]
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
for field, value in [
|
||||||
|
("cron_schedule", cron_schedule),
|
||||||
|
("groups_csv", groups_csv),
|
||||||
|
("use_ddu", use_ddu),
|
||||||
|
("use_dkp", use_dkp),
|
||||||
|
("period_months_back", period_months_back),
|
||||||
|
("inter_group_delay_s", inter_group_delay_s),
|
||||||
|
("rate_ms", rate_ms),
|
||||||
|
("retries", retries),
|
||||||
|
("updated_by", updated_by),
|
||||||
|
]:
|
||||||
|
if value is not None:
|
||||||
|
sets.append(f"{field} = :{field}")
|
||||||
|
params[field] = value
|
||||||
|
if len(sets) == 1:
|
||||||
|
# Только updated_at — нечего обновлять
|
||||||
|
return get_config(db)
|
||||||
|
|
||||||
|
# Гарантия что строка существует
|
||||||
|
db.execute(text("INSERT INTO objective_sync_config (id) VALUES (1) ON CONFLICT DO NOTHING"))
|
||||||
|
db.execute(
|
||||||
|
text(f"UPDATE objective_sync_config SET {', '.join(sets)} WHERE id = 1"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return get_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cron_schedule_safe(db_factory) -> str:
|
||||||
|
"""Безопасное чтение cron при старте Celery beat — открывает свою сессию.
|
||||||
|
|
||||||
|
db_factory: callable () -> Session (обычно SessionLocal)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db = db_factory()
|
||||||
|
try:
|
||||||
|
cfg = get_config(db)
|
||||||
|
return cfg.cron_schedule
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("get_cron_schedule_safe: fallback на settings: %s", e)
|
||||||
|
return settings.objective_sync_cron
|
||||||
|
|
@ -43,6 +43,7 @@ celery_app = Celery(
|
||||||
"app.workers.tasks.scrape_nspd",
|
"app.workers.tasks.scrape_nspd",
|
||||||
"app.workers.tasks.refresh_analytics",
|
"app.workers.tasks.refresh_analytics",
|
||||||
"app.workers.tasks.scrape_objective",
|
"app.workers.tasks.scrape_objective",
|
||||||
|
"app.workers.tasks.objective_etl",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
celery_app.conf.timezone = "Europe/Moscow"
|
celery_app.conf.timezone = "Europe/Moscow"
|
||||||
|
|
@ -86,13 +87,35 @@ celery_app.conf.beat_schedule["refresh-ekb-districts-medians"] = {
|
||||||
"kwargs": {"window_months": 24, "min_deals": 50},
|
"kwargs": {"window_months": 24, "min_deals": 50},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Objective sync — еженедельно (объёмы умеренные, ключ платный = бережём limits).
|
|
||||||
# По умолчанию: пн 05:00 МСК. Если OBJECTIVE_API_KEY пуст — task сразу skipped.
|
# Objective sync — наш ЕДИНЫЙ source of truth для Objective-данных.
|
||||||
|
# Дёргает api.objctv.ru напрямую и пишет в PostgreSQL (objective_lots /
|
||||||
|
# corpus_room_month / lots_history) минуя любые промежуточные SQLite.
|
||||||
|
#
|
||||||
|
# Динамическая конфигурация в БД (objective_sync_config single-row):
|
||||||
|
# - cron_schedule (читается ОДИН РАЗ при старте beat — после изменения
|
||||||
|
# требует `docker compose restart beat`)
|
||||||
|
# - groups_csv, use_ddu, use_dkp, period_months_back, inter_group_delay_s,
|
||||||
|
# rate_ms, retries — читаются task'ом ПРИ КАЖДОМ запуске, динамически.
|
||||||
|
#
|
||||||
|
# Если БД недоступна на старте — fallback на settings.objective_sync_cron.
|
||||||
|
def _objective_cron() -> "crontab":
|
||||||
|
"""Прочитать cron из БД с fallback на settings."""
|
||||||
|
try:
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.services.objective_sync_config import get_cron_schedule_safe
|
||||||
|
|
||||||
|
cron_str = get_cron_schedule_safe(SessionLocal)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("objective beat cron: fallback на settings (%s)", e)
|
||||||
|
cron_str = settings.objective_sync_cron
|
||||||
|
return _parse_cron(cron_str)
|
||||||
|
|
||||||
|
|
||||||
celery_app.conf.beat_schedule["objective-sync"] = {
|
celery_app.conf.beat_schedule["objective-sync"] = {
|
||||||
"task": "tasks.scrape_objective.sync_objective_group",
|
"task": "tasks.scrape_objective.sync_all_groups",
|
||||||
"schedule": _parse_cron(settings.objective_sync_cron),
|
"schedule": _objective_cron(),
|
||||||
"kwargs": {
|
"kwargs": {
|
||||||
"group_name": settings.objective_default_group,
|
|
||||||
"triggered_by": "beat",
|
"triggered_by": "beat",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
169
backend/app/workers/tasks/objective_etl.py
Normal file
169
backend/app/workers/tasks/objective_etl.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
"""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()
|
||||||
|
|
@ -143,26 +143,43 @@ def sync_objective_group(
|
||||||
self: Any,
|
self: Any,
|
||||||
group_name: str | None = None,
|
group_name: str | None = None,
|
||||||
triggered_by: str = "beat",
|
triggered_by: str = "beat",
|
||||||
|
use_ddu: bool = True,
|
||||||
|
use_dkp: bool = True,
|
||||||
|
period_months_back: int = 1,
|
||||||
|
rate_ms: int = 3000,
|
||||||
|
retries: int = 2,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Sync 4 канонических отчёта Объектив для одной группы (территории).
|
"""Sync канонические отчёты Объектив для одной группы (территории).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
group_name: имя группы (например 'Свердловская область'). None → settings default
|
||||||
|
triggered_by: 'beat'/'manual'/'api'/'beat-multi' (журнал)
|
||||||
|
use_ddu/use_dkp: какие сделки тянуть (передаётся в API)
|
||||||
|
period_months_back: для Сводных/Корпуса — сколько месяцев назад
|
||||||
|
захватывать в start_date (1=последний полный месяц)
|
||||||
|
rate_ms: пауза между HTTP-запросами в client (мс)
|
||||||
|
retries: ретраи на 429/5xx
|
||||||
|
|
||||||
Идемпотентность: каждый прогон создаёт новые objective_raw_reports
|
Идемпотентность: каждый прогон создаёт новые objective_raw_reports
|
||||||
(history-style). Нормализация (parse → objective_oks/lots) — отдельная
|
(history-style). Парсинг сразу через late-import парсера 70.
|
||||||
задача после изучения реальной схемы payload через smoke-script.
|
|
||||||
"""
|
"""
|
||||||
if not settings.objective_api_key:
|
if not settings.objective_api_key:
|
||||||
logger.warning("sync_objective_group skipped: OBJECTIVE_API_KEY не задан")
|
logger.warning("sync_objective_group skipped: OBJECTIVE_API_KEY не задан")
|
||||||
return {"skipped": True, "reason": "no_api_key"}
|
return {"skipped": True, "reason": "no_api_key"}
|
||||||
|
|
||||||
group = group_name or settings.objective_default_group
|
group = group_name or settings.objective_default_group
|
||||||
|
# period_months_back=1 → последний полный месяц [start_date..end_date)
|
||||||
|
# period_months_back=N → N последних полных месяцев
|
||||||
end_date = date.today().replace(day=1)
|
end_date = date.today().replace(day=1)
|
||||||
start_date = (end_date - timedelta(days=1)).replace(day=1)
|
start_date = end_date
|
||||||
|
for _ in range(max(period_months_back, 1)):
|
||||||
|
start_date = (start_date - timedelta(days=1)).replace(day=1)
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
run_id: int | None = None
|
run_id: int | None = None
|
||||||
try:
|
try:
|
||||||
run_id = _start_run(db, group, triggered_by)
|
run_id = _start_run(db, group, triggered_by)
|
||||||
client = ObjectiveClient()
|
client = ObjectiveClient(rate_ms=rate_ms, retries=retries)
|
||||||
|
|
||||||
reports_ok = 0
|
reports_ok = 0
|
||||||
reports_failed = 0
|
reports_failed = 0
|
||||||
|
|
@ -196,8 +213,8 @@ def sync_objective_group(
|
||||||
"group_name": group,
|
"group_name": group,
|
||||||
"start_date": start_date,
|
"start_date": start_date,
|
||||||
"end_date": end_date,
|
"end_date": end_date,
|
||||||
"use_ddu": True,
|
"use_ddu": use_ddu,
|
||||||
"use_dkp": True,
|
"use_dkp": use_dkp,
|
||||||
},
|
},
|
||||||
"Объединенные данные",
|
"Объединенные данные",
|
||||||
"Сводные",
|
"Сводные",
|
||||||
|
|
@ -206,7 +223,7 @@ def sync_objective_group(
|
||||||
(
|
(
|
||||||
"lots_pf",
|
"lots_pf",
|
||||||
"report_lots_per_flat",
|
"report_lots_per_flat",
|
||||||
{"group_name": group, "use_ddu": True, "use_dkp": True},
|
{"group_name": group, "use_ddu": use_ddu, "use_dkp": use_dkp},
|
||||||
"Объединенные данные",
|
"Объединенные данные",
|
||||||
"Поквартирные",
|
"Поквартирные",
|
||||||
"Лоты",
|
"Лоты",
|
||||||
|
|
@ -326,3 +343,141 @@ def sync_objective_group(
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Multi-group wrapper для еженедельного beat ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(
|
||||||
|
bind=True,
|
||||||
|
name="tasks.scrape_objective.sync_all_groups",
|
||||||
|
max_retries=0,
|
||||||
|
)
|
||||||
|
def sync_all_groups(
|
||||||
|
self: Any,
|
||||||
|
groups: list[str] | None = None,
|
||||||
|
triggered_by: str = "beat",
|
||||||
|
# Все остальные параметры — None = взять из БД-конфига objective_sync_config
|
||||||
|
use_ddu: bool | None = None,
|
||||||
|
use_dkp: bool | None = None,
|
||||||
|
period_months_back: int | None = None,
|
||||||
|
inter_group_delay_s: int | None = None,
|
||||||
|
rate_ms: int | None = None,
|
||||||
|
retries: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Перебирает группы из БД-конфига (или explicit override) и тянет каждую.
|
||||||
|
|
||||||
|
Все None-параметры подменяются из таблицы objective_sync_config (PATCH-style).
|
||||||
|
Это позволяет менять настройки sync через админку без редеплоя.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
groups: explicit override (если None — берётся из БД config.groups_csv)
|
||||||
|
triggered_by: 'beat' | 'manual' | 'api'
|
||||||
|
use_ddu/use_dkp/period_months_back/inter_group_delay_s/rate_ms/retries:
|
||||||
|
None → читается из БД config
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict {'groups': [...], 'total_lots': N, 'total_corpus_room': N,
|
||||||
|
'total_failed': N, 'config_used': {...}}
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
from app.services.objective_sync_config import get_config
|
||||||
|
|
||||||
|
if not settings.objective_api_key:
|
||||||
|
logger.warning("sync_all_groups skipped: OBJECTIVE_API_KEY не задан")
|
||||||
|
return {"skipped": True, "reason": "no_api_key"}
|
||||||
|
|
||||||
|
# Загружаем динамический config из БД (с fallback на settings)
|
||||||
|
cfg_db = SessionLocal()
|
||||||
|
try:
|
||||||
|
cfg = get_config(cfg_db)
|
||||||
|
finally:
|
||||||
|
cfg_db.close()
|
||||||
|
|
||||||
|
# PATCH-merge: explicit args > БД config
|
||||||
|
eff_groups = groups if groups is not None else cfg.groups
|
||||||
|
eff_use_ddu = use_ddu if use_ddu is not None else cfg.use_ddu
|
||||||
|
eff_use_dkp = use_dkp if use_dkp is not None else cfg.use_dkp
|
||||||
|
eff_period = period_months_back if period_months_back is not None else cfg.period_months_back
|
||||||
|
eff_delay = inter_group_delay_s if inter_group_delay_s is not None else cfg.inter_group_delay_s
|
||||||
|
eff_rate_ms = rate_ms if rate_ms is not None else cfg.rate_ms
|
||||||
|
eff_retries = retries if retries is not None else cfg.retries
|
||||||
|
|
||||||
|
if not eff_groups:
|
||||||
|
return {"skipped": True, "reason": "no_groups_configured"}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sync_all_groups starting for %d groups: %s "
|
||||||
|
"(use_ddu=%s use_dkp=%s period_months=%s inter_group=%ss rate=%sms retries=%s)",
|
||||||
|
len(eff_groups),
|
||||||
|
eff_groups,
|
||||||
|
eff_use_ddu,
|
||||||
|
eff_use_dkp,
|
||||||
|
eff_period,
|
||||||
|
eff_delay,
|
||||||
|
eff_rate_ms,
|
||||||
|
eff_retries,
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
total_lots = 0
|
||||||
|
total_crm = 0
|
||||||
|
total_failed = 0
|
||||||
|
|
||||||
|
for idx, group in enumerate(eff_groups):
|
||||||
|
logger.info("[%d/%d] sync_objective_group(group=%r) START", idx + 1, len(eff_groups), group)
|
||||||
|
try:
|
||||||
|
res = sync_objective_group(
|
||||||
|
self,
|
||||||
|
group_name=group,
|
||||||
|
triggered_by=f"{triggered_by}-multi",
|
||||||
|
use_ddu=eff_use_ddu,
|
||||||
|
use_dkp=eff_use_dkp,
|
||||||
|
period_months_back=eff_period,
|
||||||
|
rate_ms=eff_rate_ms,
|
||||||
|
retries=eff_retries,
|
||||||
|
)
|
||||||
|
if res.get("skipped"):
|
||||||
|
results.append({"group": group, "status": "skipped", "reason": res.get("reason")})
|
||||||
|
else:
|
||||||
|
total_lots += int(res.get("rows_lots") or 0)
|
||||||
|
total_crm += int(res.get("rows_corpus_room") or 0)
|
||||||
|
if int(res.get("reports_failed") or 0) > 0:
|
||||||
|
total_failed += 1
|
||||||
|
results.append({"group": group, "status": "done", **res})
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
"[%d/%d] sync for group=%r failed: %s", idx + 1, len(eff_groups), group, e
|
||||||
|
)
|
||||||
|
total_failed += 1
|
||||||
|
results.append(
|
||||||
|
{"group": group, "status": "failed", "error": f"{type(e).__name__}: {e}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
if idx < len(eff_groups) - 1:
|
||||||
|
logger.info("inter-group sleep %ds", eff_delay)
|
||||||
|
_time.sleep(eff_delay)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"sync_all_groups DONE: total_lots=%d total_crm=%d failed=%d",
|
||||||
|
total_lots,
|
||||||
|
total_crm,
|
||||||
|
total_failed,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"groups": results,
|
||||||
|
"total_lots": total_lots,
|
||||||
|
"total_corpus_room": total_crm,
|
||||||
|
"total_failed": total_failed,
|
||||||
|
"n_groups": len(eff_groups),
|
||||||
|
"config_used": {
|
||||||
|
"groups": eff_groups,
|
||||||
|
"use_ddu": eff_use_ddu,
|
||||||
|
"use_dkp": eff_use_dkp,
|
||||||
|
"period_months_back": eff_period,
|
||||||
|
"inter_group_delay_s": eff_delay,
|
||||||
|
"rate_ms": eff_rate_ms,
|
||||||
|
"retries": eff_retries,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
|
||||||
517
data/sql/71_etl_anton_sqlite_to_pg.py
Normal file
517
data/sql/71_etl_anton_sqlite_to_pg.py
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
"""ETL: SQLite Антона (sf_anton_snapshot.db) → наша PG.
|
||||||
|
|
||||||
|
Грузит 3 таблицы из его SQLite в нашу schema 68 v2:
|
||||||
|
- objective_lots (303 677 строк)
|
||||||
|
- objective_corp_month ( 19 738 строк)
|
||||||
|
- jk_objective_match ( 229 строк)
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
cd backend
|
||||||
|
DATABASE_URL=postgresql://gendesign:PWD@localhost:15432/gendesign \\
|
||||||
|
uv run python -X utf8 ../data/sql/71_etl_anton_sqlite_to_pg.py [--dry-run] [--limit N]
|
||||||
|
|
||||||
|
Идемпотентно через ON CONFLICT DO UPDATE по UNIQUE-ключам.
|
||||||
|
Для повторной заливки: данные в таблицах перезатрутся (snapshot_date обновится).
|
||||||
|
|
||||||
|
Источник: Антоновский /sf/api/* SQLite (~340 MB), скачанный в repo-root через scp:
|
||||||
|
scp gendesign:/opt/gendesign/site-finder/analysis.db sf_anton_snapshot.db
|
||||||
|
|
||||||
|
Mapping полей задокументирован в _ROW_TO_LOT / _ROW_TO_CRM / _ROW_TO_MAPPING ниже.
|
||||||
|
|
||||||
|
После прогона ETL рекомендуется DISABLE наш Celery objective-sync beat task
|
||||||
|
(Антон уже еженедельно тянет — не дублируем платный API).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
logger = logging.getLogger("etl_anton")
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
SQLITE_DEFAULT = REPO_ROOT / "sf_anton_snapshot.db"
|
||||||
|
|
||||||
|
GROUP_NAME = "Екатеринбург" # У Антона нет колонки group_name — у нас всё под Екб
|
||||||
|
|
||||||
|
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: str | None) -> date | None:
|
||||||
|
"""SQLite хранит как ISO-строку или None."""
|
||||||
|
if s is None or s == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(s[:10])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_month_to_first_day(s: str | None) -> date | None:
|
||||||
|
"""'2025-08' → date(2025, 8, 1)."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
y, m = s.split("-")
|
||||||
|
return date(int(y), int(m), 1)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_rooms(s: str | None) -> int | None:
|
||||||
|
"""'студия'→0, '1'→1, …, '4+'→4 (как в наш парсер 70)."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
sl = s.strip().lower()
|
||||||
|
if "студ" in sl:
|
||||||
|
return 0
|
||||||
|
m = re.match(r"^(\d+)", sl)
|
||||||
|
if m:
|
||||||
|
return min(int(m.group(1)), 5)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bool_from_yes(v) -> bool | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
s = str(v).strip().lower()
|
||||||
|
if s in ("да", "true", "1", "yes"):
|
||||||
|
return True
|
||||||
|
if s in ("нет", "false", "0", "no"):
|
||||||
|
return False
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _site_id_to_obj_id(s: str | None) -> int | None:
|
||||||
|
"""'jk:49854' → 49854. 'parcel:...' → None."""
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.startswith("jk:"):
|
||||||
|
try:
|
||||||
|
return int(s[3:])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(v) -> int | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_num(v) -> float | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── row mappings ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_lot(r: sqlite3.Row, snapshot_date: date) -> tuple:
|
||||||
|
"""SQLite objective_lots row → tuple для INSERT в наш objective_lots."""
|
||||||
|
return (
|
||||||
|
r["lot_id"], # objective_lot_id
|
||||||
|
r["project_id"], # objective_project_id
|
||||||
|
r["project"], # project_name (NOT NULL)
|
||||||
|
r["developer"],
|
||||||
|
r["city"],
|
||||||
|
r["district"],
|
||||||
|
r["corpus"], # corpus_name
|
||||||
|
r["address"],
|
||||||
|
r["obj_class"], # class
|
||||||
|
r["section"],
|
||||||
|
_to_int(r["floor"]),
|
||||||
|
r["lot_num"], # lot_number
|
||||||
|
r["room_kind"], # premise_kind
|
||||||
|
_parse_date(r["sales_start"]),
|
||||||
|
_parse_date(r["plan_date"]),
|
||||||
|
_parse_date(r["fact_date"]),
|
||||||
|
_to_int(r["readiness_pct"]),
|
||||||
|
r["construction_stage"],
|
||||||
|
r["finish_type"], # finishing
|
||||||
|
r["status"],
|
||||||
|
_bool_from_yes(r["sold"]), # is_sold
|
||||||
|
r["rooms_dev"], # rooms_dev_site
|
||||||
|
r["rooms_pd"],
|
||||||
|
r["rooms_obj"], # rooms_objective
|
||||||
|
_normalize_rooms(r["rooms_obj"]), # rooms_int (0..5)
|
||||||
|
_to_num(r["area_dev"]), # area_dev_site
|
||||||
|
_to_num(r["area_pd"]),
|
||||||
|
_to_num(r["budget_rub"]), # price_calculated_total_rub
|
||||||
|
_to_num(r["price_per_m2"]), # price_per_m2_rub (Р/м², не тыс)
|
||||||
|
_to_num(r["offer_price"]), # price_offer_total_rub
|
||||||
|
_to_num(r["delta_price_rub"]),
|
||||||
|
_to_num(r["delta_price_pct"]),
|
||||||
|
r["price_method"], # pricing_method
|
||||||
|
_parse_date(r["price_set_date"]),
|
||||||
|
_parse_date(r["price_actual_date"]),
|
||||||
|
_parse_date(r["contract_date"]),
|
||||||
|
_parse_date(r["register_date"]), # registration_date
|
||||||
|
r["deal_type"], # contract_type
|
||||||
|
r["buyer_type"],
|
||||||
|
r["register_num"], # registration_number
|
||||||
|
r["encumbrance"], # encumbrance_type
|
||||||
|
r["bank"], # bank_name
|
||||||
|
_parse_date(r["encumbrance_start"]), # encumbrance_start_date
|
||||||
|
_parse_date(r["egrn_actual_date"]),
|
||||||
|
snapshot_date, # snapshot_date — день ETL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LOT_INSERT = """
|
||||||
|
INSERT INTO objective_lots (
|
||||||
|
objective_lot_id, objective_project_id, project_name, developer, city,
|
||||||
|
district, corpus_name, address, class, section, floor, lot_number,
|
||||||
|
premise_kind, sales_start_date, plan_completion_date, actual_completion_date,
|
||||||
|
readiness_pct, construction_stage, finishing, status, is_sold,
|
||||||
|
rooms_dev_site, rooms_pd, rooms_objective, rooms_int,
|
||||||
|
area_dev_site, area_pd,
|
||||||
|
price_calculated_total_rub, price_per_m2_rub, price_offer_total_rub,
|
||||||
|
price_delta_rub, price_delta_pct, pricing_method, price_set_date,
|
||||||
|
price_actual_date, contract_date, registration_date, contract_type,
|
||||||
|
buyer_type, registration_number, encumbrance_type, bank_name,
|
||||||
|
encumbrance_start_date, egrn_actual_date, snapshot_date
|
||||||
|
) VALUES %s
|
||||||
|
ON CONFLICT (objective_lot_id) DO UPDATE SET
|
||||||
|
project_name = EXCLUDED.project_name,
|
||||||
|
developer = EXCLUDED.developer, district = EXCLUDED.district,
|
||||||
|
corpus_name = EXCLUDED.corpus_name, address = EXCLUDED.address,
|
||||||
|
class = EXCLUDED.class, section = EXCLUDED.section, floor = EXCLUDED.floor,
|
||||||
|
lot_number = EXCLUDED.lot_number, premise_kind = EXCLUDED.premise_kind,
|
||||||
|
sales_start_date = EXCLUDED.sales_start_date,
|
||||||
|
plan_completion_date = EXCLUDED.plan_completion_date,
|
||||||
|
actual_completion_date = EXCLUDED.actual_completion_date,
|
||||||
|
readiness_pct = EXCLUDED.readiness_pct,
|
||||||
|
construction_stage = EXCLUDED.construction_stage,
|
||||||
|
finishing = EXCLUDED.finishing,
|
||||||
|
status = EXCLUDED.status, is_sold = EXCLUDED.is_sold,
|
||||||
|
rooms_dev_site = EXCLUDED.rooms_dev_site,
|
||||||
|
rooms_pd = EXCLUDED.rooms_pd,
|
||||||
|
rooms_objective = EXCLUDED.rooms_objective,
|
||||||
|
rooms_int = EXCLUDED.rooms_int,
|
||||||
|
area_dev_site = EXCLUDED.area_dev_site, area_pd = EXCLUDED.area_pd,
|
||||||
|
price_calculated_total_rub = EXCLUDED.price_calculated_total_rub,
|
||||||
|
price_per_m2_rub = EXCLUDED.price_per_m2_rub,
|
||||||
|
price_offer_total_rub = EXCLUDED.price_offer_total_rub,
|
||||||
|
price_delta_rub = EXCLUDED.price_delta_rub,
|
||||||
|
price_delta_pct = EXCLUDED.price_delta_pct,
|
||||||
|
pricing_method = EXCLUDED.pricing_method,
|
||||||
|
price_set_date = EXCLUDED.price_set_date,
|
||||||
|
price_actual_date = EXCLUDED.price_actual_date,
|
||||||
|
contract_date = EXCLUDED.contract_date,
|
||||||
|
registration_date = EXCLUDED.registration_date,
|
||||||
|
contract_type = EXCLUDED.contract_type, buyer_type = EXCLUDED.buyer_type,
|
||||||
|
registration_number = EXCLUDED.registration_number,
|
||||||
|
encumbrance_type = EXCLUDED.encumbrance_type,
|
||||||
|
bank_name = EXCLUDED.bank_name,
|
||||||
|
encumbrance_start_date = EXCLUDED.encumbrance_start_date,
|
||||||
|
egrn_actual_date = EXCLUDED.egrn_actual_date,
|
||||||
|
snapshot_date = EXCLUDED.snapshot_date,
|
||||||
|
updated_at = NOW()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_crm(r: sqlite3.Row) -> tuple:
|
||||||
|
"""objective_corp_month → objective_corpus_room_month.
|
||||||
|
|
||||||
|
Антон НЕ разделяет ДДУ vs ДКП vs всего — у нас только totals из его данных.
|
||||||
|
NULL'и в ddu/dkp колонках — Антон их не давал.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
_parse_month_to_first_day(r["month"]), # report_month
|
||||||
|
GROUP_NAME, # group_name
|
||||||
|
r["project"], # project_name
|
||||||
|
r["developer"],
|
||||||
|
r["district"],
|
||||||
|
r["obj_class"], # class
|
||||||
|
r["corpus"], # corpus_name
|
||||||
|
r["rooms_bucket"], # room_bucket
|
||||||
|
_normalize_rooms(r["rooms_bucket"]), # rooms_int
|
||||||
|
_parse_date(r["sales_start"]),
|
||||||
|
_parse_date(r["plan_date"]),
|
||||||
|
_parse_date(r["fact_date"]),
|
||||||
|
_to_int(r["months_in_sales"]), # months_in_realization
|
||||||
|
_to_int(r["lots_pd"]), # lots_pd_count
|
||||||
|
_to_num(r["area_pd"]), # lots_pd_area
|
||||||
|
# ДДУ — у Антона нет, NULL'им
|
||||||
|
None, None, None, None, None, None, None,
|
||||||
|
# ДКП — то же
|
||||||
|
None, None, None, None, None, None, None,
|
||||||
|
# Всего (Антон даёт только это)
|
||||||
|
_to_int(r["deals_total"]), # deals_total_count
|
||||||
|
_to_int(r["deals_priced"]), # deals_total_count_priced
|
||||||
|
_to_num(r["sold_volume_m2"]), # deals_total_vol_m2
|
||||||
|
_to_num(r["sold_volume_m2"]), # deals_total_vol_m2_priced (= тот же)
|
||||||
|
None, # deals_total_sum_mln_rub — нет
|
||||||
|
_to_num(r["avg_price_m2"]), # deals_total_avg_price_thousand_rub_per_m2 (тыс.Р/м²)
|
||||||
|
_to_num(r["avg_area_m2"]), # deals_total_avg_area_m2
|
||||||
|
# Offer
|
||||||
|
_to_int(r["stock_lots"]), # offer_count
|
||||||
|
_to_num(r["stock_m2"]), # offer_area_m2
|
||||||
|
None, # offer_sum_mln_rub — нет
|
||||||
|
_to_num(r["stock_avg_price_m2"]), # offer_avg_price_thousand_rub_per_m2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CRM_INSERT = """
|
||||||
|
INSERT INTO objective_corpus_room_month (
|
||||||
|
report_month, group_name, project_name, developer, district, class,
|
||||||
|
corpus_name, room_bucket, rooms_int,
|
||||||
|
sales_start_date, plan_completion_date, actual_completion_date,
|
||||||
|
months_in_realization, lots_pd_count, lots_pd_area,
|
||||||
|
deals_ddu_count, deals_ddu_count_priced, deals_ddu_vol_m2,
|
||||||
|
deals_ddu_vol_m2_priced, deals_ddu_sum_mln_rub,
|
||||||
|
deals_ddu_avg_price_thousand_rub_per_m2, deals_ddu_avg_area_m2,
|
||||||
|
deals_dkp_count, deals_dkp_count_priced, deals_dkp_vol_m2,
|
||||||
|
deals_dkp_vol_m2_priced, deals_dkp_sum_mln_rub,
|
||||||
|
deals_dkp_avg_price_thousand_rub_per_m2, deals_dkp_avg_area_m2,
|
||||||
|
deals_total_count, deals_total_count_priced, deals_total_vol_m2,
|
||||||
|
deals_total_vol_m2_priced, deals_total_sum_mln_rub,
|
||||||
|
deals_total_avg_price_thousand_rub_per_m2, deals_total_avg_area_m2,
|
||||||
|
offer_count, offer_area_m2, offer_sum_mln_rub,
|
||||||
|
offer_avg_price_thousand_rub_per_m2
|
||||||
|
) VALUES %s
|
||||||
|
ON CONFLICT (report_month, group_name, project_name, corpus_name, room_bucket)
|
||||||
|
DO UPDATE SET
|
||||||
|
developer = EXCLUDED.developer, district = EXCLUDED.district,
|
||||||
|
class = EXCLUDED.class, rooms_int = EXCLUDED.rooms_int,
|
||||||
|
sales_start_date = EXCLUDED.sales_start_date,
|
||||||
|
plan_completion_date = EXCLUDED.plan_completion_date,
|
||||||
|
actual_completion_date = EXCLUDED.actual_completion_date,
|
||||||
|
months_in_realization = EXCLUDED.months_in_realization,
|
||||||
|
lots_pd_count = EXCLUDED.lots_pd_count,
|
||||||
|
lots_pd_area = EXCLUDED.lots_pd_area,
|
||||||
|
deals_total_count = EXCLUDED.deals_total_count,
|
||||||
|
deals_total_count_priced = EXCLUDED.deals_total_count_priced,
|
||||||
|
deals_total_vol_m2 = EXCLUDED.deals_total_vol_m2,
|
||||||
|
deals_total_vol_m2_priced = EXCLUDED.deals_total_vol_m2_priced,
|
||||||
|
deals_total_avg_price_thousand_rub_per_m2 = EXCLUDED.deals_total_avg_price_thousand_rub_per_m2,
|
||||||
|
deals_total_avg_area_m2 = EXCLUDED.deals_total_avg_area_m2,
|
||||||
|
offer_count = EXCLUDED.offer_count,
|
||||||
|
offer_area_m2 = EXCLUDED.offer_area_m2,
|
||||||
|
offer_avg_price_thousand_rub_per_m2 = EXCLUDED.offer_avg_price_thousand_rub_per_m2,
|
||||||
|
fetched_at = NOW()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_mapping(r: sqlite3.Row) -> tuple | None:
|
||||||
|
"""jk_objective_match → objective_complex_mapping.
|
||||||
|
Возвращает None если site_id не jk: (parcel пропускаем — у нас нет parcel-таблицы).
|
||||||
|
"""
|
||||||
|
obj_id = _site_id_to_obj_id(r["site_id"])
|
||||||
|
if obj_id is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
r["project"], # objective_complex_name
|
||||||
|
None, # objective_project_id (Антон не хранит)
|
||||||
|
GROUP_NAME,
|
||||||
|
obj_id, # domrf_obj_id
|
||||||
|
False, # is_reviewed
|
||||||
|
r["method"], # match_method ('fuzzy', etc.)
|
||||||
|
_to_num(r["score"]), # match_score
|
||||||
|
"imported_from_anton_sf", # note
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_MAP_INSERT = """
|
||||||
|
INSERT INTO objective_complex_mapping (
|
||||||
|
objective_complex_name, objective_project_id, objective_group,
|
||||||
|
domrf_obj_id, is_reviewed, match_method, match_score, note
|
||||||
|
) VALUES %s
|
||||||
|
ON CONFLICT (objective_complex_name, objective_group) DO UPDATE SET
|
||||||
|
domrf_obj_id = EXCLUDED.domrf_obj_id,
|
||||||
|
match_method = EXCLUDED.match_method,
|
||||||
|
match_score = EXCLUDED.match_score,
|
||||||
|
note = EXCLUDED.note,
|
||||||
|
updated_at = NOW()
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── orchestration ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def etl_lots(sl: sqlite3.Connection, pg, dry_run: bool, limit: int | None,
|
||||||
|
batch_size: int) -> int:
|
||||||
|
snap = date.today()
|
||||||
|
sql = "SELECT * FROM objective_lots"
|
||||||
|
if limit:
|
||||||
|
sql += f" LIMIT {limit}"
|
||||||
|
cur = sl.execute(sql)
|
||||||
|
n = 0
|
||||||
|
batch: list[tuple] = []
|
||||||
|
pg_cur = pg.cursor() if pg else None
|
||||||
|
while True:
|
||||||
|
rows = cur.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for r in rows:
|
||||||
|
batch.append(_row_to_lot(r, snap))
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
if not dry_run:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=batch_size)
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
batch.clear()
|
||||||
|
if n % (batch_size * 5) == 0:
|
||||||
|
logger.info(" lots: %d upserted", n)
|
||||||
|
if batch:
|
||||||
|
if not dry_run:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _LOT_INSERT, batch, page_size=len(batch))
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
if pg_cur is not None:
|
||||||
|
pg_cur.close()
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def etl_crm(sl: sqlite3.Connection, pg, dry_run: bool, limit: int | None,
|
||||||
|
batch_size: int) -> int:
|
||||||
|
sql = "SELECT * FROM objective_corp_month"
|
||||||
|
if limit:
|
||||||
|
sql += f" LIMIT {limit}"
|
||||||
|
cur = sl.execute(sql)
|
||||||
|
n = 0
|
||||||
|
skipped = 0
|
||||||
|
batch: list[tuple] = []
|
||||||
|
pg_cur = pg.cursor() if pg else None
|
||||||
|
while True:
|
||||||
|
rows = cur.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for r in rows:
|
||||||
|
t = _row_to_crm(r)
|
||||||
|
# Skip строк где report_month/project/corpus/room_bucket NULL — они нарушат UNIQUE.
|
||||||
|
if not t[0] or not t[2] or not t[6] or not t[7]:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
batch.append(t)
|
||||||
|
if len(batch) >= batch_size:
|
||||||
|
if not dry_run:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=batch_size)
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
batch.clear()
|
||||||
|
if batch:
|
||||||
|
if not dry_run:
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _CRM_INSERT, batch, page_size=len(batch))
|
||||||
|
pg.commit()
|
||||||
|
n += len(batch)
|
||||||
|
if pg_cur is not None:
|
||||||
|
pg_cur.close()
|
||||||
|
if skipped:
|
||||||
|
logger.warning(" corp_month: пропущено %d строк с пустыми обязательными полями", skipped)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def etl_mapping(sl: sqlite3.Connection, pg, dry_run: bool) -> int:
|
||||||
|
# ORDER BY score DESC — при дублях берём mapping с наибольшим match score.
|
||||||
|
cur = sl.execute("SELECT * FROM jk_objective_match ORDER BY score DESC NULLS LAST")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
# Дедупликация по (objective_complex_name, objective_group) — наш UNIQUE.
|
||||||
|
# У Антона разные site_id (jk:N) могут матчиться в один и тот же Objective project
|
||||||
|
# (fuzzy mapping для близких имён). Берём первый = с лучшим score.
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
batch: list[tuple] = []
|
||||||
|
skipped_dups = 0
|
||||||
|
for r in rows:
|
||||||
|
t = _row_to_mapping(r)
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
key = (t[0], t[2]) # (complex_name, group)
|
||||||
|
if key in seen:
|
||||||
|
skipped_dups += 1
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
batch.append(t)
|
||||||
|
if skipped_dups:
|
||||||
|
logger.info(" mapping: дедуплицировано %d дублей (один Objective project ↔ несколько jk)", skipped_dups)
|
||||||
|
if not batch:
|
||||||
|
return 0
|
||||||
|
if not dry_run:
|
||||||
|
pg_cur = pg.cursor()
|
||||||
|
psycopg2.extras.execute_values(pg_cur, _MAP_INSERT, batch, page_size=500)
|
||||||
|
pg.commit()
|
||||||
|
pg_cur.close()
|
||||||
|
return len(batch)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--sqlite", default=str(SQLITE_DEFAULT),
|
||||||
|
help=f"Путь к SQLite (default: {SQLITE_DEFAULT})")
|
||||||
|
ap.add_argument("--db-url", default=os.environ.get("DATABASE_URL"),
|
||||||
|
help="postgres URL (или env DATABASE_URL). Через SSH-туннель: "
|
||||||
|
"postgresql://gendesign:PWD@localhost:15432/gendesign")
|
||||||
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
|
help="Не писать в PG (только посчитать строки)")
|
||||||
|
ap.add_argument("--limit", type=int, default=None,
|
||||||
|
help="Ограничить кол-во строк (для пробного прогона)")
|
||||||
|
ap.add_argument("--batch-size", type=int, default=500,
|
||||||
|
help="execute_values batch size (default 500)")
|
||||||
|
ap.add_argument("--only", choices=("lots", "crm", "mapping"), default=None,
|
||||||
|
help="Грузить только одну таблицу")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
sqlite_path = Path(args.sqlite)
|
||||||
|
if not sqlite_path.exists():
|
||||||
|
logger.error("SQLite не найден: %s", sqlite_path)
|
||||||
|
return 1
|
||||||
|
if not args.db_url and not args.dry_run:
|
||||||
|
logger.error("Нужен --db-url или env DATABASE_URL")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
logger.info("ETL: %s → %s", sqlite_path.name, args.db_url or "(dry-run)")
|
||||||
|
|
||||||
|
sl = sqlite3.connect(sqlite_path)
|
||||||
|
sl.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
pg = None
|
||||||
|
if not args.dry_run:
|
||||||
|
pg = psycopg2.connect(args.db_url)
|
||||||
|
|
||||||
|
t0 = datetime.now()
|
||||||
|
try:
|
||||||
|
if args.only in (None, "mapping"):
|
||||||
|
logger.info("[1/3] objective_complex_mapping (Антоновский match)…")
|
||||||
|
n_map = etl_mapping(sl, pg, args.dry_run)
|
||||||
|
logger.info(" → upserted %d mappings", n_map)
|
||||||
|
|
||||||
|
if args.only in (None, "crm"):
|
||||||
|
logger.info("[2/3] objective_corpus_room_month (~19 738 строк)…")
|
||||||
|
n_crm = etl_crm(sl, pg, args.dry_run, args.limit, args.batch_size)
|
||||||
|
logger.info(" → upserted %d corp_month rows", n_crm)
|
||||||
|
|
||||||
|
if args.only in (None, "lots"):
|
||||||
|
logger.info("[3/3] objective_lots (~303 677 строк)…")
|
||||||
|
n_lots = etl_lots(sl, pg, args.dry_run, args.limit, args.batch_size)
|
||||||
|
logger.info(" → upserted %d lots", n_lots)
|
||||||
|
finally:
|
||||||
|
sl.close()
|
||||||
|
if pg is not None:
|
||||||
|
pg.close()
|
||||||
|
|
||||||
|
elapsed = (datetime.now() - t0).total_seconds()
|
||||||
|
logger.info("ETL DONE in %.1fs%s", elapsed, " (dry-run)" if args.dry_run else "")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
46
data/sql/72_objective_sync_config.sql
Normal file
46
data/sql/72_objective_sync_config.sql
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
-- Динамическая конфигурация Objective sync — single-row settings.
|
||||||
|
--
|
||||||
|
-- Зачем нужна:
|
||||||
|
-- 1. В админке /admin/scrape/objective можно менять параметры запроса
|
||||||
|
-- (groups, use_ddu/dkp, period_months_back, inter_group_delay) без редеплоя.
|
||||||
|
-- 2. Cron-расписание тоже хранится здесь — Celery beat читает его при старте.
|
||||||
|
-- Изменение требует restart beat-контейнера (это указано в UI как hint).
|
||||||
|
--
|
||||||
|
-- Pattern: single-row (id=1), UPSERT для безопасных обновлений.
|
||||||
|
-- Если строки нет — task/beat падают на settings.* env как fallback.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS objective_sync_config (
|
||||||
|
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||||
|
-- Cron-расписание для Celery beat (5-полевой формат: 'minute hour dom month dow').
|
||||||
|
-- Celery beat перечитывает это поле только при старте — после изменения
|
||||||
|
-- нужен `docker compose restart beat`.
|
||||||
|
cron_schedule TEXT NOT NULL DEFAULT '0 6 * * tue',
|
||||||
|
-- Группы для sync (csv). Эмпирически проверены на тарифе:
|
||||||
|
-- 'Свердловская область', 'Челябинск', 'Тюмень', 'Пермь',
|
||||||
|
-- 'Екатеринбург', 'Верхняя Пышма', 'Берёзовский', 'Нижний Тагил',
|
||||||
|
-- 'Каменск-Уральский', 'Первоуральск', 'Новоуральск', 'Сысерть'.
|
||||||
|
groups_csv TEXT NOT NULL DEFAULT 'Свердловская область,Челябинск,Тюмень,Пермь',
|
||||||
|
-- Параметры запроса /v2/Report/GetReport.
|
||||||
|
use_ddu BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
use_dkp BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
-- Сколько месяцев назад тянуть для Сводных отчётов (corp_sum).
|
||||||
|
-- 1 = только последний полный месяц (default). 12 = весь год.
|
||||||
|
-- Поквартирные/Лоты не имеют дат — всегда snapshot.
|
||||||
|
period_months_back SMALLINT NOT NULL DEFAULT 1 CHECK (period_months_back BETWEEN 1 AND 24),
|
||||||
|
-- Пауза между группами в одном task'е (секунды). Защита от 429.
|
||||||
|
inter_group_delay_s SMALLINT NOT NULL DEFAULT 30 CHECK (inter_group_delay_s BETWEEN 0 AND 300),
|
||||||
|
-- Пауза между HTTP-запросами в client (мс). Передаётся в ObjectiveClient(rate_ms=…).
|
||||||
|
rate_ms SMALLINT NOT NULL DEFAULT 3000 CHECK (rate_ms BETWEEN 0 AND 10000),
|
||||||
|
-- Сколько ретраев на 429/5xx.
|
||||||
|
retries SMALLINT NOT NULL DEFAULT 2 CHECK (retries BETWEEN 0 AND 5),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||||
|
updated_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE objective_sync_config IS
|
||||||
|
'Single-row динамическая конфигурация Objective sync. Все task-ы и beat '
|
||||||
|
'читают отсюда. Если строки нет — fallback на settings.* env.';
|
||||||
|
|
||||||
|
-- Засеваем единственную строку с дефолтами (если ещё не создана).
|
||||||
|
INSERT INTO objective_sync_config (id) VALUES (1)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
@ -91,6 +91,11 @@ services:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data # playwright_state.json + photo binaries
|
- ./data:/app/data # playwright_state.json + photo binaries
|
||||||
|
# Read-only bind-mount Антоновского /sf/ SQLite — для objective_etl task.
|
||||||
|
# На VPS файл лежит в /opt/gendesign/site-finder/analysis.db; путь внутри
|
||||||
|
# контейнера задан settings.objective_anton_sqlite_path (default
|
||||||
|
# /data/anton-sqlite/analysis.db).
|
||||||
|
- /opt/gendesign/site-finder:/data/anton-sqlite:ro
|
||||||
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
|
command: ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
|
||||||
|
|
||||||
beat:
|
beat:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { usePathname } from "next/navigation";
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ href: "/admin/scrape", label: "Скрапер DOM.РФ" },
|
{ href: "/admin/scrape", label: "Скрапер DOM.РФ" },
|
||||||
{ href: "/admin/scrape/nspd", label: "Скрапер NSPD" },
|
{ href: "/admin/scrape/nspd", label: "Скрапер NSPD" },
|
||||||
|
{ href: "/admin/scrape/objective", label: "ETL Objective" },
|
||||||
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
754
frontend/src/app/admin/scrape/objective/page.tsx
Normal file
754
frontend/src/app/admin/scrape/objective/page.tsx
Normal file
|
|
@ -0,0 +1,754 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { apiFetch } from "@/lib/api";
|
||||||
|
|
||||||
|
interface ObjectiveRunRow {
|
||||||
|
run_id: number;
|
||||||
|
started_at: string | null;
|
||||||
|
finished_at: string | null;
|
||||||
|
heartbeat_at: string | null;
|
||||||
|
group_name: string;
|
||||||
|
reports_ok: number;
|
||||||
|
reports_failed: number;
|
||||||
|
rows_corpus_room: number;
|
||||||
|
rows_lots: number;
|
||||||
|
rows_mappings: number;
|
||||||
|
requests_count: number;
|
||||||
|
status: string;
|
||||||
|
error: string | null;
|
||||||
|
triggered_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectiveCoverage {
|
||||||
|
pg: {
|
||||||
|
lots: number;
|
||||||
|
corp_room_month: number;
|
||||||
|
mappings: number;
|
||||||
|
last_lot_snapshot: string | null;
|
||||||
|
last_run_at: string | null;
|
||||||
|
};
|
||||||
|
sqlite: {
|
||||||
|
path: string;
|
||||||
|
exists: boolean;
|
||||||
|
size_bytes?: number;
|
||||||
|
modified_at?: number; // epoch seconds
|
||||||
|
lots?: number;
|
||||||
|
corp_room_month?: number;
|
||||||
|
mappings?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectiveTriggerResp {
|
||||||
|
task_id: string;
|
||||||
|
sqlite_path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectiveSyncOurResp {
|
||||||
|
task_id: string;
|
||||||
|
groups_planned: string[];
|
||||||
|
estimate_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObjectiveConfig {
|
||||||
|
cron_schedule: string;
|
||||||
|
groups_csv: string;
|
||||||
|
groups: string[];
|
||||||
|
use_ddu: boolean;
|
||||||
|
use_dkp: boolean;
|
||||||
|
period_months_back: number;
|
||||||
|
inter_group_delay_s: number;
|
||||||
|
rate_ms: number;
|
||||||
|
retries: number;
|
||||||
|
updated_at: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
_note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(b: number | undefined): string {
|
||||||
|
if (!b) return "—";
|
||||||
|
const mb = b / 1024 / 1024;
|
||||||
|
return mb >= 100 ? `${mb.toFixed(0)} MB` : `${mb.toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEpoch(sec: number | undefined): string {
|
||||||
|
if (!sec) return "—";
|
||||||
|
const d = new Date(sec * 1000);
|
||||||
|
return d.toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatIso(s: string | null): string {
|
||||||
|
if (!s) return "—";
|
||||||
|
return new Date(s).toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffSecs(a: string | null, b: string | null): string {
|
||||||
|
if (!a || !b) return "—";
|
||||||
|
const dt = (new Date(b).getTime() - new Date(a).getTime()) / 1000;
|
||||||
|
if (dt < 60) return `${dt.toFixed(1)}с`;
|
||||||
|
return `${(dt / 60).toFixed(1)} мин`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ObjectiveEtlAdminPage() {
|
||||||
|
const [token, setToken] = useState<string>(() =>
|
||||||
|
typeof window === "undefined"
|
||||||
|
? ""
|
||||||
|
: (localStorage.getItem("admin_token") ?? ""),
|
||||||
|
);
|
||||||
|
const [overrideSqlitePath, setOverrideSqlitePath] = useState("");
|
||||||
|
const [lastTaskId, setLastTaskId] = useState<string | null>(null);
|
||||||
|
// Группы для нашего sync — csv. Пусто = backend возьмёт настройки из БД
|
||||||
|
const [syncGroupsCsv, setSyncGroupsCsv] = useState("");
|
||||||
|
const [lastSyncTaskId, setLastSyncTaskId] = useState<string | null>(null);
|
||||||
|
// Локальный state для формы настроек (заполняется из cfg при первом ответе)
|
||||||
|
const [cfgDraft, setCfgDraft] = useState<Partial<ObjectiveConfig>>({});
|
||||||
|
const [cfgSaved, setCfgSaved] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const saveToken = (v: string) => {
|
||||||
|
setToken(v);
|
||||||
|
localStorage.setItem("admin_token", v);
|
||||||
|
};
|
||||||
|
|
||||||
|
const coverage = useQuery({
|
||||||
|
queryKey: ["objective-coverage", token],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<ObjectiveCoverage>("/api/v1/admin/scrape/objective/coverage", {
|
||||||
|
headers: { "X-Admin-Token": token },
|
||||||
|
}),
|
||||||
|
enabled: !!token,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runs = useQuery({
|
||||||
|
queryKey: ["objective-runs", token],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<ObjectiveRunRow[]>(
|
||||||
|
"/api/v1/admin/scrape/objective/runs?limit=20",
|
||||||
|
{ headers: { "X-Admin-Token": token } },
|
||||||
|
),
|
||||||
|
enabled: !!token,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch<ObjectiveTriggerResp>("/api/v1/admin/scrape/objective", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Admin-Token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sqlite_path: overrideSqlitePath || null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setLastTaskId(data.task_id);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["objective-runs"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["objective-coverage"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Динамический config из БД
|
||||||
|
const cfg = useQuery({
|
||||||
|
queryKey: ["objective-config", token],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<ObjectiveConfig>("/api/v1/admin/scrape/objective/config", {
|
||||||
|
headers: { "X-Admin-Token": token },
|
||||||
|
}),
|
||||||
|
enabled: !!token,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// При первом успешном fetch'е — заполняем draft
|
||||||
|
if (cfg.data && Object.keys(cfgDraft).length === 0) {
|
||||||
|
setCfgDraft(cfg.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCfg = useMutation({
|
||||||
|
mutationFn: (patch: Partial<ObjectiveConfig>) =>
|
||||||
|
apiFetch<ObjectiveConfig>("/api/v1/admin/scrape/objective/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"X-Admin-Token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCfgDraft(data);
|
||||||
|
setCfgSaved(true);
|
||||||
|
window.setTimeout(() => setCfgSaved(false), 3000);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["objective-config"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Наш sync через api.objctv.ru (платный API → расширение покрытия УрФО)
|
||||||
|
const syncOur = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const groups = syncGroupsCsv
|
||||||
|
.split(",")
|
||||||
|
.map((g) => g.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return apiFetch<ObjectiveSyncOurResp>(
|
||||||
|
"/api/v1/admin/scrape/objective/sync-our",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Admin-Token": token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
groups: groups.length ? groups : null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setLastSyncTaskId(data.task_id);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["objective-runs"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Подсказка про свежесть данных
|
||||||
|
const sqliteFresher = (() => {
|
||||||
|
const cov = coverage.data;
|
||||||
|
if (!cov?.sqlite.modified_at || !cov?.pg.last_run_at) return null;
|
||||||
|
const sqliteMs = cov.sqlite.modified_at * 1000;
|
||||||
|
const pgMs = new Date(cov.pg.last_run_at).getTime();
|
||||||
|
return sqliteMs > pgMs;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">
|
||||||
|
Objective: sync с api.objctv.ru → наша PG
|
||||||
|
</h1>
|
||||||
|
<a
|
||||||
|
href="/admin/scrape"
|
||||||
|
className="text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
← Назад к kn-scraper
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
<strong>Source of truth = эта PG.</strong> Наш Celery каждый вторник
|
||||||
|
06:00 МСК тянет 4 группы (Свердл.обл + Челябинск + Тюмень + Пермь) с
|
||||||
|
api.objctv.ru напрямую в objective_lots / corpus_room_month /
|
||||||
|
lots_history. SQLite Антона (/sf/) — отдельный микросервис со своим
|
||||||
|
scoring-слоем, используется только для bootstrap-импорта (не нужен для
|
||||||
|
регулярной работы).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Token */}
|
||||||
|
<div className="rounded border border-gray-300 p-4">
|
||||||
|
<label className="block text-sm font-medium">
|
||||||
|
Admin token (X-Admin-Token)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => saveToken(e.target.value)}
|
||||||
|
placeholder="введи token, сохранится в localStorage"
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Coverage — текущее состояние нашей PG */}
|
||||||
|
<div className="rounded border border-gray-300 p-4">
|
||||||
|
<h2 className="mb-2 font-semibold">
|
||||||
|
📦 Состояние PG (источник истины)
|
||||||
|
</h2>
|
||||||
|
{coverage.isLoading && (
|
||||||
|
<p className="text-sm text-gray-500">Загрузка…</p>
|
||||||
|
)}
|
||||||
|
{coverage.data && (
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-600">objective_lots</div>
|
||||||
|
<div className="font-mono text-lg font-semibold">
|
||||||
|
{coverage.data.pg.lots.toLocaleString("ru-RU")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-600">corp×room×month</div>
|
||||||
|
<div className="font-mono text-lg font-semibold">
|
||||||
|
{coverage.data.pg.corp_room_month.toLocaleString("ru-RU")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-600">complex_mapping</div>
|
||||||
|
<div className="font-mono text-lg font-semibold">
|
||||||
|
{coverage.data.pg.mappings.toLocaleString("ru-RU")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 grid grid-cols-2 gap-4 border-t border-gray-200 pt-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">last lot snapshot:</span>{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{coverage.data.pg.last_lot_snapshot ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">last successful sync:</span>{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{formatIso(coverage.data.pg.last_run_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 grid grid-cols-3 gap-4 border-t border-gray-200 pt-2 text-xs text-gray-600">
|
||||||
|
<div>
|
||||||
|
<span>SQLite Антона (legacy):</span>{" "}
|
||||||
|
{coverage.data.sqlite.exists ? (
|
||||||
|
<span className="text-green-700">
|
||||||
|
✓ {formatBytes(coverage.data.sqlite.size_bytes)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">✗ не примонтирован</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>файл обновлён:</span>{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{formatEpoch(coverage.data.sqlite.modified_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>lots в SQLite:</span>{" "}
|
||||||
|
<span className="font-mono">
|
||||||
|
{coverage.data.sqlite.lots?.toLocaleString("ru-RU") ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PRIMARY: Наш sync с api.objctv.ru → PG (source of truth) */}
|
||||||
|
<div className="rounded border-2 border-blue-400 bg-blue-50 p-4">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-blue-900">
|
||||||
|
🌐 Наш sync: api.objctv.ru → PostgreSQL
|
||||||
|
</h2>
|
||||||
|
<p className="mb-3 text-sm text-gray-800">
|
||||||
|
Основной production pipeline. Дёргает api.objctv.ru напрямую и пишет в
|
||||||
|
objective_lots / corpus_room_month / lots_history в нашей PG.
|
||||||
|
Покрытие: 4 группы УрФО (Свердл.обл целиком + Челябинск + Тюмень +
|
||||||
|
Пермь). Каждая группа = 2 отчёта (corp_sum + lots_pf), пауза 30с между
|
||||||
|
группами. ~3 минуты на группу. Тратит лимиты тарифа Объектива.
|
||||||
|
</p>
|
||||||
|
<p className="mb-3 text-xs text-gray-700">
|
||||||
|
⏰ Auto: расписание из настроек (см. блок «⚙️ Настройки» ниже, сейчас:{" "}
|
||||||
|
<code className="font-mono">{cfg.data?.cron_schedule ?? "..."}</code>
|
||||||
|
). Кнопка ниже — ручной триггер для немедленного запуска.
|
||||||
|
</p>
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="block text-sm">
|
||||||
|
Override групп (через запятую, пусто = взять из «⚙️ Настройки»:{" "}
|
||||||
|
<code className="font-mono text-xs">
|
||||||
|
{cfg.data?.groups_csv ?? "—"}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={syncGroupsCsv}
|
||||||
|
onChange={(e) => setSyncGroupsCsv(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
cfg.data?.groups_csv ?? "Свердл.обл,Челябинск,Тюмень,Пермь"
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Доступные на тарифе (probe 2026-05-10): Екатеринбург, Свердловская
|
||||||
|
область, Свердловск, Верхняя Пышма, Берёзовский, Сысерть, Нижний
|
||||||
|
Тагил, Каменск-Уральский, Первоуральск, Новоуральск, Челябинск,
|
||||||
|
Тюмень, Пермь.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => syncOur.mutate()}
|
||||||
|
disabled={syncOur.isPending || !token}
|
||||||
|
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncOur.isPending ? "Запуск…" : "Запустить наш sync"}
|
||||||
|
</button>
|
||||||
|
{lastSyncTaskId && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
Запущена task <code className="font-mono">{lastSyncTaskId}</code>.
|
||||||
|
Прогресс — в таблице прогонов ниже (один run per группа).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{syncOur.error && (
|
||||||
|
<p className="mt-2 text-sm text-red-600">
|
||||||
|
Ошибка: {(syncOur.error as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ⚙️ Настройки (динамические, из БД objective_sync_config) */}
|
||||||
|
<details
|
||||||
|
className="rounded border border-gray-300 bg-white"
|
||||||
|
open={!cfg.data?.cron_schedule}
|
||||||
|
>
|
||||||
|
<summary className="cursor-pointer p-3 font-semibold text-gray-800">
|
||||||
|
⚙️ Настройки sync (расписание + параметры запроса)
|
||||||
|
</summary>
|
||||||
|
<div className="space-y-3 p-4 pt-0 text-sm">
|
||||||
|
{!cfg.data && <p className="text-gray-500">Загрузка…</p>}
|
||||||
|
{cfg.data && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
📅 Cron-расписание (5 полей: minute hour dom month dow).
|
||||||
|
После изменения нужен{" "}
|
||||||
|
<code>docker compose restart beat</code>.
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cfgDraft.cron_schedule ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({
|
||||||
|
...cfgDraft,
|
||||||
|
cron_schedule: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="0 6 * * tue"
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Сейчас активно:{" "}
|
||||||
|
<code className="font-mono">{cfg.data.cron_schedule}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
🌍 Группы (csv) — будут перебираться в одном sync
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cfgDraft.groups_csv ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({ ...cfgDraft, groups_csv: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Свердловская область,Челябинск,Тюмень,Пермь"
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
📊 use_ddu (тянуть ДДУ-сделки)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={String(cfgDraft.use_ddu ?? true)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({
|
||||||
|
...cfgDraft,
|
||||||
|
use_ddu: e.target.value === "true",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="true">true (включить)</option>
|
||||||
|
<option value="false">false (выключить)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
📊 use_dkp (тянуть ДКП-сделки)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={String(cfgDraft.use_dkp ?? true)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({
|
||||||
|
...cfgDraft,
|
||||||
|
use_dkp: e.target.value === "true",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
🗓 period_months_back (для corp_sum)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
value={cfgDraft.period_months_back ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({
|
||||||
|
...cfgDraft,
|
||||||
|
period_months_back: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
1 = только последний полный месяц. 12 = последний год.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
⏱ inter_group_delay_s
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={300}
|
||||||
|
value={cfgDraft.inter_group_delay_s ?? 30}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({
|
||||||
|
...cfgDraft,
|
||||||
|
inter_group_delay_s: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Пауза между группами (сек). Меньше → быстрее, но риск 429.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
🚦 rate_ms (между HTTP-запросами)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10000}
|
||||||
|
step={100}
|
||||||
|
value={cfgDraft.rate_ms ?? 3000}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({
|
||||||
|
...cfgDraft,
|
||||||
|
rate_ms: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600">
|
||||||
|
🔄 retries (на 429/5xx)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={5}
|
||||||
|
value={cfgDraft.retries ?? 2}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCfgDraft({
|
||||||
|
...cfgDraft,
|
||||||
|
retries: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => saveCfg.mutate(cfgDraft)}
|
||||||
|
disabled={saveCfg.isPending || !token}
|
||||||
|
className="rounded bg-emerald-600 px-4 py-2 font-medium text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saveCfg.isPending ? "Сохранение…" : "Сохранить настройки"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => cfg.data && setCfgDraft(cfg.data)}
|
||||||
|
className="rounded border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Сбросить к актуальному
|
||||||
|
</button>
|
||||||
|
{cfgSaved && (
|
||||||
|
<span className="text-sm text-green-700">
|
||||||
|
✓ Сохранено. Параметры (кроме cron) применятся со следующим
|
||||||
|
sync.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{saveCfg.error && (
|
||||||
|
<span className="text-sm text-red-600">
|
||||||
|
{(saveCfg.error as Error).message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cfg.data.updated_at && (
|
||||||
|
<p className="border-t border-gray-200 pt-2 text-xs text-gray-500">
|
||||||
|
Последнее изменение: {formatIso(cfg.data.updated_at)}{" "}
|
||||||
|
{cfg.data.updated_by && `(by ${cfg.data.updated_by})`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* SECONDARY: Bootstrap ETL Антоновского SQLite → PG */}
|
||||||
|
<details className="rounded border border-gray-300 bg-gray-50">
|
||||||
|
<summary className="cursor-pointer p-3 font-semibold text-gray-700">
|
||||||
|
🛠 Bootstrap ETL: SQLite Антона → PG{" "}
|
||||||
|
<span className="font-normal text-xs text-gray-500">
|
||||||
|
(одноразовый импорт; в обычной работе не нужен — наш sync всё делает
|
||||||
|
напрямую)
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="space-y-3 p-4 pt-0">
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
Используется только когда Антоновский /sf/ имеет данные, которых ещё
|
||||||
|
нет в нашей PG (например при первом запуске). После того как наш
|
||||||
|
sync отработал хотя бы 1 раз — этот ETL не нужен.{" "}
|
||||||
|
<strong>Это legacy-инструмент.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{sqliteFresher === true && (
|
||||||
|
<div className="rounded border border-yellow-400 bg-yellow-50 p-3 text-sm text-yellow-900">
|
||||||
|
⚠️ SQLite Антона обновлён ПОСЛЕ последнего ETL — но это уже
|
||||||
|
неважно если наш sync настроен на cron. Используй кнопку только
|
||||||
|
если хочешь срочно подхватить Антоновские данные раньше нашего
|
||||||
|
следующего sync (вторник 06:00).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm">Override sqlite_path</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={overrideSqlitePath}
|
||||||
|
onChange={(e) => setOverrideSqlitePath(e.target.value)}
|
||||||
|
placeholder="/data/anton-sqlite/analysis.db"
|
||||||
|
className="mt-1 w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => trigger.mutate()}
|
||||||
|
disabled={trigger.isPending || !token}
|
||||||
|
className="rounded bg-gray-500 px-4 py-2 text-sm font-medium text-white hover:bg-gray-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{trigger.isPending ? "Запуск…" : "Bootstrap ETL"}
|
||||||
|
</button>
|
||||||
|
{lastTaskId && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Запущена task <code className="font-mono">{lastTaskId}</code>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{trigger.error && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
Ошибка: {(trigger.error as Error).message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Runs */}
|
||||||
|
<div className="rounded border border-gray-300">
|
||||||
|
<div className="border-b border-gray-300 p-3 font-semibold">
|
||||||
|
📜 Последние прогоны (top 20, обновляется каждые 5с)
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1 text-left">run_id</th>
|
||||||
|
<th className="px-2 py-1 text-left">started</th>
|
||||||
|
<th className="px-2 py-1 text-left">duration</th>
|
||||||
|
<th className="px-2 py-1 text-right">lots</th>
|
||||||
|
<th className="px-2 py-1 text-right">corp×room</th>
|
||||||
|
<th className="px-2 py-1 text-right">mappings</th>
|
||||||
|
<th className="px-2 py-1 text-left">status</th>
|
||||||
|
<th className="px-2 py-1 text-left">trigger</th>
|
||||||
|
<th className="px-2 py-1 text-left">error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{runs.data?.map((r) => {
|
||||||
|
const heartbeatLag = r.heartbeat_at
|
||||||
|
? (Date.now() - new Date(r.heartbeat_at).getTime()) / 1000
|
||||||
|
: null;
|
||||||
|
const isStale =
|
||||||
|
r.status === "running" &&
|
||||||
|
heartbeatLag !== null &&
|
||||||
|
heartbeatLag > 60;
|
||||||
|
return (
|
||||||
|
<tr key={r.run_id} className="border-t border-gray-100">
|
||||||
|
<td className="px-2 py-1 font-mono">{r.run_id}</td>
|
||||||
|
<td className="px-2 py-1">{formatIso(r.started_at)}</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
{diffSecs(r.started_at, r.finished_at ?? r.heartbeat_at)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 text-right font-mono">
|
||||||
|
{r.rows_lots?.toLocaleString("ru-RU") ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 text-right font-mono">
|
||||||
|
{r.rows_corpus_room?.toLocaleString("ru-RU") ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 text-right font-mono">
|
||||||
|
{r.rows_mappings?.toLocaleString("ru-RU") ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
r.status === "done"
|
||||||
|
? "rounded bg-green-100 px-1.5 text-green-800"
|
||||||
|
: r.status === "running" && !isStale
|
||||||
|
? "rounded bg-blue-100 px-1.5 text-blue-800"
|
||||||
|
: r.status === "failed" || isStale
|
||||||
|
? "rounded bg-red-100 px-1.5 text-red-800"
|
||||||
|
: "rounded bg-gray-100 px-1.5 text-gray-800"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isStale ? "stale" : r.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1 text-gray-600">
|
||||||
|
{r.triggered_by}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="max-w-xs truncate px-2 py-1 text-red-700"
|
||||||
|
title={r.error ?? ""}
|
||||||
|
>
|
||||||
|
{r.error ?? ""}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{runs.data?.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={9}
|
||||||
|
className="px-2 py-4 text-center text-gray-500"
|
||||||
|
>
|
||||||
|
Прогонов ещё не было
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue