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
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:
parent
e0fff9cfb0
commit
dd1443045e
4 changed files with 48 additions and 29 deletions
|
|
@ -2861,28 +2861,15 @@ def analyze_parcel(
|
||||||
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
|
# (repository.py:140) ДО этих мутаций, и сохранённый ран расходится с live-ответом
|
||||||
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
|
# (GET /runs/{run_id} вернул бы отчёт без этих блоков при re-open).
|
||||||
|
|
||||||
# §22-форсайт (3b-ii, #995): best-effort fire-and-forget enqueue. Таска
|
# §22-форсайт (3b-ii, #995): снимок статуса в result_payload ДО persist, чтобы
|
||||||
# `forecast_site_finder_report` в фоне (~30-180s) читает persisted analyze-1.0 ран
|
# jsonb-снимок совпадал с live-ответом. Оптимистично ставим "pending" — фактический
|
||||||
# (latest_run_for на момент исполнения, не на момент enqueue) и считает §22
|
# enqueue делаем ПОСЛЕ persist_analysis_run (ниже): иначе Celery-воркер может стартануть
|
||||||
# SiteFinderReport ('1.0'). analyze НЕ ждёт её — возвращаемся сразу. Celery/Redis down
|
# ДО коммита analyze-рана и latest_run_for вернёт None/старый ран → форсайт молча не
|
||||||
# НЕ должен валить ответ (он уже успешен: frontend зависит от формы). Зеркалит
|
# посчитается, а ретраев у таски нет (regression #1561-followup). result_payload
|
||||||
# best-effort стиль find_or_enqueue_fetch. Lazy import — избегаем import-цикла
|
# передаётся в persist by-reference; если enqueue провалится после persist —
|
||||||
# api ↔ workers.tasks на старте.
|
# перепишем снимок на "unavailable" уже только в возвращаемом ответе (persisted
|
||||||
try:
|
# снимок останется "pending", но это безвредно: poll-ручка читает live-статус рана).
|
||||||
from app.workers.tasks.forecast import forecast_site_finder_report
|
result_payload["forecast"] = {"status": "pending", "horizon": horizon}
|
||||||
|
|
||||||
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}
|
|
||||||
|
|
||||||
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
# ИРД-слой (#1067 D9b «GG-форсайт»): parcel_ird_overlaps (м.132, incl opportunity) +
|
||||||
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
# функц.зона/КРТ (геопортал WFS) + ПЗЗ-регламент зоны (C8b). Flag-gated (default off):
|
||||||
|
|
@ -2950,6 +2937,30 @@ def analyze_parcel(
|
||||||
created_by=x_authenticated_user,
|
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
|
return result_payload
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ def _build_html(estimate: AggregatedEstimate, input_snapshot: dict) -> str: # t
|
||||||
<div class="badge"
|
<div class="badge"
|
||||||
style="background:{conf_bg}; color:{conf_fg}; border:1px solid {conf_border};">
|
style="background:{conf_bg}; color:{conf_fg}; border:1px solid {conf_border};">
|
||||||
Достоверность: {conf_label}<br>
|
Достоверность: {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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -408,14 +408,19 @@ def enqueue_cadastre_harvest(self: Any, job_id: int) -> dict[str, Any]:
|
||||||
# skipped_fresh повторно. Поэтому:
|
# skipped_fresh повторно. Поэтому:
|
||||||
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
|
# • targets_skipped — идемпотентный SET (= число свежих сейчас),
|
||||||
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
|
# а не cumulative INCREMENT (иначе раздувается на каждый resume);
|
||||||
# • targets_total — GREATEST с уже учтённым прогрессом
|
# • targets_total — :new_total это только ОСТАВШИЕСЯ к обработке
|
||||||
# (targets_done + targets_failed), чтобы total не сжался ниже
|
# кварталы (len(quarters) − skipped_fresh). При resume уже
|
||||||
# done → не вызвал преждевременный 'done' в _maybe_finish_job.
|
# обработанные попадают в skipped_fresh, поэтому к остатку
|
||||||
|
# добавляем уже учтённый прогресс (done + failed), иначе total
|
||||||
|
# занижается и _maybe_finish_job помечает job 'done' после первого
|
||||||
|
# доработанного квартала (#1654-followup). GREATEST с самим
|
||||||
|
# прогрессом сохраняет монотонность (total не уменьшается).
|
||||||
db.execute(
|
db.execute(
|
||||||
text(
|
text(
|
||||||
"UPDATE cadastre_jobs SET "
|
"UPDATE cadastre_jobs SET "
|
||||||
"targets_total = GREATEST("
|
"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 "
|
"targets_skipped = :sk "
|
||||||
"WHERE job_id = :id"
|
"WHERE job_id = :id"
|
||||||
|
|
|
||||||
|
|
@ -269,11 +269,14 @@ class TestPaymentAtScenario:
|
||||||
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
res = _run(price_per_m2=120_000.0, rate_path={6: 8.0, 12: 20.0})
|
||||||
assert res.payment_at_scenario is not None
|
assert res.payment_at_scenario is not None
|
||||||
principal = 120_000.0 * _REF_AREA_M2
|
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(
|
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(
|
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]
|
assert res.payment_at_scenario[12] > res.payment_at_scenario[6]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue