fix(tradein/estimate): оживлять мёртвые сохранённые оценки при открытии #2826

Merged
lekss361 merged 1 commit from fix/tradein-revive-dead-estimates into main 2026-08-11 04:15:41 +00:00
Owner

Инцидент

Заказчик прислал в поддержку ссылку на сохранённую оценку ?id=ff421062-... и видит «НЕДОСТАТОЧНО ДАННЫХ». Запись создана до #2823/#2825 и лежит в БД мёртвой (median_price=0, n_analogs=0) со старым текстом объяснения — «минимум для оценки по рынку — 5», формулировкой, которой в коде уже нет.

Новый расчёт по тем же входным данным сейчас даёт 4 031 157 ₽ / 39 аналогов. То есть оценка починена, а старая ссылка навсегда показывает труп. Таких строк в базе 117 из 1071 (29 за последние 30 дней, самая старая 2026-05-29).

Summary

  • GET оценки: если строка мертва (median_price <= 0/NULL) — пересчёт из сохранённых входных данных с записью результата в ту же строку, id и ссылка не меняются. Строка с реальной ценой не трогается никогда — это сохранённый документ клиента.
  • Гонка и шторм: атомарный UPDATE ... WHERE id=... AND (median_price<=0 OR NULL) AND (revival_attempted_at IS NULL OR < NOW()-interval) RETURNING id — проверки «всё ещё мертва» и «давно не пробовали» выполняются в БД, без TOCTOU между параллельными GET. Окно троттлинга 10 мин (trade_in_revival_throttle_minutes). Временная строка, которую всегда создаёт estimate_quality под новым uuid, удаляется, а avito_imv_evaluations.estimate_id перепривязан к оригиналу до удаления — иначе ON DELETE CASCADE снёс бы только что записанный IMV-кэш.
  • Отказоустойчивость: любая ошибка пересчёта (геокодер, сеть, битые legacy-значения house_type/repair_state) → logger.exception и отдача сохранённой строки. GET не отдаёт 500.
  • Персист relaxations/reliability (миграция 255_*, ADD COLUMN IF NOT EXISTS, lock_timeout): раньше при возврате к сохранённой оценке пропадал красный дисклеймер, хотя цена построена на расширенной выборке. PDF читает их так же.
  • Радиус в UI: добавлены 3000 и 5000 м (бэкенд принимал до 5000) + подсказка, что «Авто» расширяет поиск, а явный радиус его ограничивает. Заказчик выбрал «мах 2000», тем самым отрезав шаги каскада на 3 и 5 км — контракт #2044 запрещает выходить за выбранный радиус.

Test plan

  • Полный бэкенд-прогон: 4197 passed, 18 skipped (+10 новых, базовая линия 4187)
  • ruff check / ruff format — чисто, pre-commit зелёный
  • tsc --noEmit, eslint, npm run build — чисто
  • Новые тесты: мёртвая строка оживает и пишется в БД; живая не трогается; троттлинг не даёт повторную попытку; исключение при пересчёте → graceful fallback; пересчёт снова в ноль → fallback + уборка временной строки; битый legacy house_type → fallback; round-trip relaxations/reliability; два guard-теста на то, что оба INSERT-пути реально пишут новые колонки
  • Проверено вручную: get_estimate объявлен как def, а не async defasyncio.run() внутри корректен
  • Post-deploy: открыть ссылку заказчика ?id=ff421062-... и убедиться, что вместо заглушки появилась цена
