From 5b13e517e5ca4a72e1010621b9f2d85892f17545 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 30 May 2026 13:32:59 +0300 Subject: [PATCH] fix(tradein): rehydrate POST-only fields on GET /estimate/{id} (#696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /estimate возвращает price_trend, last_scraped_at, avito_imv и dkp_corridor, но GET /{id} (shared-link, PDF reopen, ?id= restore) отдавал их null — house_id не ререзолвился в GET-хендлере, derived-поля терялись. Пересчитываем в хендлере из персистированного состояния (DDL не требуется): - target_house_id ререзолвится по address (tradein_normalize_short_addr) с geo-fallback 100м — тот же паттерн, что в placement-history/house-analytics; - price_trend / avito_imv (display-only anchor) / dkp_corridor — через существующие estimator-хелперы _fetch_price_trend / _fetch_house_imv_anchor / _fetch_dkp_corridor; - last_scraped_at реконструируется как created_at − data_freshness_minutes (оба поля персистятся; эквивалентно max(scraped_at) на момент POST). Всё best-effort: None при отсутствии данных — без регрессий относительно прежнего поведения. cian_valuation / est_days_on_market тоже POST-only на GET (вне scope #696 — потенциальный follow-up). Closes #696 --- tradein-mvp/backend/app/api/v1/trade_in.py | 103 +++++++++++++++++- .../backend/tests/test_estimate_idor.py | 52 ++++++++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/trade_in.py b/tradein-mvp/backend/app/api/v1/trade_in.py index 8e814002..d49fec6a 100644 --- a/tradein-mvp/backend/app/api/v1/trade_in.py +++ b/tradein-mvp/backend/app/api/v1/trade_in.py @@ -18,7 +18,9 @@ from app.core.db import get_db from app.schemas.trade_in import ( AggregatedEstimate, AnalogLot, + AvitoImvSummary, CianPriceChangeStats, + DkpCorridor, HouseAnalyticsKpi, HouseAnalyticsResponse, HouseInfoForEstimate, @@ -26,6 +28,7 @@ from app.schemas.trade_in import ( PhotoMeta, PlacementHistoryEntry, PriceHistoryYearPoint, + PriceTrendPoint, QuotaStatus, RecentSoldEntry, SalesListingPair, @@ -91,6 +94,52 @@ def _assert_estimate_access_by_id( _assert_estimate_access(row.created_by, x_authenticated_user) +def _resolve_target_house_id( + db: Session, address: str | None, lat: float | None, lon: float | None +) -> int | None: + """Резолвит house_id целевого дома по адресу (geo fallback) — для GET-rehydrate. + + Тот же паттерн, что в placement-history / house-analytics: нормализованный + short_address → houses, иначе tight 100м geo-fallback. Берём первый матч + (детерминированно — для price_trend/avito_imv достаточно одного дома). + Best-effort: None при отсутствии адреса/координат/матча (graceful). + """ + if address: + row = db.execute( + text( + """ + SELECT id FROM houses + WHERE short_address = tradein_normalize_short_addr(:addr) + OR tradein_normalize_short_addr(address) = tradein_normalize_short_addr(:addr) + LIMIT 1 + """ + ), + {"addr": address}, + ).fetchone() + if row is not None: + return row.id + + if lat is not None and lon is not None: + row = db.execute( + text( + """ + SELECT id FROM houses + WHERE geom IS NOT NULL + AND ST_DWithin( + geom::geography, ST_MakePoint(:lon, :lat)::geography, 100 + ) + ORDER BY ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) + LIMIT 1 + """ + ), + {"lat": lat, "lon": lon}, + ).fetchone() + if row is not None: + return row.id + + return None + + @router.post("/estimate", response_model=AggregatedEstimate) async def estimate( payload: TradeInEstimateInput, @@ -166,7 +215,7 @@ def get_estimate( dadata_qc_geo, dadata_metro, expected_sold_price, expected_sold_range_low, expected_sold_range_high, expected_sold_per_m2, - asking_to_sold_ratio, ratio_basis, created_by + asking_to_sold_ratio, ratio_basis, created_by, created_at FROM trade_in_estimates WHERE id = CAST(:id AS uuid) AND expires_at > NOW() @@ -180,11 +229,56 @@ def get_estimate( _assert_estimate_access(row.created_by, x_authenticated_user) - from app.services.estimator import _qc_geo_to_precision + from app.services.estimator import ( + _fetch_dkp_corridor, + _fetch_house_imv_anchor, + _fetch_price_trend, + _qc_geo_to_precision, + ) analogs = [AnalogLot(**a) for a in (row.analogs or [])] actual_deals = [AnalogLot(**a) for a in (row.actual_deals or [])] + # #696: POST-only производные поля (price_trend / avito_imv / dkp_corridor / + # last_scraped_at) на GET-rehydrate ранее были null → на shared-link / PDF / + # ?id= restore пропадали графики тренда, IMV-якорь, коридор ДКП и метка + # свежести. Пересчитываем их здесь из персистированного состояния (house_id + # ререзолвится по адресу/гео; last_scraped_at реконструируется из + # created_at − data_freshness_minutes — оба поля персистятся). Всё best-effort: + # None при отсутствии данных (без регрессий по сравнению с прежним поведением). + area_f = float(row.area_m2) if row.area_m2 is not None else None + target_house_id = _resolve_target_house_id(db, row.address, row.lat, row.lon) + + price_trend_raw = _fetch_price_trend(db, target_house_id=target_house_id) + price_trend = ( + [PriceTrendPoint(month=p["month"], ppm2=p["ppm2"]) for p in price_trend_raw] + if price_trend_raw + else None + ) + + dkp_raw = _fetch_dkp_corridor(db, address=row.address, rooms=row.rooms, area=area_f) + dkp_corridor = DkpCorridor(**dkp_raw) if dkp_raw else None + + imv_raw = _fetch_house_imv_anchor( + db, target_house_id=target_house_id, rooms=row.rooms, area=area_f + ) + avito_imv = ( + AvitoImvSummary( + recommended_price=int(imv_raw["recommended_price"]), + lower_price=int(imv_raw["lower_price"]) if imv_raw.get("lower_price") else None, + higher_price=int(imv_raw["higher_price"]) if imv_raw.get("higher_price") else None, + market_count=int(imv_raw["market_count"]) if imv_raw.get("market_count") else None, + ) + if imv_raw is not None and imv_raw.get("recommended_price") + else None + ) + + last_scraped_at = ( + row.created_at - timedelta(minutes=row.data_freshness_minutes) + if row.created_at is not None and row.data_freshness_minutes is not None + else None + ) + # ВАЖНО: возвращаем ПОЛНЫЙ набор полей. Раньше эндпоинт отдавал огрызок # без sources_used / confidence_explanation / координат — и при открытии # оценки по ссылке (?id=) карточка источников пустела до «0/7». @@ -209,6 +303,11 @@ def get_estimate( target_lon=row.lon, sources_used=row.sources_used or [], data_freshness_minutes=row.data_freshness_minutes, + # #696 — POST-only производные поля, пересчитанные на rehydrate (см. выше). + last_scraped_at=last_scraped_at, + price_trend=price_trend, + avito_imv=avito_imv, + dkp_corridor=dkp_corridor, # #648 Stage 3 — sold-correction columns rehydrated for shared-link reopen. # (numeric ratio → float for the Pydantic field.) expected_sold_price_rub=row.expected_sold_price, diff --git a/tradein-mvp/backend/tests/test_estimate_idor.py b/tradein-mvp/backend/tests/test_estimate_idor.py index 26f316ac..45bbf59d 100644 --- a/tradein-mvp/backend/tests/test_estimate_idor.py +++ b/tradein-mvp/backend/tests/test_estimate_idor.py @@ -92,6 +92,7 @@ def _make_estimate_row(created_by: str | None) -> SimpleNamespace: asking_to_sold_ratio=0.85, ratio_basis="per_rooms", created_by=created_by, + created_at=datetime.now(tz=UTC), ) @@ -130,7 +131,15 @@ def _stub_precision_and_pdf(): """Stub _qc_geo_to_precision + generate_trade_in_pdf so 200-paths don't pull heavy deps.""" from app.api.v1 import trade_in as trade_in_module - estimator_stub = SimpleNamespace(_qc_geo_to_precision=lambda _qc: "house") + # #696: GET-rehydrate pulls four POST-only helpers from estimator. Stub them to + # None by default (graceful "no data" path); the positive rehydrate test overrides + # them per-call with data-returning lambdas. + estimator_stub = SimpleNamespace( + _qc_geo_to_precision=lambda _qc: "house", + _fetch_price_trend=lambda *a, **k: None, + _fetch_dkp_corridor=lambda *a, **k: None, + _fetch_house_imv_anchor=lambda *a, **k: None, + ) real_estimator = sys.modules.get("app.services.estimator") sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment] @@ -192,6 +201,47 @@ def test_get_estimate_admin_can_read_any(trade_in_app: FastAPI) -> None: assert resp.status_code == 200 +def test_get_estimate_rehydrates_post_only_fields(trade_in_app: FastAPI) -> None: + """#696: GET /{id} recomputes price_trend / avito_imv / dkp_corridor / last_scraped_at. + + Previously these POST-only derived fields came back null on shared-link / PDF / + ?id= restore. Stub the estimator helpers with data and assert they surface. + """ + est = sys.modules["app.services.estimator"] + est._fetch_price_trend = lambda *a, **k: [ # type: ignore[attr-defined] + {"month": "2026-03", "ppm2": 140_000}, + {"month": "2026-04", "ppm2": 150_000}, + {"month": "2026-05", "ppm2": 160_000}, + ] + est._fetch_house_imv_anchor = lambda *a, **k: { # type: ignore[attr-defined] + "recommended_price": 5_200_000, + "lower_price": 4_800_000, + "higher_price": 5_600_000, + "market_count": 42, + } + est._fetch_dkp_corridor = lambda *a, **k: { # type: ignore[attr-defined] + "count": 5, + "low_ppm2": 120_000, + "median_ppm2": 150_000, + "high_ppm2": 180_000, + "period_months": 12, + } + + db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov")) + client = _client_with(trade_in_app, db_mock, 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 len(body["price_trend"]) == 3 + assert body["avito_imv"]["recommended_price"] == 5_200_000 + assert body["dkp_corridor"]["count"] == 5 + # last_scraped_at = created_at − data_freshness_minutes (both persisted) → non-null + assert body["last_scraped_at"] is not None + + def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None: """No X-Authenticated-User header → 401.""" db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov")) -- 2.45.3