fix(backend): исправить 4 регрессии code-review (PR #1660 follow-up)
Some checks failed
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (push) Successful in 8s
CI / openapi-codegen-check (pull_request) Successful in 2m10s
CI / openapi-codegen-check (push) Successful in 2m14s
CI / backend-tests (push) Failing after 9m10s
CI / backend-tests (pull_request) Failing after 9m12s

- parcels: forecast_site_finder_report.delay() ПОСЛЕ persist_analysis_run
  (был commit-before-enqueue race → воркер видел None/stale ран, ретраев нет)
- test_affordability: ожидания сценарного платежа с _KEY_RATE_MARKET_SPREAD_PP
  (правка спреда ломала test_scenario_payment_per_horizon → красный gate)
- scrape_cadastre: targets_total += уже выполненное при resume (skipped_fresh
  кварталы не должны занижать total → преждевременный 'done')
- trade_in_pdf: бейдж достоверности = n_analogs (полная статбаза), не len(analogs)
This commit is contained in:
Light1YT 2026-06-17 14:33:43 +05:00
parent e0fff9cfb0
commit dd1443045e
4 changed files with 48 additions and 29 deletions

View file

@ -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

View file

@ -257,7 +257,7 @@ def _build_html(estimate: AggregatedEstimate, input_snapshot: dict) -> str: # t
<div class="badge"
style="background:{conf_bg}; color:{conf_fg}; border:1px solid {conf_border};">
Достоверность: {conf_label}<br>
<span style="font-size:8pt; font-weight:400;">{len(estimate.analogs)} аналогов</span>
<span style="font-size:8pt; font-weight:400;">{estimate.n_analogs} аналогов</span>
</div>
</div>

View file

@ -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"

View file

@ -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]