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 (
|
from app.schemas.trade_in import (
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
AnalogLot,
|
AnalogLot,
|
||||||
|
AvitoImvSummary,
|
||||||
CianPriceChangeStats,
|
CianPriceChangeStats,
|
||||||
|
DkpCorridor,
|
||||||
HouseAnalyticsKpi,
|
HouseAnalyticsKpi,
|
||||||
HouseAnalyticsResponse,
|
HouseAnalyticsResponse,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
|
|
@ -26,6 +28,7 @@ from app.schemas.trade_in import (
|
||||||
PhotoMeta,
|
PhotoMeta,
|
||||||
PlacementHistoryEntry,
|
PlacementHistoryEntry,
|
||||||
PriceHistoryYearPoint,
|
PriceHistoryYearPoint,
|
||||||
|
PriceTrendPoint,
|
||||||
QuotaStatus,
|
QuotaStatus,
|
||||||
RecentSoldEntry,
|
RecentSoldEntry,
|
||||||
SalesListingPair,
|
SalesListingPair,
|
||||||
|
|
@ -91,6 +94,52 @@ def _assert_estimate_access_by_id(
|
||||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
_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)
|
@router.post("/estimate", response_model=AggregatedEstimate)
|
||||||
async def estimate(
|
async def estimate(
|
||||||
payload: TradeInEstimateInput,
|
payload: TradeInEstimateInput,
|
||||||
|
|
@ -166,7 +215,7 @@ 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
|
asking_to_sold_ratio, ratio_basis, created_by, created_at
|
||||||
FROM trade_in_estimates
|
FROM trade_in_estimates
|
||||||
WHERE id = CAST(:id AS uuid)
|
WHERE id = CAST(:id AS uuid)
|
||||||
AND expires_at > NOW()
|
AND expires_at > NOW()
|
||||||
|
|
@ -180,11 +229,56 @@ def get_estimate(
|
||||||
|
|
||||||
_assert_estimate_access(row.created_by, x_authenticated_user)
|
_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 [])]
|
analogs = [AnalogLot(**a) for a in (row.analogs or [])]
|
||||||
actual_deals = [AnalogLot(**a) for a in (row.actual_deals 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 / координат — и при открытии
|
# без sources_used / confidence_explanation / координат — и при открытии
|
||||||
# оценки по ссылке (?id=) карточка источников пустела до «0/7».
|
# оценки по ссылке (?id=) карточка источников пустела до «0/7».
|
||||||
|
|
@ -209,6 +303,11 @@ def get_estimate(
|
||||||
target_lon=row.lon,
|
target_lon=row.lon,
|
||||||
sources_used=row.sources_used or [],
|
sources_used=row.sources_used or [],
|
||||||
data_freshness_minutes=row.data_freshness_minutes,
|
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.
|
# #648 Stage 3 — sold-correction columns rehydrated for shared-link reopen.
|
||||||
# (numeric ratio → float for the Pydantic field.)
|
# (numeric ratio → float for the Pydantic field.)
|
||||||
expected_sold_price_rub=row.expected_sold_price,
|
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,
|
asking_to_sold_ratio=0.85,
|
||||||
ratio_basis="per_rooms",
|
ratio_basis="per_rooms",
|
||||||
created_by=created_by,
|
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."""
|
"""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
|
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")
|
real_estimator = sys.modules.get("app.services.estimator")
|
||||||
sys.modules["app.services.estimator"] = estimator_stub # type: ignore[assignment]
|
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
|
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:
|
def test_get_estimate_requires_authenticated_user(trade_in_app: FastAPI) -> None:
|
||||||
"""No X-Authenticated-User header → 401."""
|
"""No X-Authenticated-User header → 401."""
|
||||||
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))
|
db_mock = _make_db_mock(_make_estimate_row(created_by="kopylov"))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue