diff --git a/backend/app/services/exporters/full_report_pdf.py b/backend/app/services/exporters/full_report_pdf.py index c2491202..36d3aa56 100644 --- a/backend/app/services/exporters/full_report_pdf.py +++ b/backend/app/services/exporters/full_report_pdf.py @@ -7,8 +7,10 @@ volume `/app/reports/` с метадата-строкой в `analysis_runs` (sc ПОТОК (:func:`build_full_report`): 1. analyze-ран (`latest_run_for(..., schema_version=ANALYZE_SCHEMA_VERSION)`) — нет → ValueError (отчёт без базового анализа бессмысленен). - 2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → Part B (§4–§6) - деградирует «нет данных», отчёт всё равно валиден (передаём {} в part_b). + 2. forecast-ран (`latest_run_for(..., schema_version="1.0")`) — нет → best-effort + СИНХРОННО считаем его тут же (`_ensure_forecast_run`, зеркало Celery-таски форсайта, + ~20–30с) и перечитываем; всё ещё нет (сбой/тонкие данные) → Part B (§4–§6) деградирует + «нет данных», отчёт всё равно валиден (передаём {} в part_b). 3. КЭШ-ключ = (analyze_run_id, forecast_run_id). Если метадата-ран `report-pdf-1.0` с теми же id уже есть И файл на месте → cache-hit, PDF не пере-рендерим. 4. connection-capacity (`get_connection_capacity`) — best-effort, для §3-резервов. @@ -68,6 +70,12 @@ REPORT_SCHEMA_VERSION = "report-pdf-1.0" # "forecast-1.0", а именно "1.0", это SiteFinderReport._SCHEMA_VERSION). _FORECAST_SCHEMA_VERSION = "1.0" +# Горизонты best-effort синхронного форсайта (мес). Зеркало Celery-таски +# `forecast_site_finder_report(horizon=12)`: `_horizons_for(12)` = sorted({6,12,18,24}|{12}) +# = [6,12,18,24] (forecast.py) = orchestrator._DEFAULT_HORIZONS. Держим тот же набор, +# чтобы «холодный» участок получил §4–§6 идентичные ленивому GET /forecast-пути. +_FORECAST_HORIZONS: tuple[int, ...] = (6, 12, 18, 24) + # Верхняя граница длинной стороны карт-PNG (px) — печатный A4, 1400 достаточно для # ~150 dpi на ширину колонки, но не раздувает PDF гигабайтными растрами. _MAP_MAX_PX = 1400 @@ -211,6 +219,82 @@ def _get_connection_capacity(db: Session, cad: str) -> dict[str, Any] | None: return None +def _ensure_forecast_run( + db: Session, + cad: str, + analyze_row: Any, + analyze: dict[str, Any], +) -> None: + """Best-effort синхронно посчитать §22-форсайт для «холодного» участка (#2259 gap). + + §22-форсайт-ран ("1.0") существует ТОЛЬКО если пользователь открывал страницу участка + (§22 строится лениво GET /forecast-поллингом Celery-таской `forecast_site_finder_report`). + На холодном участке полный отчёт выходил БЕЗ §4–§6. Здесь — тот же compute+persist, что + делает Celery-таска, но СИНХРОННО и ПРЯМЫМ вызовом: мы УЖЕ внутри worker'а + (build_full_report_task), поэтому НЕ .delay — считаем inline (~20–30с) и персистим тем же + контрактом, чтобы последующий `latest_run_for("1.0")` в build_full_report поймал свежий ран + (его новый id корректно войдёт в кэш-ключ отчёта → cache-miss → §4–§6 попадут в PDF). + + Зеркало `forecast_site_finder_report(horizon=12)` (workers/tasks/forecast.py): + • horizons = `_FORECAST_HORIZONS` (= `_horizons_for(12)` = [6,12,18,24]); + • district = денорм-колонка рана → fallback analyze["district"]["district_name"]; + • `build_site_finder_report(...)` → `report.as_dict()` → `persist_analysis_run(..., + schema_version=d["schema_version"], status="done", ...)`. + + Best-effort: ЛЮБОЙ сбой/долгий compute → logger.warning + return (НЕ exception — форсайт + может честно не собраться на тонких данных, GlitchTip-шум не нужен). Тогда отчёт, как и + раньше, выйдет без §4–§6 (part_b деградирует «нет данных»). + + Args: + db: SQLAlchemy session (та же, что у build_full_report — свою НЕ открываем). + cad: кадастровый номер участка. + analyze_row: Row analyze-рана (несёт денорм `district`). + analyze: persist-payload analyze-рана (district-fallback + competitors для сегмента). + """ + try: + # Lazy import — тяжёлый forecasting-стек не нужен на module-load (как concept-стек). + from app.services.forecasting.orchestrator import build_site_finder_report + + district = analyze_row.district or (analyze.get("district") or {}).get("district_name") + logger.info( + "build_full_report: холодный участок cad=%s — best-effort синхронный §22-форсайт " + "(district=%s horizons=%s)", + cad, + district, + _FORECAST_HORIZONS, + ) + report = build_site_finder_report( + db, + analyze=analyze, + cad_num=cad, + district=district, + horizons=_FORECAST_HORIZONS, + ) + d = report.as_dict() + new_id = persist_analysis_run( + db, + cad_num=cad, + result=d, + params={"horizon": 12, "source": "full-report-inline-forecast"}, + district=district, + confidence=(d.get("confidence") or {}).get("level"), + status="done", + schema_version=d["schema_version"], # "1.0" (SiteFinderReport._SCHEMA_VERSION) + created_by=None, + segment=(d.get("meta") or {}).get("segment"), + ) + logger.info("build_full_report: §22-форсайт посчитан inline cad=%s run_id=%s", cad, new_id) + except Exception: + # Форсайт может честно не собраться (тонкие данные / сбой §9.x-шва) или занять + # слишком долго — деградируем в отчёт без §4–§6 (warning, НЕ exception: держим + # GlitchTip-шум в узде, ведёт себя как ленивая Celery-таска, которая тоже best-effort). + logger.warning( + "build_full_report: inline §22-форсайт не собрался cad=%s → отчёт без §4–§6", + cad, + exc_info=True, + ) + + def _find_cached_report( db: Session, cad: str, @@ -286,6 +370,14 @@ def build_full_report(db: Session, cad: str) -> dict[str, Any]: analyze_run_id = int(analyze_row.id) forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION) + if forecast_row is None: + # Холодный участок: §22-форсайт-ран ("1.0") строится лениво GET /forecast-поллингом + # и на не-открытом участке отсутствует → отчёт выходил без §4–§6. Best-effort + # считаем его СИНХРОННО прямо тут (мы в worker'е) и перечитываем — самодостаточность + # отчёта важнее +20–30с (как §7-концепция генерится в оркестраторе). Сбой/долго → + # forecast_row остаётся None, part_b деградирует «нет данных» (как раньше). + _ensure_forecast_run(db, cad, analyze_row, analyze) + forecast_row = latest_run_for(db, cad, schema_version=_FORECAST_SCHEMA_VERSION) forecast: dict[str, Any] = (forecast_row.result or {}) if forecast_row is not None else {} forecast_run_id = int(forecast_row.id) if forecast_row is not None else None diff --git a/backend/tests/services/exporters/test_full_report_pdf.py b/backend/tests/services/exporters/test_full_report_pdf.py index 476d1b7e..74bc0ad7 100644 --- a/backend/tests/services/exporters/test_full_report_pdf.py +++ b/backend/tests/services/exporters/test_full_report_pdf.py @@ -84,6 +84,9 @@ def _patch_renders(monkeypatch: pytest.MonkeyPatch, pdf_mock: MagicMock) -> None # connection-capacity + концепция — заглушаем на None (best-effort, отдельные тесты ниже). monkeypatch.setattr(mod, "_get_connection_capacity", lambda db, cad: None) monkeypatch.setattr(mod, "_generate_concept_result", lambda db, analyze: None) + # inline §22-форсайт (best-effort synchronous compute) — по умолчанию no-op, чтобы + # тяжёлый orchestrator не тянулся; поведение «холодного» участка тестируем отдельно ниже. + monkeypatch.setattr(mod, "_ensure_forecast_run", lambda db, cad, row, analyze: None) def test_no_analyze_run_raises(monkeypatch: pytest.MonkeyPatch) -> None: @@ -126,13 +129,55 @@ def test_happy_path_writes_file_and_persists( assert kwargs["result"]["forecast_run_id"] == 202 -def test_no_forecast_run_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Нет forecast-рана → forecast_run_id=None, Part B из пустого форсайта (не падает).""" +def test_no_forecast_run_triggers_inline_generation( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Холодный участок: forecast-рана нет → _ensure_forecast_run вызван → ран перечитан. + + Генератор (mock) «персистит» §22-ран, поэтому повторный latest_run_for("1.0") ловит + свежий forecast_row → его id попадает в отчёт и кэш-ключ (самодостаточность §4–§6). + """ + analyze_row = _make_row(101, _analyze_result()) + forecast_row = _make_row(777, _forecast_result()) # «появляется» после генерации + rows: dict[str | None, MagicMock | None] = { + "analyze-1.0": analyze_row, + "1.0": None, # первый read: рана ещё нет + mod.REPORT_SCHEMA_VERSION: None, + } + table = _RunTable(rows) + monkeypatch.setattr(mod, "latest_run_for", table) + monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path)) + _patch_renders(monkeypatch, MagicMock(return_value=b"%PDF")) + monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1)) + + def _appear(db: Any, cad: str, row: Any, analyze: Any) -> None: + rows["1.0"] = forecast_row # генератор «персистит» § 22-ран + + ensure_mock = MagicMock(side_effect=_appear) + monkeypatch.setattr(mod, "_ensure_forecast_run", ensure_mock) + + result = mod.build_full_report(MagicMock(), _CAD) + + # Генератор вызван ровно раз с cad + analyze-row (для district-fallback); ран перечитан. + ensure_mock.assert_called_once() + call_args, _ = ensure_mock.call_args + assert call_args[1] == _CAD # cad + assert call_args[2] is analyze_row # analyze_row + assert result["forecast_run_id"] == 777 + assert result["analyze_run_id"] == 101 + + +def test_inline_forecast_failure_degrades(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Inline-форсайт ничего не собрал (сбой/тонкие данные) → отчёт без §4–§6. + + _ensure_forecast_run best-effort (сам глотает исключение → warning), поэтому здесь он + просто no-op: повторный latest_run_for("1.0") снова None → forecast_run_id=None, + Part B из пустого форсайта (отчёт валиден, как до фикса на холодном участке).""" analyze_row = _make_row(101, _analyze_result()) table = _RunTable({"analyze-1.0": analyze_row, "1.0": None, mod.REPORT_SCHEMA_VERSION: None}) monkeypatch.setattr(mod, "latest_run_for", table) monkeypatch.setattr(mod.settings, "reports_dir", str(tmp_path)) - _patch_renders(monkeypatch, MagicMock(return_value=b"%PDF")) + _patch_renders(monkeypatch, MagicMock(return_value=b"%PDF")) # ставит _ensure no-op monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1)) result = mod.build_full_report(MagicMock(), _CAD) @@ -258,6 +303,79 @@ def test_connection_capacity_failure_is_best_effort(monkeypatch: pytest.MonkeyPa assert mod._get_connection_capacity(MagicMock(), _CAD) is None +def test_ensure_forecast_run_computes_and_persists(monkeypatch: pytest.MonkeyPatch) -> None: + """_ensure_forecast_run: считает §22 через orchestrator и персистит как Celery-таска. + + Сверка с forecast_site_finder_report: build_site_finder_report(horizons=[6,12,18,24]) → + report.as_dict() → persist_analysis_run(schema_version="1.0", status="done", ...). + """ + db = MagicMock() + analyze_row = _make_row(101, _analyze_result(), district="Кировский") + report = MagicMock() + report.as_dict.return_value = { + "schema_version": "1.0", + "confidence": {"level": "medium"}, + "meta": {"segment": {"obj_class": "comfort"}}, + } + build_mock = MagicMock(return_value=report) + persist_mock = MagicMock(return_value=888) + monkeypatch.setattr( + "app.services.forecasting.orchestrator.build_site_finder_report", build_mock + ) + monkeypatch.setattr(mod, "persist_analysis_run", persist_mock) + + mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result()) + + # build_site_finder_report вызван с дефолт-горизонтами таски и district из денорм-колонки. + build_mock.assert_called_once() + _, bkwargs = build_mock.call_args + assert bkwargs["cad_num"] == _CAD + assert bkwargs["district"] == "Кировский" + assert list(bkwargs["horizons"]) == [6, 12, 18, 24] + # persist той же схемой "1.0" + confidence/segment из as_dict. + persist_mock.assert_called_once() + _, pkwargs = persist_mock.call_args + assert pkwargs["schema_version"] == "1.0" + assert pkwargs["status"] == "done" + assert pkwargs["confidence"] == "medium" + assert pkwargs["segment"] == {"obj_class": "comfort"} + + +def test_ensure_forecast_run_district_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + """district=None в денорм-колонке → fallback на analyze['district']['district_name'].""" + db = MagicMock() + analyze_row = _make_row(101, _analyze_result(), district=None) + analyze = {**_analyze_result(), "district": {"district_name": "Ленинский"}} + report = MagicMock() + report.as_dict.return_value = {"schema_version": "1.0"} + build_mock = MagicMock(return_value=report) + monkeypatch.setattr( + "app.services.forecasting.orchestrator.build_site_finder_report", build_mock + ) + monkeypatch.setattr(mod, "persist_analysis_run", MagicMock(return_value=1)) + + mod._ensure_forecast_run(db, _CAD, analyze_row, analyze) + + _, bkwargs = build_mock.call_args + assert bkwargs["district"] == "Ленинский" + + +def test_ensure_forecast_run_failure_is_best_effort(monkeypatch: pytest.MonkeyPatch) -> None: + """orchestrator бросает → _ensure_forecast_run глотает (warning, НЕ пробрасывает).""" + db = MagicMock() + analyze_row = _make_row(101, _analyze_result()) + monkeypatch.setattr( + "app.services.forecasting.orchestrator.build_site_finder_report", + MagicMock(side_effect=RuntimeError("§9.x seam failed")), + ) + persist_mock = MagicMock() + monkeypatch.setattr(mod, "persist_analysis_run", persist_mock) + + # Не должно бросить наружу — best-effort деградация в отчёт без §4–§6. + mod._ensure_forecast_run(db, _CAD, analyze_row, _analyze_result()) + persist_mock.assert_not_called() # compute упал ДО персиста + + _MULTIPOLYGON = { "type": "MultiPolygon", "coordinates": [