fix(tradein): rehydrate POST-only fields on GET /estimate/{id} (#696) (#710)
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 40s
Deploy Trade-In / deploy (push) Successful in 34s
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 24s
All checks were successful
Deploy Trade-In / build-backend (push) Successful in 40s
Deploy Trade-In / deploy (push) Successful in 34s
Deploy Trade-In / changes (push) Successful in 4s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / test (push) Successful in 24s
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
This commit is contained in:
parent
6e8c19ebe2
commit
466774dbd8
2 changed files with 152 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue