fix(tradein): rehydrate POST-only fields on GET /estimate/{id} (#696)

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
This commit is contained in:
bot-backend 2026-05-30 13:32:59 +03:00
parent cffc91a1d8
commit 5b13e517e5
2 changed files with 152 additions and 3 deletions

View file

@ -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,

View file

@ -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"))