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
Достоверность: {conf_label}
- {len(estimate.analogs)} аналогов + {estimate.n_analogs} аналогов
diff --git a/backend/app/workers/tasks/scrape_cadastre.py b/backend/app/workers/tasks/scrape_cadastre.py index 63d6f097..bee2adeb 100644 --- a/backend/app/workers/tasks/scrape_cadastre.py +++ b/backend/app/workers/tasks/scrape_cadastre.py @@ -408,14 +408,19 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]: # skipped_fresh повторно. Поэтому: # • targets_skipped — идемпотентный SET (= число свежих сейчас), # а не cumulative INCREMENT (иначе раздувается на каждый resume); - # • targets_total — GREATEST с уже учтённым прогрессом - # (targets_done + targets_failed), чтобы total не сжался ниже - # done → не вызвал преждевременный 'done' в _maybe_finish_job. + # • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке + # кварталы (len(quarters) − skipped_fresh). При resume уже + # обработанные попадают в skipped_fresh, поэтому к остатку + # добавляем уже учтённый прогресс (done + failed), иначе total + # занижается и _maybe_finish_job помечает job 'done' после первого + # доработанного квартала (#1654-followup). GREATEST с самим + # прогрессом сохраняет монотонность (total не уменьшается). db.execute( text( "UPDATE cadastre_jobs SET " "targets_total = GREATEST(" - ":new_total, COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)" + ":new_total + COALESCE(targets_done, 0) + COALESCE(targets_failed, 0), " + "COALESCE(targets_done, 0) + COALESCE(targets_failed, 0)" "), " "targets_skipped = :sk " "WHERE job_id = :id" diff --git a/backend/tests/services/forecasting/test_affordability.py b/backend/tests/services/forecasting/test_affordability.py index 2e3dd4f6..153cdba0 100644 --- a/backend/tests/services/forecasting/test_affordability.py +++ b/backend/tests/services/forecasting/test_affordability.py @@ -269,11 +269,14 @@ class TestPaymentAtScenario: res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0}) assert res.payment_at_scenario is not None principal = 120_000.0 * _REF_AREA_M2 + # rate_path несёт КЛЮЧЕВУЮ ставку сценария; affordability приводит к рыночной + # базе (+ _KEY_RATE_MARKET_SPREAD_PP), как и monthly_payment_rub (#1639). Ожидания + # выражаем символически — тест переживёт перекалибровку спреда. assert res.payment_at_scenario[6] == pytest.approx( - _annuity(principal, 8.0, _ANNUITY_TERM_MONTHS) + _annuity(principal, 8.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS) ) assert res.payment_at_scenario[12] == pytest.approx( - _annuity(principal, 20.0, _ANNUITY_TERM_MONTHS) + _annuity(principal, 20.0 + _KEY_RATE_MARKET_SPREAD_PP, _ANNUITY_TERM_MONTHS) ) # Выше ставка → выше платёж на этом горизонте. assert res.payment_at_scenario[12] > res.payment_at_scenario[6]