build_site_finder_report (§22) takes ~30-180s → runs in a background Celery task, not inline on the sync /analyze endpoint. - repository: latest_run_for gains keyword-only schema_version (default None keeps v_analysis_runs_latest behavior, backward-compat); when given, reads base analysis_runs filtered by schema_version ORDER BY created_at DESC LIMIT 1 — fetches the latest analyze-1.0 site-analysis run even when newer 1.0 (§22) rows exist on top (index-served via 127's (cad_num, created_at DESC)). - new workers/tasks/forecast.py::forecast_site_finder_report: reads latest analyze-1.0, runs the §22 orchestrator, persists SiteFinderReport.as_dict() as a 1.0 run via persist_analysis_run. Graceful: no base run / compute error → logger + return None (worker not crashed). time_limit=900/soft=840 (no global limit). Registered in include. Prod-confirmed: analyze-1.0 result carries the full analyze dict (competitors+district) → orchestrator input valid. Endpoint trigger (3b-ii) + §9.x untouched. 943 tests pass; code-review APPROVE (contracts verified vs real as_dict(); status done→complete normalized, no IntegrityError). Refs #994 #961.
174 lines
9.5 KiB
Python
174 lines
9.5 KiB
Python
"""Celery task: посчитать §22 SiteFinderReport в фоне и сохранить в analysis_runs.
|
||
|
||
Step 3b-i проводки форсайта. §22-оркестратор ``build_site_finder_report`` (#1056,
|
||
forecasting/orchestrator.py) считает форсайт из §9.x-слоёв — это занимает ~30-180с
|
||
(ЕКБ-wide ~184с, scoped-сегмент меньше), поэтому НЕ может крутиться inline на
|
||
синхронном /analyze-эндпоинте. Здесь — фоновая Celery-таска, которая его запускает
|
||
и персистит результат рядом с site-analysis раном в той же таблице ``analysis_runs``.
|
||
|
||
ПОТОК (read → build → persist):
|
||
1. Читаем ПОСЛЕДНИЙ site-analysis ран ('analyze-1.0') для cad_num через
|
||
schema-фильтрованный ``latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)``
|
||
(#3b-i, repository.py) — НЕ через view: поверх analyze-рана уже могут лежать
|
||
§22-раны ('1.0'), view вернул бы их, а нам нужна именно site-analysis база.
|
||
Нет такого рана → logger.warning + return None (нечего форсайтить — graceful,
|
||
без падения воркера).
|
||
2. ``analyze = row.result`` (полный персиснутый dict analyze — несёт district +
|
||
competitors, которые нужны оркестратору). district — из денорм-колонки рана,
|
||
fallback в analyze["district"]["district_name"].
|
||
3. ``build_site_finder_report(db, analyze=…, cad_num=…, district=…, horizons=…)``
|
||
— горизонты = §9.x default grid, с гарантией что запрошенный ``horizon`` в сетке.
|
||
4. ``report.as_dict()`` → ``persist_analysis_run(... schema_version="1.0",
|
||
status="done" ...)``. persist сам best-effort (SAVEPOINT + commit), нормализует
|
||
status/confidence под CHECK'и миграции 127 (status='done' → нормализуется в
|
||
'complete'; форма CHECK не знает 'done'). Возвращаем id новой строки (или None).
|
||
|
||
NB схема: site-analysis раны несут schema_version='analyze-1.0' (ANALYZE_SCHEMA_VERSION),
|
||
§22-форсайт-раны — '1.0' (собственный SiteFinderReport._SCHEMA_VERSION, прокидывается
|
||
из ``d["schema_version"]``). Оба сосуществуют в analysis_runs, различаются по
|
||
schema_version — поэтому read берёт именно 'analyze-1.0', а write кладёт '1.0'.
|
||
|
||
Mirror conventions (supply_layers_refresh.py / cbr_macro_sync.py): ``@celery_app.task``,
|
||
``SessionLocal()`` + try/finally close, ``logger`` (не print), синхронный ``def`` (НЕ
|
||
async внутри таски), ``CAST(:x AS type)`` живёт в repository (тут SQL не пишем).
|
||
|
||
TIME LIMIT: build_site_finder_report может занять минуты — ставим щедрый per-task лимит
|
||
(time_limit=900с / soft_time_limit=840с), чтобы таску не убили mid-compute. Глобального
|
||
task_time_limit в celery_app.py нет (только timezone + beat_schedule), так что override
|
||
тут — единственный лимит для этой таски.
|
||
|
||
REGISTRATION: добавлено в ``include=[...]`` celery_app.py (зеркало того, как
|
||
зарегистрирован supply_layers_refresh) — beat-расписания НЕТ намеренно (таска
|
||
enqueue-on-demand; триггер из /analyze — отдельный шаг 3b-ii, вне scope).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
from app.core.db import SessionLocal
|
||
from app.services.analysis_runs.repository import (
|
||
ANALYZE_SCHEMA_VERSION,
|
||
latest_run_for,
|
||
persist_analysis_run,
|
||
)
|
||
from app.services.forecasting.orchestrator import build_site_finder_report
|
||
from app.workers.celery_app import celery_app
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Горизонты прогноза по умолчанию (мес) — зеркало §9.x default grid (orchestrator.py
|
||
# _DEFAULT_HORIZONS / #952/#984/#986). Запрошенный horizon домешивается, если его нет.
|
||
_DEFAULT_HORIZONS: tuple[int, ...] = (6, 12, 18, 24)
|
||
|
||
|
||
def _horizons_for(horizon: int) -> list[int]:
|
||
"""Default-сетка горизонтов с ГАРАНТИЕЙ что запрошенный ``horizon`` в ней. PURE.
|
||
|
||
§9.x single-горизонтные сервисы (score_card/overlay/future_supply) берут primary
|
||
из сетки (orchestrator._primary_horizon → 12 если есть, иначе первый). Чтобы
|
||
запрошенный horizon реально влиял на прогноз, добавляем его в сетку (отсортировано,
|
||
без дублей). horizon ≤ 0 (бессмысленный) → возвращаем чистую default-сетку.
|
||
"""
|
||
if horizon <= 0:
|
||
return list(_DEFAULT_HORIZONS)
|
||
return sorted(set(_DEFAULT_HORIZONS) | {horizon})
|
||
|
||
|
||
@celery_app.task(
|
||
bind=True,
|
||
name="tasks.forecast.forecast_site_finder_report",
|
||
time_limit=900,
|
||
soft_time_limit=840,
|
||
)
|
||
def forecast_site_finder_report(
|
||
self: Any,
|
||
cad_num: str,
|
||
horizon: int = 12,
|
||
created_by: str | None = None,
|
||
) -> int | None:
|
||
"""Посчитать §22 SiteFinderReport для участка в фоне и сохранить в analysis_runs.
|
||
|
||
Берёт последний site-analysis ран ('analyze-1.0') как базу, считает §22-форсайт
|
||
через оркестратор (#1056) и персистит ``SiteFinderReport.as_dict()`` отдельной
|
||
строкой ('1.0') в той же таблице. Best-effort на каждом шаге: нет базы → None
|
||
(warning, не crash); неожиданная ошибка compute → logger.exception + None (таска
|
||
не валит воркер — persist сам best-effort, и форсайт может честно не собраться).
|
||
|
||
Args:
|
||
cad_num: кадастровый номер участка.
|
||
horizon: основной горизонт прогноза (мес); домешивается в default-сетку.
|
||
created_by: автор рана (прокидывается в persist; None допустимо).
|
||
|
||
Returns:
|
||
id новой строки analysis_runs (§22-форсайт, schema_version='1.0') или None,
|
||
если базы для прогноза нет / персист не удался / compute упал.
|
||
"""
|
||
db = SessionLocal()
|
||
try:
|
||
# 1. База прогноза — последний site-analysis ран ('analyze-1.0'), НЕ через view.
|
||
row = latest_run_for(db, cad_num, schema_version=ANALYZE_SCHEMA_VERSION)
|
||
if row is None:
|
||
logger.warning(
|
||
"forecast_site_finder_report: no %s run for cad=%s — nothing to forecast "
|
||
"from (run /analyze first); returning None",
|
||
ANALYZE_SCHEMA_VERSION,
|
||
cad_num,
|
||
)
|
||
return None
|
||
|
||
# 2. analyze-dict (несёт district + competitors) + district (денорм → fallback).
|
||
analyze: dict[str, Any] = row.result or {}
|
||
district = row.district or (analyze.get("district") or {}).get("district_name")
|
||
|
||
horizons = _horizons_for(horizon)
|
||
logger.info(
|
||
"forecast_site_finder_report: building §22 report cad=%s district=%s "
|
||
"horizon=%s horizons=%s (base run id=%s)",
|
||
cad_num,
|
||
district,
|
||
horizon,
|
||
horizons,
|
||
row.id,
|
||
)
|
||
|
||
# 3. §22-форсайт через оркестратор (#1056) — может занять минуты (time_limit=900).
|
||
report = build_site_finder_report(
|
||
db,
|
||
analyze=analyze,
|
||
cad_num=cad_num,
|
||
district=district,
|
||
horizons=horizons,
|
||
)
|
||
d = report.as_dict()
|
||
|
||
# 4. Персист отдельной строкой '1.0'. status='done' нормализуется repository'ем
|
||
# под CHECK миграции 127 (→ 'complete'); confidence/segment — из as_dict().
|
||
new_id = persist_analysis_run(
|
||
db,
|
||
cad_num=cad_num,
|
||
result=d,
|
||
params={"horizon": horizon, "source": "forecast-task"},
|
||
district=district,
|
||
confidence=(d.get("confidence") or {}).get("level"),
|
||
status="done",
|
||
schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION)
|
||
created_by=created_by,
|
||
segment=(d.get("meta") or {}).get("segment"),
|
||
)
|
||
logger.info(
|
||
"forecast_site_finder_report: persisted §22 report cad=%s run_id=%s schema_version=%s",
|
||
cad_num,
|
||
new_id,
|
||
d.get("schema_version"),
|
||
)
|
||
return new_id
|
||
except Exception:
|
||
# Неожиданный сбой compute/чтения: логируем traceback и НЕ роняем воркер —
|
||
# форсайт может честно не собраться (тонкие данные / сбой §9.x-шва), а persist
|
||
# сам best-effort. Возвращаем None — таска failed gracefully.
|
||
logger.exception("forecast_site_finder_report failed for cad=%s (returning None)", cad_num)
|
||
return None
|
||
finally:
|
||
db.close()
|