diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index 8f7d3687..c510c8b3 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -2861,28 +2861,15 @@ def analyze_parcel( # (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом # (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open). - # §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue. Таска - # `forecast_site_finder_report` в фоне (~30-180s) читает persisted analyze-1.0 ран - # (latest_run_for на момент исполнения, не на момент enqueue) и считает §22 - # SiteFinderReport ('1.0'). analyze НЕ ждёт её — возвращаемся сразу. Celery/Redis down - # НЕ должен валить ответ (он уже успешен: frontend зависит от формы). Зеркалит - # best-effort стиль find_or_enqueue_fetch. Lazy import — избегаем import-цикла - # api ↔ workers.tasks на старте. - try: - from app.workers.tasks.forecast import forecast_site_finder_report - - forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user) - result_payload["forecast"] = {"status": "pending", "horizon": horizon} - except Exception: - # Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory, - # клиент узнаёт по status="unavailable" и не будет зря поллить /forecast. - logger.warning( - "forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected", - cad_num, - horizon, - exc_info=True, - ) - result_payload["forecast"] = {"status": "unavailable", "horizon": horizon} + # §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы + # jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический + # enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть + # ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не + # посчитается, а ретраев у таски нет (regression #1561-followup). result_payload + # передаётся в persist by-reference; если enqueue провалится после persist — + # перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted + # снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана). + result_payload["forecast"] = {"status": "pending", "horizon": horizon} # ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) + # функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off): @@ -2950,6 +2937,30 @@ def analyze_parcel( created_by=x_authenticated_user, ) + # §22-форсайт enqueue — СТРОГО ПОСЛЕ persist_analysis_run. persist_analysis_run — + # единственный commit analyze-рана (get_db() на success не коммитит), поэтому enqueue + # должен случиться только после того, как ран закоммичен: иначе фоновая таска + # `forecast_site_finder_report` (~30-180s) прочтёт latest_run_for и не найдёт свежий + # ран (None/старый) → форсайт молча не посчитается, ретраев нет (#1561-followup). + # Best-effort fire-and-forget: Celery/Redis down НЕ валит ответ (он уже успешен, + # frontend зависит от формы). Зеркалит best-effort стиль find_or_enqueue_fetch. + # Lazy import — избегаем import-цикла api ↔ workers.tasks на старте. + try: + from app.workers.tasks.forecast import forecast_site_finder_report + + forecast_site_finder_report.delay(cad_num, horizon, x_authenticated_user) + except Exception: + # Enqueue не удался (broker недоступен и т.п.) — §9.x форсайт advisory, + # клиент узнаёт по status="unavailable" и не будет зря поллить /forecast. + # persisted снимок остаётся "pending" (безвреден — poll читает live-статус рана). + logger.warning( + "forecast enqueue failed for cad=%s horizon=%s — analyze response unaffected", + cad_num, + horizon, + exc_info=True, + ) + result_payload["forecast"] = {"status": "unavailable", "horizon": horizon} + return result_payload diff --git a/backend/app/services/exporters/trade_in_pdf.py b/backend/app/services/exporters/trade_in_pdf.py index d009cd8c..c917b295 100644 --- a/backend/app/services/exporters/trade_in_pdf.py +++ b/backend/app/services/exporters/trade_in_pdf.py @@ -257,7 +257,7 @@ def _build_html(estimate: AggregatedEstimate, input_snapshot: dict) -> str: # t