gendesign/backend/app/workers/tasks/forecast.py
Light1YT c94810f3a7
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m21s
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m11s
feat(forecast): forecast_site_finder_report Celery task + schema-filtered run read (3b-i)
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.
2026-06-05 09:02:15 +05:00

174 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Celery task: посчитать §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()