feat(tradein): insufficient_data flag on AggregatedEstimate (#697)

When no comps/anchor are found the estimate returns median=0/range 0-0, and
the frontend can render a literal «0 ₽». Add a computed boolean
insufficient_data (= median_price_rub <= 0) so the UI shows an explicit
«недостаточно данных» state instead.

Implemented as a Pydantic @computed_field derived from median_price_rub —
correct automatically across all four AggregatedEstimate construction sites
(main estimate, _empty_estimate, and both GET-rehydration paths), no DB column,
no risk of per-site drift. Additive/serialized; existing fields untouched.

Refs #697
This commit is contained in:
bot-backend 2026-05-30 17:38:52 +03:00
parent 066fab11d3
commit 01cc7057cd
2 changed files with 62 additions and 1 deletions

View file

@ -9,7 +9,7 @@ from datetime import date, datetime
from typing import Any, Literal from typing import Any, Literal
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, computed_field
class TradeInEstimateInput(BaseModel): class TradeInEstimateInput(BaseModel):
@ -132,6 +132,18 @@ class AggregatedEstimate(BaseModel):
confidence: Literal["low", "medium", "high"] confidence: Literal["low", "medium", "high"]
confidence_explanation: str | None = None # «Найдено 15 аналогов, разброс ±7%» confidence_explanation: str | None = None # «Найдено 15 аналогов, разброс ±7%»
n_analogs: int n_analogs: int
@computed_field # type: ignore[prop-decorator]
@property
def insufficient_data(self) -> bool:
"""#697: нет пригодной оценки — комплов/якоря не нашлось → headline median=0.
Фронт по этому флагу рендерит явное «недостаточно данных», а не буквальный
«0 ». Производное от median_price_rub (==0 нет оценки), поэтому корректно
во всех конструкторах AggregatedEstimate автоматически (вкл. rehydrate на GET).
"""
return self.median_price_rub <= 0
period_months: int # 24 period_months: int # 24
analogs: list[AnalogLot] # top 5-10 listings analogs: list[AnalogLot] # top 5-10 listings
actual_deals: list[AnalogLot] # реальные продажи last 12 mo actual_deals: list[AnalogLot] # реальные продажи last 12 mo

View file

@ -0,0 +1,49 @@
"""#697 — AggregatedEstimate.insufficient_data computed flag.
median=0 (нет комплов/якоря) insufficient_data=True, чтобы фронт показал
«недостаточно данных», а не «0 ». Производное от median_price_rub.
"""
import os
from datetime import UTC, datetime
from uuid import uuid4
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.schemas.trade_in import AggregatedEstimate
def _estimate(median: int) -> AggregatedEstimate:
return AggregatedEstimate(
estimate_id=uuid4(),
median_price_rub=median,
range_low_rub=0 if median == 0 else median - 100,
range_high_rub=0 if median == 0 else median + 100,
median_price_per_m2=0 if median == 0 else 150_000,
confidence="low" if median == 0 else "medium",
n_analogs=0 if median == 0 else 12,
period_months=12,
analogs=[],
actual_deals=[],
expires_at=datetime(2026, 6, 1, tzinfo=UTC),
)
def test_insufficient_when_median_zero() -> None:
assert _estimate(0).insufficient_data is True
def test_sufficient_when_median_positive() -> None:
assert _estimate(6_900_000).insufficient_data is False
def test_flag_serialized_in_model_dump() -> None:
dump = _estimate(0).model_dump()
assert dump["insufficient_data"] is True
dump2 = _estimate(5_000_000).model_dump()
assert dump2["insufficient_data"] is False
def test_negative_median_is_insufficient() -> None:
# защита от случайного отрицательного headline.
assert _estimate(-1).insufficient_data is True