## Инцидент Заказчик прислал в поддержку ссылку на **сохранённую** оценку `?id=ff421062-...` и видит «НЕДОСТАТОЧНО ДАННЫХ». Запись создана до #2823/#2825 и лежит в БД мёртвой (`median_price=0`, `n_analogs=0`) со старым текстом объяснения — «минимум для оценки по рынку — 5», формулировкой, которой в коде уже нет. Новый расчёт по тем же входным данным сейчас даёт **4 031 157 ₽ / 39 аналогов**. То есть оценка починена, а старая ссылка навсегда показывает труп. Таких строк в базе **117 из 1071** (29 за последние 30 дней, самая старая 2026-05-29). ## Summary - **GET оценки**: если строка мертва (`median_price <= 0`/NULL) — пересчёт из сохранённых входных данных с записью результата **в ту же строку**, id и ссылка не меняются. Строка с реальной ценой не трогается никогда — это сохранённый документ клиента. - **Гонка и шторм**: атомарный `UPDATE ... WHERE id=... AND (median_price<=0 OR NULL) AND (revival_attempted_at IS NULL OR < NOW()-interval) RETURNING id` — проверки «всё ещё мертва» и «давно не пробовали» выполняются в БД, без TOCTOU между параллельными GET. Окно троттлинга 10 мин (`trade_in_revival_throttle_minutes`). Временная строка, которую всегда создаёт `estimate_quality` под новым uuid, удаляется, а `avito_imv_evaluations.estimate_id` перепривязан к оригиналу **до** удаления — иначе `ON DELETE CASCADE` снёс бы только что записанный IMV-кэш. - **Отказоустойчивость**: любая ошибка пересчёта (геокодер, сеть, битые legacy-значения `house_type`/`repair_state`) → `logger.exception` и отдача сохранённой строки. GET не отдаёт 500. - **Персист `relaxations`/`reliability`** (миграция `255_*`, `ADD COLUMN IF NOT EXISTS`, `lock_timeout`): раньше при возврате к сохранённой оценке пропадал красный дисклеймер, хотя цена построена на расширенной выборке. PDF читает их так же. - **Радиус в UI**: добавлены 3000 и 5000 м (бэкенд принимал до 5000) + подсказка, что «Авто» расширяет поиск, а явный радиус его ограничивает. Заказчик выбрал «мах 2000», тем самым отрезав шаги каскада на 3 и 5 км — контракт #2044 запрещает выходить за выбранный радиус. ## Test plan - [x] Полный бэкенд-прогон: **4197 passed, 18 skipped** (+10 новых, базовая линия 4187) - [x] `ruff check` / `ruff format` — чисто, pre-commit зелёный - [x] `tsc --noEmit`, `eslint`, `npm run build` — чисто - [x] Новые тесты: мёртвая строка оживает и пишется в БД; живая не трогается; троттлинг не даёт повторную попытку; исключение при пересчёте → graceful fallback; пересчёт снова в ноль → fallback + уборка временной строки; битый legacy `house_type` → fallback; round-trip `relaxations`/`reliability`; два guard-теста на то, что оба INSERT-пути реально пишут новые колонки - [x] Проверено вручную: `get_estimate` объявлен как `def`, а не `async def` — `asyncio.run()` внутри корректен - [ ] Post-deploy: открыть ссылку заказчика `?id=ff421062-...` и убедиться, что вместо заглушки появилась цена
lekss361 added 1 commit 2026-08-11 04:09:11 +00:00
fix(tradein/estimate): оживлять мёртвые сохранённые оценки при открытии
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Successful in 4m31s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m20s
3e809321e9
Заказчик прислал в поддержку ссылку на сохранённую оценку и увидел
«НЕДОСТАТОЧНО ДАННЫХ». Запись создана до фиксов #2823/#2825 и лежит в БД
мёртвой (median_price=0) со старым текстом объяснения, хотя новый расчёт
по тем же входным данным даёт 4 031 157 ₽. Таких строк 117 из 1071.

- GET оценки: если строка мертва (median_price <= 0/NULL) — пересчёт из
  сохранённых входных данных с записью результата в ТУ ЖЕ строку, id и
  ссылка не меняются. Строку с реальной ценой не трогаем никогда — это
  сохранённый документ клиента.
- Защита от шторма и гонки: атомарный UPDATE с проверкой «всё ещё мертва»
  и «давно не пробовали» прямо в БД (revival_attempted_at, окно 10 мин),
  без TOCTOU между параллельными GET. Временная строка, которую создаёт
  estimate_quality, удаляется, avito_imv_evaluations перепривязан до
  удаления — иначе каскад снёс бы только что записанный кэш.
- Любая ошибка пересчёта → отдаём сохранённую строку и logger.exception,
  GET не падает.
- relaxations / reliability теперь персистятся (миграция 255_*, idempotent)
  — раньше при возврате к сохранённой оценке пропадал красный дисклеймер,
  хотя цена построена на расширенной выборке. PDF читает их так же.
- Радиус в UI: добавлены 3000 и 5000 м (бэкенд принимал до 5000) плюс
  подсказка, что «Авто» расширяет поиск, а явный радиус ограничивает —
  заказчик выбрал «мах 2000», тем самым отрезав шаги каскада на 3 и 5 км.
lekss361 merged commit 82d8db9f42 into main 2026-08-11 04:15:41 +00:00
lekss361 deleted branch fix/tradein-revive-dead-estimates 2026-08-11 04:15:41 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2826
No description provided.