diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index a6fe69f3..5f5ca292 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -31,6 +31,7 @@ from app.schemas.trade_in import ( CoverageProbeInput, CoverageProbeResponse, DkpCorridor, + ExposureWindow, HouseAnalyticsKpi, HouseAnalyticsResponse, HouseInfoForEstimate, @@ -390,6 +391,7 @@ async def _try_revive_dead_estimate( ), {"orig": str(estimate_id), "temp": str(temp_id)}, ) + w = result.exposure_window # #2898: окно целиком или NULL целиком db.execute( text( """ @@ -418,6 +420,10 @@ async def _try_revive_dead_estimate( ratio_basis = :ratio_basis, relaxations = CAST(:relaxations_json AS jsonb), reliability = :reliability, + est_days_p25 = :est_days_p25, + est_days_p50 = :est_days_p50, + est_days_p75 = :est_days_p75, + est_days_n = :est_days_n, revival_completed_at = NOW() WHERE id = CAST(:id AS uuid) """ @@ -452,6 +458,10 @@ async def _try_revive_dead_estimate( "ratio_basis": result.ratio_basis, "relaxations_json": json.dumps(result.relaxations, ensure_ascii=False), "reliability": result.reliability, + "est_days_p25": w.p25_days if w else None, + "est_days_p50": w.p50_days if w else None, + "est_days_p75": w.p75_days if w else None, + "est_days_n": w.n if w else None, }, ) db.execute( @@ -678,7 +688,8 @@ def load_estimate( expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, created_by, created_at, - relaxations, reliability + relaxations, reliability, + est_days_p25, est_days_p50, est_days_p75, est_days_n FROM trade_in_estimates WHERE id = CAST(:id AS uuid) AND {ESTIMATE_READABLE_SQL} @@ -843,6 +854,19 @@ def load_estimate( # миграции 267) деградирует в None — «позиции не знаем», — а не роняет # ответ AttributeError'ом. market_percentile=getattr(row, "market_percentile", None), + # #2898: окно из колонок, а не пересчёт — по ссылке то же окно, что при оценке. + # getattr: строка без колонок (in-memory double) — окна нет, а не AttributeError. + exposure_window=( + ExposureWindow( + p25_days=row.est_days_p25, + p50_days=row.est_days_p50, + p75_days=row.est_days_p75, + n=row.est_days_n, + ) + if getattr(row, "est_days_n", None) is not None + else None + ), + est_days_on_market=getattr(row, "est_days_p50", None), period_months=12, analogs=analogs, actual_deals=actual_deals, diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index 922098c0..b23ab98c 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -230,6 +230,21 @@ class PriceTrendPoint(BaseModel): ppm2: int # медиана ₽/м² за месяц +class ExposureWindow(BaseModel): + """#2898: сколько дней похожие квартиры рядом висели до снятия объявления. + + Квартили `house_placement_history.exposure_days`: те же комнаты, площадь ±15%, + дома в радиусе подбора аналогов, снятые за последние 24 мес. Это срок ДО СНЯТИЯ + объявления, а не подтверждённой продажи: в данных снятие и продажа неразличимы. + Окно либо целиком, либо None (выборка < `EXPOSURE_WINDOW_MIN_N`). + """ + + p25_days: int + p50_days: int + p75_days: int + n: int # сколько снятых объявлений в выборке + + class AggregatedEstimate(BaseModel): estimate_id: UUID median_price_rub: int @@ -292,7 +307,11 @@ class AggregatedEstimate(BaseModel): data_freshness_minutes: int | None = None # сколько минут назад был самый свежий парсинг # абсолютный timestamp самого свежего парсинга аналогов last_scraped_at: datetime | None = None - est_days_on_market: int | None = None # прогноз срока продажи (медиана по аналогам) + # #2898: окно срока экспозиции (p25–p75) — показывать его, а не одно число. + exposure_window: ExposureWindow | None = None + # Устаревшее, оставлено для старых клиентов: = exposure_window.p50_days. Раньше — + # медиана возраста АКТИВНЫХ объявлений-аналогов (цензурированная выборка). + est_days_on_market: int | None = None cian_valuation: CianValuationSummary | None = None # ── Месячный ₽/м² тренд для целевого дома (web TREND chart) — ADDITIVE + OPTIONAL ── # ~12-24 точки. Источник: houses_price_dynamics (preferred, пока пуста в prod) → diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 8beb6cd1..87a405e5 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -63,6 +63,7 @@ from app.schemas.trade_in import ( AvitoImvSummary, CianValuationSummary, DkpCorridor, + ExposureWindow, PriceTrendPoint, TradeInEstimateInput, ) @@ -5502,6 +5503,19 @@ async def estimate_quality( sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags) source_counts = _source_counts([lot.source for lot in analogs_lots]) freshness_pre = _compute_freshness_minutes(metadata_lots) + # #2898: окно считается один раз здесь и персистится — отчёт по ссылке через месяц + # покажет то же окно, что в момент расчёта, а не пересчёт по новой истории. + exposure_window = _exposure_window( + await run_db_thread( + _fetch_exposure_days, + db, + lat=geo.lat, + lon=geo.lon, + rooms=payload.rooms, + area=payload.area_m2, + radius_m=search_radius_m, + ) + ) # DaData enrichment (PR Q1) — заполняется только если service отработал. # При DaData = None все колонки идут в DB как NULL (graceful). dadata_metro_json = ( @@ -5531,6 +5545,7 @@ async def estimate_quality( expected_sold_range_high, expected_sold_per_m2, asking_to_sold_ratio, ratio_basis, relaxations, reliability, + est_days_p25, est_days_p50, est_days_p75, est_days_n, created_by, expires_at, consent, client_ip, consent_policy_version, consent_text_snapshot @@ -5554,6 +5569,7 @@ async def estimate_quality( :expected_sold_range_high, :expected_sold_per_m2, :asking_to_sold_ratio, :ratio_basis, CAST(:relaxations_json AS jsonb), :reliability, + :est_days_p25, :est_days_p50, :est_days_p75, :est_days_n, :created_by, :expires_at, :consent, CAST(:client_ip AS inet), :consent_policy_version, @@ -5614,6 +5630,11 @@ async def estimate_quality( # пропадал при открытии оценки по сохранённой ссылке (?id=). "relaxations_json": json.dumps(relaxations, ensure_ascii=False), "reliability": reliability, + # #2898: окно пишем целиком или NULL целиком (n < порога). + "est_days_p25": exposure_window.p25_days if exposure_window else None, + "est_days_p50": exposure_window.p50_days if exposure_window else None, + "est_days_p75": exposure_window.p75_days if exposure_window else None, + "est_days_n": exposure_window.n if exposure_window else None, "created_by": created_by, "expires_at": expires_at, **_estimate_consent_persist_fields(require_consent, client_ip), @@ -5784,7 +5805,8 @@ async def estimate_quality( data_freshness_minutes=freshness_min, last_scraped_at=last_scraped_at, price_trend=price_trend, - est_days_on_market=_estimate_days_on_market(metadata_lots, deals), + exposure_window=exposure_window, + est_days_on_market=exposure_window.p50_days if exposure_window else None, cian_valuation=( CianValuationSummary( sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None, @@ -5976,23 +5998,88 @@ def _manual_review( return (bool(reasons), reasons) -def _estimate_days_on_market( - listings: list[dict[str, Any]], deals: list[dict[str, Any]] -) -> int | None: - """Прогноз срока продажи — медиана days_on_market по аналогам/сделкам. +# #2898: окно срока экспозиции вместо одного числа. Раньше est_days_on_market был +# медианой days_on_market АКТИВНЫХ аналогов — возраст висящего объявления, а не срок +# до снятия (цензурированная выборка; deals.days_on_market пуст). Реальная экспозиция +# снятых объявлений — house_placement_history.exposure_days. +# +# Порог 30 — замер 17.09.2026 на 51 прод-когорте с n>=80 (радиус 1 км): подвыборка +# в 10 лотов даёт среднюю ошибку края окна 34-40%, 20 — 24%, 30 — 18%, 50 — 12%. +# При 30 окно есть у 94 из 217 недавних оценок (при 20 — у 109). +EXPOSURE_WINDOW_MIN_N = 30 +EXPOSURE_WINDOW_MONTHS = 24 - Возвращает None если ни у одного аналога нет данных о сроке экспозиции - (наши парсеры не всегда его отдают — честно показываем «нет данных»). + +def _exposure_window(days: list[int]) -> ExposureWindow | None: + """Квартили срока экспозиции; None, если выборка меньше EXPOSURE_WINDOW_MIN_N. + + method="inclusive" — та же линейная интерполяция, что percentile_cont в + sell-time-sensitivity, чтобы одно и то же окно не расходилось на единицу. """ - values = [ - int(lot["days_on_market"]) - for lot in (*listings, *deals) - if lot.get("days_on_market") and int(lot["days_on_market"]) > 0 - ] - if len(values) < 3: + if len(days) < EXPOSURE_WINDOW_MIN_N: return None - values.sort() - return values[len(values) // 2] + p25, p50, p75 = statistics.quantiles(days, n=4, method="inclusive") + return ExposureWindow( + p25_days=int(p25 + 0.5), + p50_days=int(p50 + 0.5), + p75_days=int(p75 + 0.5), + n=len(days), + ) + + +def _fetch_exposure_days( + db: Session, + *, + lat: float | None, + lon: float | None, + rooms: int, + area: float, + radius_m: int, +) -> list[int]: + """exposure_days снятых объявлений той же комнатности и площади ±AREA_TOLERANCE + в домах в радиусе подбора аналогов. Best-effort: ошибка → [] (окна нет). + + ponytail: без дедупа avito_imv/yandex_valuation — прод 17.09: у 926 из 9482 + yandex-строк есть avito-двойник (~3% всей выборки), на квартили не влияет. + """ + if lat is None or lon is None or not area: + return [] + try: + # SAVEPOINT на месте глушения: упавший SELECT не должен отравить транзакцию, + # в которой следом идёт INSERT оценки. + with db.begin_nested(): + rows = db.execute( + text( + """ + SELECT h.exposure_days + FROM house_placement_history h + JOIN houses ho ON ho.id = h.house_id + WHERE ST_DWithin( + ho.geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius + ) + AND h.rooms = :rooms + AND h.area_m2 BETWEEN :area_min AND :area_max + AND h.exposure_days IS NOT NULL + AND h.removed_date IS NOT NULL + AND h.removed_date <= CURRENT_DATE + AND h.removed_date > CURRENT_DATE + - make_interval(months => CAST(:months AS integer)) + """ + ), + { + "lat": lat, + "lon": lon, + "radius": radius_m, + "rooms": rooms, + "area_min": area * (1 - AREA_TOLERANCE), + "area_max": area * (1 + AREA_TOLERANCE), + "months": EXPOSURE_WINDOW_MONTHS, + }, + ).all() + except Exception as exc: + logger.warning("exposure_window lookup failed (graceful): %s", exc) + return [] + return [int(r[0]) for r in rows] def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None: diff --git a/tradein-mvp/backend/data/sql/324_trade_in_estimates_exposure_window.sql b/tradein-mvp/backend/data/sql/324_trade_in_estimates_exposure_window.sql new file mode 100644 index 00000000..988ecd92 --- /dev/null +++ b/tradein-mvp/backend/data/sql/324_trade_in_estimates_exposure_window.sql @@ -0,0 +1,31 @@ +-- #2898: окно срока экспозиции (p25/p50/p75 и размер выборки) вместо одного числа. +-- +-- ЗАЧЕМ КОЛОНКИ. Окно считается по house_placement_history на момент оценки, а +-- история пополняется каждый день: пересчёт на чтении показал бы по ссылке через +-- месяц другое окно, чем в момент покупки отчёта. Поэтому значение хранится. +-- +-- Все четыре либо заполнены, либо NULL: NULL — выборка меньше +-- EXPOSURE_WINDOW_MIN_N (30) или оценка посчитана до этой миграции. Бэкфилла нет: +-- историю размещений на дату старой оценки не восстановить. +-- +-- Старое поле ответа est_days_on_market не колонка и не удаляется: GET отдаёт в нём +-- est_days_p50, старые строки дают null, как и раньше. +-- +-- ПРО ЛОКИ (#2752): ADD COLUMN без DEFAULT не переписывает таблицу, но ждать +-- ACCESS EXCLUSIVE может долго и держать очередь запросов — lock_timeout. +BEGIN; + +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE trade_in_estimates + ADD COLUMN IF NOT EXISTS est_days_p25 integer, + ADD COLUMN IF NOT EXISTS est_days_p50 integer, + ADD COLUMN IF NOT EXISTS est_days_p75 integer, + ADD COLUMN IF NOT EXISTS est_days_n integer; + +COMMENT ON COLUMN trade_in_estimates.est_days_p50 IS + '#2898: медиана дней до СНЯТИЯ объявления (не продажи) у похожих квартир рядом, ' + 'house_placement_history.exposure_days. p25/p75 — края окна, n — размер выборки. ' + 'NULL — выборка < 30.'; + +COMMIT; diff --git a/tradein-mvp/backend/tests/test_2898_exposure_window.py b/tradein-mvp/backend/tests/test_2898_exposure_window.py new file mode 100644 index 00000000..2827b8ff --- /dev/null +++ b/tradein-mvp/backend/tests/test_2898_exposure_window.py @@ -0,0 +1,151 @@ +"""#2898: окно срока экспозиции (p25–p75) вместо одного числа и его персист. + +Раньше est_days_on_market был медианой возраста АКТИВНЫХ объявлений-аналогов и не +сохранялся: отчёт по ссылке отдавал null. Теперь квартили exposure_days снятых +объявлений считаются на POST, пишутся в trade_in_estimates и поднимаются на GET. + +Ожидаемые квартили посчитаны руками по формуле percentile_cont (позиция +(n-1)·q, линейная интерполяция, ::int округляет .5 вверх), а не той же функцией. +""" + +from __future__ import annotations + +import contextlib +import os +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from unittest.mock import AsyncMock, MagicMock, patch + +import anyio + +from app.services.estimator import ( + EXPOSURE_WINDOW_MIN_N, + _exposure_window, + _fetch_exposure_days, +) + +# 2..31 (n=30): p25 на позиции 7.25 → 9.25 → 9; p50 на 14.5 → 16.5 → 17 (банковское +# округление дало бы 16); p75 на 21.75 → 23.75 → 24. +_DAYS_30 = list(range(2, 32)) + + +def test_quartiles_match_percentile_cont() -> None: + w = _exposure_window(_DAYS_30) + assert w is not None + assert (w.p25_days, w.p50_days, w.p75_days, w.n) == (9, 17, 24, 30) + + +def test_order_of_input_does_not_matter() -> None: + w = _exposure_window(list(reversed(_DAYS_30))) + assert w is not None + assert (w.p25_days, w.p50_days, w.p75_days) == (9, 17, 24) + + +def test_skewed_sample_window() -> None: + """Прод-подобная выборка: пачка ровно по 31 дню (срок размещения на Авито) и хвост.""" + days = [31] * 20 + [62] * 5 + [120, 180, 240, 300, 400] + w = _exposure_window(days) + assert w is not None + # позиции 7.25 / 14.5 / 21.75 — внутри пачки 31 и пачки 62 + assert (w.p25_days, w.p50_days, w.p75_days) == (31, 31, 62) + + +def test_below_threshold_no_window() -> None: + assert EXPOSURE_WINDOW_MIN_N == 30 + assert _exposure_window(_DAYS_30[:-1]) is None + assert _exposure_window([]) is None + + +def test_fetch_failure_is_graceful() -> None: + db = MagicMock() + db.execute.side_effect = RuntimeError("boom") + assert _fetch_exposure_days(db, lat=56.8, lon=60.6, rooms=1, area=40.0, radius_m=1000) == [] + + +def test_fetch_without_coords_does_not_query() -> None: + db = MagicMock() + assert _fetch_exposure_days(db, lat=None, lon=60.6, rooms=1, area=40.0, radius_m=1000) == [] + assert not db.execute.called + + +# ── POST: окно уходит и в ответ, и в INSERT ─────────────────────────────────── + + +def _run_estimate(exposure_days: list[int]) -> tuple[Any, dict[str, Any]]: + from app.schemas.trade_in import TradeInEstimateInput + from app.services.estimator import estimate_quality + from app.services.geocoder import GeocodeResult + + db = MagicMock() + payload = TradeInEstimateInput(address="ЕКБ, ул. Тестовая, 1", area_m2=40.0, rooms=1) + geo = GeocodeResult( + lat=56.838, lon=60.595, full_address="Екатеринбург, ул. Тестовая, 1", provider="nominatim" + ) + fetch = MagicMock(return_value=exposure_days) + patches = ( + patch("app.services.estimator.geocode", new=AsyncMock(return_value=geo)), + patch("app.services.estimator.dadata_clean_address", new=AsyncMock(return_value=None)), + patch("app.services.estimator.match_house_readonly", return_value=None), + patch("app.services.estimator.get_house_metadata", new=AsyncMock(return_value=None)), + patch("app.services.estimator._fetch_analogs", return_value=([], False, "W")), + patch("app.services.estimator._fetch_deals", return_value=[]), + patch("app.services.estimator._get_or_fetch_imv_cached", new=AsyncMock(return_value=None)), + patch( + "app.services.estimator._get_or_fetch_yandex_valuation_cached", + new=AsyncMock(return_value=None), + ), + patch( + "app.services.estimator.estimate_via_cian_valuation", new=AsyncMock(return_value=None) + ), + patch("app.services.estimator._get_asking_sold_ratio", return_value=(None, None)), + patch("app.services.estimator._fetch_exposure_days", new=fetch), + ) + + async def _run() -> Any: + with contextlib.ExitStack() as stack: + for cm in patches: + stack.enter_context(cm) + return await estimate_quality(payload, db) + + result = anyio.run(_run) + # Выборка берётся по координатам и параметрам оцениваемой квартиры. + kwargs = fetch.call_args.kwargs + assert (kwargs["lat"], kwargs["lon"], kwargs["rooms"], kwargs["area"]) == ( + 56.838, + 60.595, + 1, + 40.0, + ) + for call in db.execute.call_args_list: + sql = str(getattr(call.args[0], "text", call.args[0])) + if "INSERT INTO trade_in_estimates" in sql: + return result, call.args[1] + raise AssertionError("INSERT INTO trade_in_estimates не вызван") + + +def test_post_persists_window_and_returns_it() -> None: + result, params = _run_estimate(_DAYS_30) + assert ( + params["est_days_p25"], + params["est_days_p50"], + params["est_days_p75"], + params["est_days_n"], + ) == (9, 17, 24, 30) + assert result.exposure_window is not None + assert (result.exposure_window.p25_days, result.exposure_window.p75_days) == (9, 24) + # Старое поле для старых клиентов — медиана того же окна, а не возраст объявлений. + assert result.est_days_on_market == 17 + + +def test_post_small_sample_persists_nulls() -> None: + result, params = _run_estimate(_DAYS_30[:-1]) + assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [ + None, + None, + None, + None, + ] + assert result.exposure_window is None + assert result.est_days_on_market is None diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 5ed999e1..90bed6d3 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -80,6 +80,11 @@ def _make_estimate_row(created_by: str | None, retain_until: object = None) -> S # #2899: колонка есть у всех строк после миграции 267; NULL у старых # (бэкфилла нет — позицию по сохранённому top-10 не восстановить). market_percentile=63, + # #2898: окно срока экспозиции (миграция 324); NULL у строк до неё. + est_days_p25=31, + est_days_p50=58, + est_days_p75=121, + est_days_n=44, analogs=[], actual_deals=[], sources_used=["avito"], @@ -801,3 +806,36 @@ def test_get_estimate_market_percentile_nullable(trade_in_app: FastAPI) -> None: ) assert resp.status_code == 200 assert resp.json()["market_percentile"] is None + + +def test_get_estimate_surfaces_exposure_window(trade_in_app: FastAPI) -> None: + """#2898: окно срока поднимается из колонок — по ссылке то же, что при оценке. + + До #2898 GET отдавал est_days_on_market=null всегда: значение жило только в POST. + """ + row = _make_estimate_row(created_by="kopylov") + client = _client_with(trade_in_app, _make_db_mock(row), role="pilot") + + 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["exposure_window"] == {"p25_days": 31, "p50_days": 58, "p75_days": 121, "n": 44} + assert body["est_days_on_market"] == 58 + + +def test_get_estimate_old_row_without_window(trade_in_app: FastAPI) -> None: + """Контроль: строка до миграции 324 (все четыре NULL) — окна нет, старое поле null.""" + row = _make_estimate_row(created_by="kopylov") + row.est_days_p25 = row.est_days_p50 = row.est_days_p75 = row.est_days_n = None + client = _client_with(trade_in_app, _make_db_mock(row), role="pilot") + + resp = client.get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + assert resp.json()["exposure_window"] is None + assert resp.json()["est_days_on_market"] is None diff --git a/tradein-mvp/backend/tests/test_estimate_revival.py b/tradein-mvp/backend/tests/test_estimate_revival.py index 3957f26d..21959606 100644 --- a/tradein-mvp/backend/tests/test_estimate_revival.py +++ b/tradein-mvp/backend/tests/test_estimate_revival.py @@ -602,3 +602,32 @@ def test_empty_estimate_persists_relaxations_reliability() -> None: src = inspect.getsource(estimator._empty_estimate) assert "relaxations, reliability" in src assert "'very_low'" in src + + +def test_revival_persists_exposure_window( + trade_in_app: FastAPI, _estimator_stub: SimpleNamespace +) -> None: + """#2898: пересчитанное окно срока пишется в исходную строку — иначе следующий + GET по той же ссылке отдал бы окно null, хотя ответ ревайвла его показал.""" + from app.schemas.trade_in import ExposureWindow + + window = ExposureWindow(p25_days=31, p50_days=58, p75_days=121, n=44) + + async def _fake_estimate_quality(payload, db, **kwargs): + return _fake_revived_result(exposure_window=window, est_days_on_market=58) + + _estimator_stub.estimate_quality = _fake_estimate_quality + + db = _dispatch_db(_make_dead_row(), claim_result=SimpleNamespace(id=_ESTIMATE_ID)) + resp = _client_with(trade_in_app, db).get( + f"/api/v1/trade-in/estimate/{_ESTIMATE_ID}", + headers={"X-Authenticated-User": "kopylov"}, + ) + assert resp.status_code == 200 + params = _calls_containing(db, "UPDATE trade_in_estimates SET")[0].args[1] + assert [params[k] for k in ("est_days_p25", "est_days_p50", "est_days_p75", "est_days_n")] == [ + 31, + 58, + 121, + 44, + ] diff --git a/tradein-mvp/backend/tests/test_public_mera_estimate.py b/tradein-mvp/backend/tests/test_public_mera_estimate.py index c2915bc1..b2188008 100644 --- a/tradein-mvp/backend/tests/test_public_mera_estimate.py +++ b/tradein-mvp/backend/tests/test_public_mera_estimate.py @@ -261,6 +261,7 @@ _PAID_FIELDS = { "actual_deals", "expected_sold_price_rub", "est_days_on_market", + "exposure_window", "price_trend", } diff --git a/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx b/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx index 5a3d0117..91b01680 100644 --- a/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx +++ b/tradein-mvp/frontend/src/app/mera-public/docs/page.tsx @@ -74,7 +74,7 @@ const TARIFFS: readonly { }, { name: "Полный отчёт", - note: "Цена, прогноз срока, диапазон, аналоги, факторы. Разовый платёж", + note: "Цена, диапазон, срок экспозиции похожих объявлений, аналоги, факторы. Разовый платёж", price: `${SERVICE_PRICE_RUB} ₽`, }, { diff --git a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts index ec10ab75..47c1f655 100644 --- a/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts +++ b/tradein-mvp/frontend/src/app/ui-preview/estimate/fixture.ts @@ -139,6 +139,7 @@ export const FIXTURE_ESTIMATE: AggregatedEstimate = { sources_used: ["avito", "cian", "rosreestr"], data_freshness_minutes: 35, last_scraped_at: "2026-05-30T12:00:00Z", + exposure_window: { p25_days: 31, p50_days: 48, p75_days: 104, n: 57 }, est_days_on_market: 48, address_precision: "house", area_m2: 55.3, diff --git a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx index d03f2419..47abc2e2 100644 --- a/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/HeroSummary.tsx @@ -246,7 +246,9 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals const houseType = estimate.house_type ?? input.house_type; const repairState = estimate.repair_state ?? input.repair_state; const hasBalcony = estimate.has_balcony ?? input.has_balcony; - const estDaysOnMarket = estimate.est_days_on_market; + // #2898: окно p25–p75, а не одно число. est_days_on_market не показываем: + // у старых оценок это возраст висящих объявлений, а не срок до снятия. + const exposure = estimate.exposure_window ?? null; function handleEnrichSubmit(e: React.FormEvent) { e.preventDefault(); @@ -403,9 +405,14 @@ export function HeroSummary({ estimate, input, onResubmit, isResubmitting = fals {estimate.n_analogs}
- Срок продажи - {estDaysOnMarket !== null ? ( - {estDaysOnMarket} дн. + До снятия объявления + {exposure ? ( + + {exposure.p25_days}–{exposure.p75_days} дн. + ) : ( нет данных )} diff --git a/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryExposureWindow.test.tsx b/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryExposureWindow.test.tsx new file mode 100644 index 00000000..b1acd6ed --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/__tests__/HeroSummaryExposureWindow.test.tsx @@ -0,0 +1,46 @@ +/** + * #2898 — в hero окно срока до снятия объявления (p25–p75), а не одно число. + * Старое est_days_on_market без окна не показываем: у оценок до #2898 это возраст + * висящих объявлений, и «48 дн.» читалось бы как срок продажи. + */ +import { render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { HeroSummary } from "../HeroSummary"; +import { FIXTURE_ESTIMATE, FIXTURE_INPUT } from "@/app/ui-preview/estimate/fixture"; +import type { AggregatedEstimate } from "@/types/trade-in"; + +vi.mock("@/lib/useBrand", () => ({ + useActiveBrandSlug: () => null, + useBrand: () => ({ data: null }), +})); +vi.mock("../HeroTransparency", () => ({ HeroTransparency: () => null })); + +function heroText(estimate: AggregatedEstimate): string { + const { container } = render( + {}} />, + ); + return container.textContent ?? ""; +} + +describe("#2898 окно срока в v1 HeroSummary", () => { + it("показывает края окна", () => { + const text = heroText({ + ...FIXTURE_ESTIMATE, + exposure_window: { p25_days: 29, p50_days: 61, p75_days: 146, n: 44 }, + est_days_on_market: 61, + }); + expect(text).toContain("До снятия объявления29–146 дн."); + expect(text).not.toContain("Срок продажи"); + }); + + it("без окна — «нет данных», старое число не всплывает", () => { + const text = heroText({ + ...FIXTURE_ESTIMATE, + exposure_window: null, + est_days_on_market: 48, + }); + expect(text).toContain("До снятия объявлениянет данных"); + expect(text).not.toContain("48 дн."); + }); +}); diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index bcec3816..6d409918 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -128,6 +128,15 @@ export interface PriceTrendPoint { ppm2: number; // ₽/м² } +// #2898: сколько дней похожие квартиры рядом висели до СНЯТИЯ объявления +// (не подтверждённой продажи) — квартили house_placement_history.exposure_days. +export interface ExposureWindow { + p25_days: number; + p50_days: number; + p75_days: number; + n: number; // снятых объявлений в выборке +} + export interface CianValuationSummary { sale_price_rub: number | null; rent_price_rub: number | null; @@ -234,7 +243,10 @@ export interface AggregatedEstimate { created_at?: string | null; // ISO datetime — «отчёт от DD.MM» data_freshness_minutes: number | null; // «обновлено N минут назад» last_scraped_at?: string | null; // ISO datetime последнего скрейпа источников (optional) - est_days_on_market: number | null; // прогноз срока продажи + // #2898: окно p25–p75; null — выборка < 30 или оценка старее бэкенда. + exposure_window?: ExposureWindow | null; + // Устарело: = exposure_window.p50_days. Показывать окно, не это число. + est_days_on_market: number | null; // address_precision — точность гео-привязки адреса (из DaData qc_geo): // «house» (qc_geo=0, дом точно), «street» (qc_geo=1, до улицы), // «approximate» (qc_geo≥2: населённый пункт/город/регион/не распознан).