fix(tradein/estimate): оживлять мёртвые сохранённые оценки при открытии (#2826)
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 3m11s
Deploy Trade-In / test (push) Successful in 3m42s
Deploy Trade-In / build-backend (push) Successful in 1m8s
Deploy Trade-In / deploy (push) Successful in 6m59s

This commit is contained in:
lekss361 2026-08-11 04:15:40 +00:00
parent 0b84f0b661
commit 82d8db9f42
7 changed files with 935 additions and 3 deletions

View file

@ -5,7 +5,9 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import calendar import calendar
import json
import logging import logging
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any from typing import Annotated, Any
@ -167,6 +169,228 @@ def _resolve_target_house_id(
return None return None
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────────
# Заказчик открыл сохранённую ссылку (?id=...) и увидел «НЕДОСТАТОЧНО ДАННЫХ»:
# запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825) и
# лежит в БД мёртвой (median_price<=0/NULL), хотя тот же адрес/параметры
# сейчас честно считаются. get_estimate() ниже пытается пересчитать такую
# строку ОДИН раз (throttled) через тот же estimate_quality(), что и POST
# /estimate, и пишет результат В ТУ ЖЕ строку (id/ссылка не меняются). Живую
# строку (median_price>0) этот путь не трогает вообще — сохранённая клиенту
# цена неприкосновенна.
def _precision_to_qc_geo(precision: str | None) -> int | None:
"""Best-effort обратное отображение к estimator._qc_geo_to_precision.
AggregatedEstimate наружу отдаёт только бакетированный address_precision
(house/street/approximate), не сырой dadata.qc_geo (0..5) тот остаётся
приватным для estimate_quality(). При revival нам нужно записать ЧТО-ТО в
колонку dadata_qc_geo, чтобы будущие (уже НЕ revival, обычные) GET той же
теперь-живой строки не откатили address_precision в None. Бакеты 2..5
(settlement/city/region/unknown) неразличимы ПОСЛЕ _qc_geo_to_precision
2 репрезентативно для всех: тот же helper на чтении схлопывает их обратно
в тот же "approximate", наблюдаемое поведение не меняется.
"""
if precision == "house":
return 0
if precision == "street":
return 1
if precision == "approximate":
return 2
return None
def _payload_from_dead_row(row: Any) -> TradeInEstimateInput:
"""Восстанавливает вход оценки из мёртвой сохранённой строки для revival.
Только поля, реально персистящиеся в trade_in_estimates при создании
(address/lat/lon/area_m2/rooms/floor/total_floors/year_built/house_type/
repair_state/has_balcony) CRM-only поля (ownership_type/has_mortgage)
на расчёт не влияют и не нужны здесь. radius_m НИКОГДА не персистится
(payload.radius_m живёт только в рамках одного POST-запроса, ни главный
INSERT, ни _empty_estimate его не пишут) None здесь даёт тот же
default-каскад (DEFAULT_RADIUS_M/FALLBACK_RADIUS_M), что у подавляющего
большинства сохранённых строк (явный радиус выбирает меньшинство).
consent=None + require_consent=False у вызывающего revival не новое
согласие физлица, а служебный recompute уже существующей записи.
"""
return TradeInEstimateInput(
address=row.address,
area_m2=float(row.area_m2),
rooms=row.rooms,
floor=row.floor,
total_floors=row.total_floors,
year_built=row.year_built,
house_type=row.house_type,
repair_state=row.repair_state,
has_balcony=row.has_balcony,
lat=row.lat,
lon=row.lon,
radius_m=None,
consent=None,
)
async def _try_revive_dead_estimate(
db: Session, estimate_id: UUID, row: Any
) -> AggregatedEstimate | None:
"""Пытается пересчитать «мёртвую» (median_price<=0/NULL) строку на месте.
Возвращает свежий AggregatedEstimate (estimate_id ПОДМЕНЁН на исходный
id/ссылка не меняются) при успехе; None если: (а) throttle ещё не истёк /
заявку уже забрал параллельный запрос anti-storm через атомарный
conditional `UPDATE ... RETURNING` ниже (тот же паттерн, что
account_quota.increment, #747): WHERE перепроверяет и «мертва ли строка
сейчас», и «давно ли последняя попытка» НЕПОСРЕДСТВЕННО в БД, а не по
значению, прочитанному раньше в Python TOCTOU-гонка между двумя
параллельными GET невозможна, проигравший просто не дублирует работу;
(б) пересчёт сам дал 0 (по-прежнему недостаточно данных); (в) пересчёт
упал с исключением (сеть/геокод/что угодно). Во всех трёх случаях caller
обязан отдать сохранённую (по-прежнему мёртвую) строку как раньше НЕ 500.
"""
claim = db.execute(
text(
"""
UPDATE trade_in_estimates
SET revival_attempted_at = NOW()
WHERE id = CAST(:id AS uuid)
AND (median_price <= 0 OR median_price IS NULL)
AND (
revival_attempted_at IS NULL
OR revival_attempted_at
< NOW() - make_interval(mins => CAST(:throttle AS integer))
)
RETURNING id
"""
),
{"id": str(estimate_id), "throttle": settings.trade_in_revival_throttle_minutes},
).fetchone()
db.commit()
if claim is None:
logger.info("estimate revival throttled/lost race: id=%s", estimate_id)
return None
from app.services.estimator import estimate_quality
try:
payload = _payload_from_dead_row(row)
result = await estimate_quality(
payload,
db,
created_by=row.created_by,
client_ip=None,
require_consent=False,
)
except Exception:
logger.exception("estimate revival failed: id=%s address=%r", estimate_id, row.address)
return None
temp_id = result.estimate_id
if result.median_price_rub <= 0:
logger.info("estimate revival still insufficient data: id=%s", estimate_id)
db.execute(
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(temp_id)},
)
db.commit()
return None
# estimate_quality() persists under a BRAND NEW uuid (temp_id) — it has no
# notion of "recompute this existing row". Copy the computed OUTPUT fields
# into the ORIGINAL row (id/link contract), then drop the throwaway one.
# INPUT snapshot (address/area/rooms/...) is untouched — it did not change,
# only the outputs were recomputed.
db.execute(
text(
"""
UPDATE avito_imv_evaluations
SET estimate_id = CAST(:orig AS uuid)
WHERE estimate_id = CAST(:temp AS uuid)
"""
),
{"orig": str(estimate_id), "temp": str(temp_id)},
)
db.execute(
text(
"""
UPDATE trade_in_estimates SET
median_price = :median_price,
range_low = :range_low,
range_high = :range_high,
median_price_per_m2 = :median_ppm2,
confidence = :confidence,
confidence_explanation = :explanation,
n_analogs = :n_analogs,
analogs = CAST(:analogs_json AS jsonb),
actual_deals = CAST(:deals_json AS jsonb),
sources_used = CAST(:sources_json AS jsonb),
data_freshness_minutes = :freshness,
canonical_address = :canonical_address,
house_cadnum = :house_cadnum,
house_fias_id = :house_fias_id,
dadata_qc_geo = :dadata_qc_geo,
dadata_metro = CAST(:dadata_metro_json AS jsonb),
expected_sold_price = :expected_sold_price,
expected_sold_range_low = :expected_sold_range_low,
expected_sold_range_high = :expected_sold_range_high,
expected_sold_per_m2 = :expected_sold_per_m2,
asking_to_sold_ratio = :asking_to_sold_ratio,
ratio_basis = :ratio_basis,
relaxations = CAST(:relaxations_json AS jsonb),
reliability = :reliability,
created_at = :created_at
WHERE id = CAST(:id AS uuid)
"""
),
{
"id": str(estimate_id),
"median_price": result.median_price_rub,
"range_low": result.range_low_rub,
"range_high": result.range_high_rub,
"median_ppm2": result.median_price_per_m2,
"confidence": result.confidence,
"explanation": result.confidence_explanation,
"n_analogs": result.n_analogs,
"analogs_json": json.dumps(
[a.model_dump(mode="json") for a in result.analogs], ensure_ascii=False
),
"deals_json": json.dumps(
[a.model_dump(mode="json") for a in result.actual_deals], ensure_ascii=False
),
"sources_json": json.dumps(result.sources_used, ensure_ascii=False),
"freshness": result.data_freshness_minutes,
"canonical_address": result.canonical_address,
"house_cadnum": result.house_cadnum,
"house_fias_id": result.house_fias_id,
"dadata_qc_geo": _precision_to_qc_geo(result.address_precision),
"dadata_metro_json": json.dumps(result.metro_nearest, ensure_ascii=False),
"expected_sold_price": result.expected_sold_price_rub,
"expected_sold_range_low": result.expected_sold_range_low_rub,
"expected_sold_range_high": result.expected_sold_range_high_rub,
"expected_sold_per_m2": result.expected_sold_per_m2,
"asking_to_sold_ratio": result.asking_to_sold_ratio,
"ratio_basis": result.ratio_basis,
"relaxations_json": json.dumps(result.relaxations, ensure_ascii=False),
"reliability": result.reliability,
"created_at": result.created_at,
},
)
db.execute(
text("DELETE FROM trade_in_estimates WHERE id = CAST(:id AS uuid)"),
{"id": str(temp_id)},
)
db.commit()
logger.info(
"estimate revived: id=%s median=%d n=%d confidence=%s reliability=%s",
estimate_id,
result.median_price_rub,
result.n_analogs,
result.confidence,
result.reliability,
)
return result.model_copy(update={"estimate_id": estimate_id})
@router.post("/estimate", response_model=AggregatedEstimate) @router.post("/estimate", response_model=AggregatedEstimate)
async def estimate( async def estimate(
payload: TradeInEstimateInput, payload: TradeInEstimateInput,
@ -281,7 +505,8 @@ def get_estimate(
dadata_qc_geo, dadata_metro, dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low, expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2, expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by, created_at asking_to_sold_ratio, ratio_basis, created_by, created_at,
relaxations, reliability
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
AND {ESTIMATE_READABLE_SQL} AND {ESTIMATE_READABLE_SQL}
@ -295,6 +520,22 @@ def get_estimate(
_assert_estimate_access(row.created_by, x_authenticated_user) _assert_estimate_access(row.created_by, x_authenticated_user)
# #incident-2026-08-10: строка «мертва» (median_price<=0/NULL) — посчитана
# ДО фикса оценщика (#oblast-E/#oblast-F, PR #2823/#2825). Пробуем
# пересчитать её на месте (throttled, race-safe — см. докстринг
# _try_revive_dead_estimate) через тот же путь, что и POST /estimate.
# Живую строку (median_price>0) не трогаем вообще. asyncio.run() — sync↔
# async мост (тот же паттерн, что app/scheduler_main.py): get_estimate
# остаётся `def` (Starlette гоняет его в threadpool, как сейчас), поэтому
# ОСТАЛЬНЫЕ синхронные db.execute() ниже по функции не переезжают на event
# loop — только сама попытка revival временно занимает свой поток на время
# await estimate_quality(). Любая ошибка расчёта — не 500: revived is None,
# и функция просто продолжает как раньше, отдавая сохранённую строку.
if row.median_price is None or row.median_price <= 0:
revived = asyncio.run(_try_revive_dead_estimate(db, estimate_id, row))
if revived is not None:
return revived
from app.services.estimator import ( from app.services.estimator import (
_canonical_sources, _canonical_sources,
_cv_from_ppm2, _cv_from_ppm2,
@ -431,6 +672,14 @@ def get_estimate(
cv=cv, cv=cv,
source_counts=source_counts, source_counts=source_counts,
created_at=row.created_at, created_at=row.created_at,
# PR #2823 open follow-up (fixed incident 2026-08-10, migration 255):
# relaxations/reliability теперь персистятся — GET-rehydrate больше не
# теряет красный баннер «точность снижена» при открытии по ссылке.
# getattr defensive: старые in-memory test doubles / любая строка без
# этих колонок (не должно случаться после миграции) деградируют в
# дефолт схемы (ok / []), а не падают AttributeError.
relaxations=list(getattr(row, "relaxations", None) or []),
reliability=getattr(row, "reliability", None) or "ok",
) )
@ -462,7 +711,8 @@ def estimate_pdf(
dadata_qc_geo, dadata_metro, dadata_qc_geo, dadata_metro,
expected_sold_price, expected_sold_range_low, expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2, expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, created_by asking_to_sold_ratio, ratio_basis, created_by,
relaxations, reliability
FROM trade_in_estimates FROM trade_in_estimates
WHERE id = CAST(:id AS uuid) WHERE id = CAST(:id AS uuid)
""" """
@ -523,6 +773,10 @@ def estimate_pdf(
house_fias_id=row.house_fias_id, house_fias_id=row.house_fias_id,
address_precision=_qc_geo_to_precision(row.dadata_qc_geo), address_precision=_qc_geo_to_precision(row.dadata_qc_geo),
metro_nearest=(row.dadata_metro or []), metro_nearest=(row.dadata_metro or []),
# migration 255 — та же сноска «точность снижена», что и на JSON GET,
# теперь и в PDF-регенерации сохранённой оценки (см. get_estimate).
relaxations=list(getattr(row, "relaxations", None) or []),
reliability=getattr(row, "reliability", None) or "ok",
) )
input_snapshot = { input_snapshot = {
"address": row.address, "address": row.address,

View file

@ -852,6 +852,19 @@ class Settings(BaseSettings):
# «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS. # «год»". ENV: TRADE_IN_PAID_RETENTION_DAYS.
trade_in_paid_retention_days: int = 365 trade_in_paid_retention_days: int = 365
# ── Revival на GET /estimate/{id} (incident 2026-08-10) ─────────────────
# Throttle повторных попыток пересчёта «мёртвой» (median_price<=0/NULL)
# сохранённой строки — записи, посчитанные ДО фикса оценщика (#oblast-E/F,
# PR #2823/#2825) и навсегда застрявшие с median_price=0. GET пытается
# пересчитать такую строку через тот же estimate_quality(), что и POST
# (app/api/v1/trade_in.py::_try_revive_dead_estimate), не чаще одного раза
# в это число минут на строку — иначе каждый refresh страницы бил бы по
# геокодеру/DaData для объективно мёртвого адреса. 10 минут — компромисс:
# достаточно редко, чтобы не спамить внешние сервисы, достаточно быстро,
# чтобы повторный визит клиента после нашего фикса увидел живую цену. ENV:
# TRADE_IN_REVIVAL_THROTTLE_MINUTES.
trade_in_revival_throttle_minutes: int = 10
# Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним # Батч-размер физического DELETE в purge_expired_trade_in_data (нельзя одним
# DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама # DELETE по всей таблице — долгая блокировка на большом бэклоге). Задача сама
# крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) — # крутит цикл батчей за один прогон (см. _DEFAULT_MAX_BATCHES в таске) —

View file

@ -4370,6 +4370,7 @@ async def estimate_quality(
expected_sold_price, expected_sold_range_low, expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2, expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis, asking_to_sold_ratio, ratio_basis,
relaxations, reliability,
created_by, created_by,
expires_at, expires_at,
consent, client_ip, consent_policy_version, consent_text_snapshot consent, client_ip, consent_policy_version, consent_text_snapshot
@ -4391,6 +4392,7 @@ async def estimate_quality(
:expected_sold_price, :expected_sold_range_low, :expected_sold_price, :expected_sold_range_low,
:expected_sold_range_high, :expected_sold_per_m2, :expected_sold_range_high, :expected_sold_per_m2,
:asking_to_sold_ratio, :ratio_basis, :asking_to_sold_ratio, :ratio_basis,
CAST(:relaxations_json AS jsonb), :reliability,
:created_by, :created_by,
:expires_at, :expires_at,
:consent, CAST(:client_ip AS inet), :consent_policy_version, :consent, CAST(:client_ip AS inet), :consent_policy_version,
@ -4444,6 +4446,12 @@ async def estimate_quality(
"expected_sold_per_m2": expected_sold_per_m2, "expected_sold_per_m2": expected_sold_per_m2,
"asking_to_sold_ratio": asking_to_sold_ratio, "asking_to_sold_ratio": asking_to_sold_ratio,
"ratio_basis": ratio_basis, "ratio_basis": ratio_basis,
# #oblast-F / GET-rehydrate (PR #2823 open follow-up): персистим
# relaxations/reliability вместе с median — раньше эти поля жили
# только в POST-ответе, и красный баннер «точность снижена»
# пропадал при открытии оценки по сохранённой ссылке (?id=).
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
"reliability": reliability,
"created_by": created_by, "created_by": created_by,
"expires_at": expires_at, "expires_at": expires_at,
**_estimate_consent_persist_fields(require_consent, client_ip), **_estimate_consent_persist_fields(require_consent, client_ip),
@ -6885,6 +6893,7 @@ def _empty_estimate(
confidence, confidence_explanation, n_analogs, confidence, confidence_explanation, n_analogs,
analogs, actual_deals, analogs, actual_deals,
sources_used, sources_used,
relaxations, reliability,
created_by, created_by,
expires_at, expires_at,
consent, client_ip, consent_policy_version, consent_text_snapshot consent, client_ip, consent_policy_version, consent_text_snapshot
@ -6897,6 +6906,11 @@ def _empty_estimate(
'low', :explanation, 0, 'low', :explanation, 0,
'[]'::jsonb, '[]'::jsonb, '[]'::jsonb, '[]'::jsonb,
'[]'::jsonb, '[]'::jsonb,
-- #oblast-F: поиск аналогов вообще не выполнялся (geocode failed /
-- no coords) каскад послаблений не запускался, relaxations честно
-- пуст; reliability='very_low' зеркалит то, что возвращает Python
-- ниже (см. AggregatedEstimate(..., reliability="very_low")).
'[]'::jsonb, 'very_low',
:created_by, :created_by,
:expires_at, :expires_at,
:consent, CAST(:client_ip AS inet), :consent_policy_version, :consent, CAST(:client_ip AS inet), :consent_policy_version,

View file

@ -0,0 +1,77 @@
-- 255_trade_in_estimates_revival_relaxations.sql
-- Номер сверен по `ls data/sql | sort` (max applied = 254) непосредственно
-- перед коммитом — см. sql.md § file naming + tradein.md § collision trap
-- (108_*/084_* уже дублировались в прошлом).
--
-- ── Incident 2026-08-10: «мёртвые» сохранённые оценки ───────────────────────
-- Заказчик открыл сохранённую ссылку /trade-in/v2?id=... и увидел «НЕДОСТАТОЧНО
-- ДАННЫХ»: запись создана ДО фикса оценщика (#oblast-E/#oblast-F, PR
-- #2823/#2825) и лежит в БД с median_price=0/NULL, хотя тот же адрес и
-- параметры сейчас честно считаются (4 031 157 ₽ / 39 аналогов). 117 из 1071
-- строк trade_in_estimates находятся в этом состоянии (29 за последние 30
-- дней). GET /api/v1/trade-in/estimate/{id} (app/api/v1/trade_in.py::
-- _try_revive_dead_estimate) теперь пересчитывает такую строку на месте и
-- пишет результат В ТУ ЖЕ строку (id/ссылка не меняются) — этому нужны две
-- новые колонки:
--
-- revival_attempted_at — throttle: не пересчитывать чаще одного раза в N
-- минут (settings.trade_in_revival_throttle_minutes, default 10) на одну
-- строку. Заявка на пересчёт — атомарный conditional
-- `UPDATE ... WHERE revival_attempted_at IS NULL OR ... < NOW() - N min
-- RETURNING id` (тот же паттерн, что account_quota.increment, #747) —
-- защищает и от шторма повторных попыток на мёртвый адрес, и от гонки
-- двух параллельных GET (второй просто теряет заявку и отдаёт то, что
-- есть, без 500).
--
-- ── relaxations / reliability (открытый хвост PR #2823, найден post-deploy
-- 2026-08-10 — см. fixes/Fix_Mera_Studio_Not_Estimated_Never_Block_Aug10) ──
-- Обе колонки УЖЕ вычисляются в estimator.estimate_quality() и уходят в POST-
-- ответ (AggregatedEstimate.relaxations/reliability), но раньше НЕ
-- персистились — на GET-rehydrate (открытие сохранённой ссылки) красный
-- баннер «точность снижена» пропадал, хотя цена по-прежнему построена на
-- расширенной/тонкой выборке. Теперь пишутся при каждом (re)compute (основной
-- INSERT в estimate_quality() + этот revival-путь) и читаются на GET.
--
-- ── IDEMPOTENCY ───────────────────────────────────────────────────────────
-- ADD COLUMN IF NOT EXISTS — безопасный re-run. Бэкфилла нет: все существующие
-- строки получают DEFAULT (reliability='ok', relaxations='[]', revival_
-- attempted_at=NULL) — честно отражает то, что для них каскад послаблений
-- никогда не считался (записи ДО #oblast-F) и revival ещё не запускался.
--
-- Dependencies: 001_trade_in_estimates.sql. Apply after: 254_*.
BEGIN;
SET LOCAL lock_timeout = '5s';
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS relaxations jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS reliability text NOT NULL DEFAULT 'ok'
CHECK (reliability IN ('ok', 'low', 'very_low'));
ALTER TABLE trade_in_estimates
ADD COLUMN IF NOT EXISTS revival_attempted_at timestamptz;
COMMENT ON COLUMN trade_in_estimates.relaxations IS
'RU-подписи применённых послаблений подбора (estimator.py #oblast-F cascade) '
'— персистится, чтобы GET-rehydrate (?id=) мог восстановить дисклеймер '
'«точность снижена». [] = базовой выборки хватило / запись создана до '
'#oblast-F (без бэкфилла).';
COMMENT ON COLUMN trade_in_estimates.reliability IS
'Надёжность итоговой выборки (ok|low|very_low), производная от n_analogs + '
'relaxations (estimator.py::estimate_quality) — персистится для GET-rehydrate '
'красного баннера. Default ok = запись создана до #oblast-F (без бэкфилла).';
COMMENT ON COLUMN trade_in_estimates.revival_attempted_at IS
'Момент последней попытки пересчитать «мёртвую» (median_price<=0/NULL) '
'строку на GET /estimate/{id} (incident 2026-08-10, app/api/v1/trade_in.py::'
'_try_revive_dead_estimate). Throttle: не пересчитывать чаще одного раза в '
'settings.trade_in_revival_throttle_minutes на одну строку — атомарный '
'conditional UPDATE...RETURNING (см. модульный докстринг). NULL = либо '
'строка живая (median_price>0) и revival никогда не запускался, либо '
'запись создана до этой фичи.';
COMMIT;

View file

@ -57,6 +57,12 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every retain_until defaults to None (PR-D1, migration 240) -- unpaid, matches every
row that existed before that migration; explicit param lets retention-gate row that existed before that migration; explicit param lets retention-gate
tests (see test_estimate_retention_gate.py) construct a paid row. tests (see test_estimate_retention_gate.py) construct a paid row.
relaxations/reliability (migration 255) default to the schema defaults
('[]' / 'ok') -- matches every pre-migration row (no backfill). Revival
scenarios (a "dead" median_price<=0/NULL row) are covered separately in
test_estimate_revival.py with their own dedicated row builders, since this
fixture's downstream tests here all assume a "live" estimate.
""" """
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@ -99,6 +105,8 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S
ratio_basis="per_rooms", ratio_basis="per_rooms",
created_by=created_by, created_by=created_by,
created_at=datetime.now(tz=UTC), created_at=datetime.now(tz=UTC),
relaxations=[],
reliability="ok",
) )

View file

@ -0,0 +1,542 @@
"""Tests for GET /estimate/{id} revival of "dead" (median_price<=0/NULL) rows.
Incident 2026-08-10: a customer opened a saved estimate link and saw
«НЕДОСТАТОЧНО ДАННЫХ» the row was created BEFORE the estimator fix
(#oblast-E/#oblast-F, PR #2823/#2825) and is permanently stuck at
median_price=0, even though the same address/params now compute a real
price. app.api.v1.trade_in::_try_revive_dead_estimate recomputes such a row
in place (same id/link) via the same estimate_quality() path as POST
/estimate. Also covers migration 255 (relaxations/reliability persistence).
Реальная БД не нужна: DB + get_role + estimate_quality мокируются, mirroring
test_estimate_idor.py's approach (self-contained, no cross-file fixture
imports this repo has no precedent for importing fixtures across
tests/test_*.py modules, only from tests/support/).
DB mock dispatches by SQL substring (not call-position): get_estimate's
existing rehydrate path calls the real (non-stubbed) _resolve_target_house_id
helper, which itself fires 1-2 incidental `SELECT id FROM houses` queries
whenever the revival attempt does NOT short-circuit with an early return
hand-counting positional side_effect entries around that would be brittle.
"""
from __future__ import annotations
import inspect
import os
import sys
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
# psycopg v3 driver required; stub DATABASE_URL before any app import
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
# WeasyPrint requires GTK — not present in CI/Windows. Stub before any app import.
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
sys.modules.setdefault("weasyprint.CSS", _wp_mock)
sys.modules.setdefault("weasyprint.HTML", _wp_mock)
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
_ESTIMATE_ID = "22222222-2222-2222-2222-222222222222"
_TEMP_ID = "33333333-3333-3333-3333-333333333333"
@pytest.fixture(autouse=True)
def _restore_get_role():
"""Restore app.core.auth.get_role after each test (mirror test_estimate_idor)."""
from app.core import auth as auth_mod
original = auth_mod.get_role
yield
auth_mod.get_role = original
@pytest.fixture()
def trade_in_app() -> FastAPI:
"""Minimal FastAPI app mounting only the trade-in router."""
from app.api.v1 import trade_in as trade_in_module
application = FastAPI()
application.include_router(trade_in_module.router, prefix="/api/v1/trade-in")
return application
def _make_dead_row(
*,
median_price: int | None = 0,
house_type: str | None = None,
repair_state: str | None = None,
relaxations: list[str] | None = None,
reliability: str = "ok",
) -> SimpleNamespace:
"""A trade_in_estimates row in the "dead" state (incident 2026-08-10).
house_type/repair_state default to None (valid TradeInEstimateInput input
most revival tests need a row that round-trips through pydantic cleanly);
a dedicated test passes legacy Russian literal values to exercise the
"invalid persisted value" graceful-degrade path.
"""
return SimpleNamespace(
id=_ESTIMATE_ID,
median_price=median_price,
range_low=0,
range_high=0,
median_price_per_m2=0,
confidence="low",
confidence_explanation="Рядом найдено недостаточно объявлений (4 шт.)",
n_analogs=0,
analogs=[],
actual_deals=[],
sources_used=[],
data_freshness_minutes=None,
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
retain_until=None,
address="г Екатеринбург, ул Академика Парина, д 46, к 5",
lat=56.8519,
lon=60.6122,
area_m2=23.1,
rooms=1,
floor=5,
total_floors=16,
year_built=2018,
house_type=house_type,
repair_state=repair_state,
has_balcony=None,
canonical_address=None,
house_cadnum=None,
house_fias_id=None,
dadata_qc_geo=None,
dadata_metro=[],
expected_sold_price=None,
expected_sold_range_low=None,
expected_sold_range_high=None,
expected_sold_per_m2=None,
asking_to_sold_ratio=None,
ratio_basis=None,
created_by="kopylov",
created_at=datetime(2026, 5, 29, tzinfo=UTC),
relaxations=relaxations or [],
reliability=reliability,
)
def _make_live_row(
*, relaxations: list[str] | None = None, reliability: str = "ok"
) -> SimpleNamespace:
"""A "live" row (median_price>0) — revival must never touch it."""
return SimpleNamespace(
id=_ESTIMATE_ID,
median_price=4_031_157,
range_low=3_700_000,
range_high=4_300_000,
median_price_per_m2=174_000,
confidence="medium",
confidence_explanation="Найдено 39 аналогов",
n_analogs=39,
analogs=[],
actual_deals=[],
sources_used=["avito", "rosreestr"],
data_freshness_minutes=15,
expires_at=datetime.now(tz=UTC) + timedelta(hours=12),
retain_until=None,
address="г Екатеринбург, ул Академика Парина, д 46, к 5",
lat=56.8519,
lon=60.6122,
area_m2=23.1,
rooms=1,
floor=5,
total_floors=16,
year_built=2018,
house_type=None,
repair_state=None,
has_balcony=None,
canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
house_cadnum=None,
house_fias_id=None,
dadata_qc_geo=0,
dadata_metro=[],
expected_sold_price=None,
expected_sold_range_low=None,
expected_sold_range_high=None,
expected_sold_per_m2=None,
asking_to_sold_ratio=None,
ratio_basis=None,
created_by="kopylov",
created_at=datetime.now(tz=UTC),
relaxations=relaxations or [],
reliability=reliability,
)
def _fake_revived_result(**overrides):
"""A canned AggregatedEstimate mimicking a successful estimate_quality() call."""
from app.schemas.trade_in import AggregatedEstimate
defaults = dict(
estimate_id=_TEMP_ID,
median_price_rub=4_031_157,
range_low_rub=3_700_000,
range_high_rub=4_300_000,
median_price_per_m2=174_000,
confidence="medium",
confidence_explanation="Найдено 39 аналогов",
n_analogs=39,
period_months=12,
analogs=[],
actual_deals=[],
expires_at=datetime.now(tz=UTC) + timedelta(hours=24),
target_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
target_lat=56.8519,
target_lon=60.6122,
sources_used=["avito", "rosreestr"],
data_freshness_minutes=15,
canonical_address="г Екатеринбург, ул Академика Парина, д 46, к 5",
relaxations=["снят фильтр по году постройки", "учтены студии"],
reliability="low",
created_at=datetime.now(tz=UTC),
)
defaults.update(overrides)
return AggregatedEstimate(**defaults)
def _dispatch_db(row: object, claim_result: object = None) -> MagicMock:
"""DB session mock dispatching fetchone() results by SQL substring.
- initial GET SELECT ("SELECT id, median_price ...") -> row
- revival claim UPDATE ("SET revival_attempted_at") -> claim_result
- everything else (houses lookup, persist UPDATE, DELETE,
avito_imv UPDATE none of which .fetchone() in real code
except the two above, but MagicMock tolerates the unused
call either way) -> None
call_args_list still records every call in order regardless of dispatch,
so tests can assert on it directly (grep by substring) without needing to
hand-count incidental queries fired by _resolve_target_house_id.
"""
db = MagicMock()
def _execute(clause, params=None, *_a, **_k):
sql = getattr(clause, "text", str(clause))
result = MagicMock()
if "SET revival_attempted_at" in sql:
result.fetchone.return_value = claim_result
elif "SELECT id, median_price" in sql:
result.fetchone.return_value = row
else:
result.fetchone.return_value = None
return result
db.execute.side_effect = _execute
return db
def _calls_containing(db: MagicMock, needle: str) -> list:
return [c for c in db.execute.call_args_list if needle in getattr(c.args[0], "text", "")]
def _client_with(app: FastAPI, db_mock: MagicMock, role: str = "pilot") -> TestClient:
from app.core.db import get_db
def _override_db():
yield db_mock
app.dependency_overrides[get_db] = _override_db
auth_mod = sys.modules["app.core.auth"]
auth_mod.get_role = lambda _u: role # type: ignore[assignment]
return TestClient(app)
@pytest.fixture()
def _estimator_stub():
"""Replaces app.services.estimator with a SimpleNamespace stub.
Mirrors test_estimate_idor.py::_stub_precision_and_pdf, plus an
`estimate_quality` async attribute (revival's own lazy import target).
Individual tests overwrite `estimate_quality` per-scenario.
"""
real_estimator = sys.modules.get("app.services.estimator")
async def _default_estimate_quality(*_a, **_k): # pragma: no cover — overridden per test
raise AssertionError("estimate_quality stub not configured for this test")
stub = SimpleNamespace(
_qc_geo_to_precision=lambda _qc: None,
_fetch_price_trend=lambda *a, **k: None,
_fetch_dkp_corridor=lambda *a, **k: None,
_fetch_house_imv_anchor=lambda *a, **k: None,
_resolve_target_city=lambda *a, **k: None,
_cv_from_ppm2=lambda *a, **k: None,
_source_counts=lambda *a, **k: {},
_canonical_sources=lambda *a, **k: [],
estimate_quality=_default_estimate_quality,
)
sys.modules["app.services.estimator"] = stub # type: ignore[assignment]
yield stub
if real_estimator is not None:
sys.modules["app.services.estimator"] = real_estimator
else:
sys.modules.pop("app.services.estimator", None)
# ── Revival success ───────────────────────────────────────────────────────
def test_dead_row_revives_and_updates_db(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""Dead row (median_price=0) → recomputed, written to the SAME id, returned fresh.
Success path returns early (before the pre-existing rehydrate block), so
the call count is exactly the 5 documented in _try_revive_dead_estimate's
docstring: claim, avito_imv relink, persist UPDATE, DELETE temp plus the
initial SELECT.
"""
async def _fake_estimate_quality(payload, db, **kwargs):
assert payload.address.startswith("г Екатеринбург")
assert payload.rooms == 1
assert payload.radius_m is None # never persisted — default cascade
return _fake_revived_result()
_estimator_stub.estimate_quality = _fake_estimate_quality
row = _make_dead_row()
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
# id/link contract: response carries the ORIGINAL id, not the temp uuid
# estimate_quality() minted internally.
assert body["estimate_id"] == _ESTIMATE_ID
assert body["median_price_rub"] == 4_031_157
assert body["n_analogs"] == 39
assert body["insufficient_data"] is False
assert body["reliability"] == "low"
assert "снят фильтр по году постройки" in body["relaxations"]
assert len(db.execute.call_args_list) == 5
persist_calls = _calls_containing(db, "UPDATE trade_in_estimates SET")
assert len(persist_calls) == 1
persist_params = persist_calls[0].args[1]
assert persist_params["id"] == _ESTIMATE_ID
assert persist_params["median_price"] == 4_031_157
assert persist_params["reliability"] == "low"
delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates")
assert len(delete_calls) == 1
assert delete_calls[0].args[1]["id"] == _TEMP_ID
# ── Live row is never touched ────────────────────────────────────────────
def test_live_row_never_triggers_revival(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""median_price>0 → revival branch skipped entirely: estimate_quality is
never called and no claim UPDATE fires the saved price is untouched."""
async def _must_not_be_called(*_a, **_k):
raise AssertionError("estimate_quality must not be called for a live row")
_estimator_stub.estimate_quality = _must_not_be_called
row = _make_live_row()
db = _dispatch_db(row)
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["median_price_rub"] == 4_031_157
assert _calls_containing(db, "SET revival_attempted_at") == []
# ── Throttle / anti-storm ────────────────────────────────────────────────
def test_dead_row_throttled_does_not_recompute(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""Claim UPDATE returns no row (recent attempt / lost race) → no recompute,
honest still-dead response, no 500, exactly one claim attempt (no retry
loop within the same request)."""
async def _must_not_be_called(*_a, **_k):
raise AssertionError("estimate_quality must not be called when throttled")
_estimator_stub.estimate_quality = _must_not_be_called
row = _make_dead_row()
db = _dispatch_db(row, claim_result=None) # throttled: WHERE matched nothing
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["insufficient_data"] is True
assert body["median_price_rub"] == 0
assert len(_calls_containing(db, "SET revival_attempted_at")) == 1
# ── Recompute error degrades gracefully (no 500) ─────────────────────────
def test_revival_recompute_exception_falls_back_without_500(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
async def _raises(*_a, **_k):
raise RuntimeError("geocode timeout")
_estimator_stub.estimate_quality = _raises
row = _make_dead_row()
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["insufficient_data"] is True
# exception happened AFTER the claim — throttle was still recorded once.
assert len(_calls_containing(db, "SET revival_attempted_at")) == 1
# ...but nothing was written back to the row (no persist UPDATE fired).
assert _calls_containing(db, "UPDATE trade_in_estimates SET") == []
def test_revival_still_zero_falls_back_without_500(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""Recompute runs but still finds nothing (median_price_rub=0) — honest
insufficient_data, temp throwaway row cleaned up, no crash."""
async def _still_empty(*_a, **_k):
return _fake_revived_result(
median_price_rub=0,
range_low_rub=0,
range_high_rub=0,
median_price_per_m2=0,
n_analogs=0,
confidence="low",
)
_estimator_stub.estimate_quality = _still_empty
row = _make_dead_row()
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["insufficient_data"] is True
delete_calls = _calls_containing(db, "DELETE FROM trade_in_estimates")
assert len(delete_calls) == 1
assert delete_calls[0].args[1]["id"] == _TEMP_ID
# the still-dead ORIGINAL row was never overwritten with (fresh) zeros.
assert _calls_containing(db, "UPDATE trade_in_estimates SET") == []
def test_revival_invalid_persisted_house_type_falls_back_gracefully(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""Legacy row with a house_type outside the current Literal set — payload
reconstruction itself raises (pydantic ValidationError), caught the same
way as any other recompute failure. No 500."""
async def _must_not_be_called(*_a, **_k):
raise AssertionError("estimate_quality must not be reached — payload build fails first")
_estimator_stub.estimate_quality = _must_not_be_called
row = _make_dead_row(house_type="монолит", repair_state="хороший")
db = _dispatch_db(row, claim_result=SimpleNamespace(id=_ESTIMATE_ID))
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
assert resp.json()["insufficient_data"] is True
# ── relaxations/reliability round-trip (migration 255) ───────────────────
def test_relaxations_reliability_roundtrip_on_get(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""A live row with persisted relaxations/reliability surfaces them
byte-for-byte on GET the red "точность снижена" banner survives
reopening a saved link (previously always reset to ok/[])."""
row = _make_live_row(
relaxations=["радиус расширен до 2000 м", "площадь ±25%"], reliability="low"
)
db = _dispatch_db(row)
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["reliability"] == "low"
assert body["relaxations"] == ["радиус расширен до 2000 м", "площадь ±25%"]
def test_get_estimate_defaults_relaxations_reliability_when_row_lacks_columns(
trade_in_app: FastAPI, _estimator_stub: SimpleNamespace
) -> None:
"""Defensive getattr fallback: a row/mock without relaxations/reliability
attrs (e.g. a stale test double) degrades to schema defaults, not a crash."""
row = _make_live_row()
del row.relaxations
del row.reliability
db = _dispatch_db(row)
client = _client_with(trade_in_app, db)
resp = client.get(
f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}",
headers={"X-Authenticated-User": "kopylov"},
)
assert resp.status_code == 200
body = resp.json()
assert body["reliability"] == "ok"
assert body["relaxations"] == []
# ── estimator.py persists relaxations/reliability on (re)compute ─────────
def test_estimate_quality_insert_persists_relaxations_reliability() -> None:
"""Source guard: the main POST-path INSERT must write relaxations/
reliability, not just return them in the response regression guard
against the exact gap this migration closes (PR #2823 open follow-up)."""
from app.services import estimator
src = inspect.getsource(estimator.estimate_quality)
assert "relaxations_json" in src
assert '"reliability": reliability' in src
def test_empty_estimate_persists_relaxations_reliability() -> None:
"""_empty_estimate's INSERT must also set reliability='very_low' (mirrors
the Python object it returns) rather than silently defaulting to 'ok'."""
from app.services import estimator
src = inspect.getsource(estimator._empty_estimate)
assert "relaxations, reliability" in src
assert "'very_low'" in src

View file

@ -629,7 +629,30 @@ function initCityConfirmed(address: string | undefined): boolean {
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its // РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides // two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
// both ("ищем строго в пределах X м"). Design dropdown was values-only. // both ("ищем строго в пределах X м"). Design dropdown was values-only.
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"]; // 3000/5000 added (support ticket "выбран мах радиус 2 000") — the dropdown
// used to cap at 2000 m while the backend accepts up to 5000
// (schemas/trade_in.py Field(ge=100, le=5000)), so a user in a sparse district
// picking "2000 м" thinking it was the widest option actually CUT OFF the
// cascade's own 3/5 km fallback steps (contract #2044 — an explicit radius
// pick must never be silently widened past itself). See radiusHint below for
// the disclosure that "Авто" is the option that widens automatically.
const RADIUS_OPTIONS = [
"Авто",
"300 м",
"500 м",
"1000 м",
"2000 м",
"3000 м",
"5000 м",
];
// #support — "Авто" delegates radius selection to the backend's own two-tier
// (and, since #2044, cascade) widening; any explicit pick is a hard ceiling
// the cascade may not cross. Same zero-layout-cost title= pattern as
// ResultPanel's confidence tooltip / HeroBar's location-index disclaimer —
// a native hover tooltip, no modal, no panel re-layout.
const radiusHint =
"Авто — расширяем радиус сами, если аналогов мало. Явный радиус ограничивает поиск.";
// "Авто" sends no radius_m → the backend applies its two-tier default (1000 m // "Авто" sends no radius_m → the backend applies its two-tier default (1000 m
// primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle // primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle
@ -1408,6 +1431,7 @@ export default function ParamsPanel({
: undefined : undefined
} }
aria-label="Радиус анализа" aria-label="Радиус анализа"
title={radiusHint}
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",