feat(tradein): этаж/этажность optional + best test presets по deal count #558
8 changed files with 133 additions and 56 deletions
|
|
@ -16,8 +16,8 @@ class TradeInEstimateInput(BaseModel):
|
||||||
address: str = Field(min_length=3, max_length=500)
|
address: str = Field(min_length=3, max_length=500)
|
||||||
area_m2: float = Field(gt=10, lt=500)
|
area_m2: float = Field(gt=10, lt=500)
|
||||||
rooms: int = Field(ge=0, le=10) # 0 = студия
|
rooms: int = Field(ge=0, le=10) # 0 = студия
|
||||||
floor: int = Field(ge=1, le=100)
|
floor: int | None = Field(default=None, ge=1, le=100)
|
||||||
total_floors: int = Field(ge=1, le=100)
|
total_floors: int | None = Field(default=None, ge=1, le=100)
|
||||||
year_built: int | None = Field(default=None, ge=1800, le=2100)
|
year_built: int | None = Field(default=None, ge=1800, le=2100)
|
||||||
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
|
house_type: Literal["panel", "brick", "monolith", "monolith_brick", "other"] | None = None
|
||||||
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
|
repair_state: Literal["needs_repair", "standard", "good", "excellent"] | None = None
|
||||||
|
|
|
||||||
|
|
@ -741,7 +741,7 @@ async def estimate_quality(payload: TradeInEstimateInput, db: Session) -> Aggreg
|
||||||
if analog_tier == "S":
|
if analog_tier == "S":
|
||||||
tier_note = " (аналоги из того же дома)"
|
tier_note = " (аналоги из того же дома)"
|
||||||
elif analog_tier == "H":
|
elif analog_tier == "H":
|
||||||
tf_str = f"{payload.total_floors}-эт." if payload.total_floors else ""
|
tf_str = f"{payload.total_floors}-эт." if payload.total_floors is not None else ""
|
||||||
yr_str = f"{target_year}±15 г." if target_year else ""
|
yr_str = f"{target_year}±15 г." if target_year else ""
|
||||||
parts_str = ", ".join(p for p in [yr_str, tf_str] if p)
|
parts_str = ", ".join(p for p in [yr_str, tf_str] if p)
|
||||||
tier_note = f" (аналоги из домов того же класса: {parts_str})" if parts_str else ""
|
tier_note = f" (аналоги из домов того же класса: {parts_str})" if parts_str else ""
|
||||||
|
|
|
||||||
|
|
@ -261,8 +261,8 @@ def _build_cover(
|
||||||
address = _html.escape(address_short or full_address)
|
address = _html.escape(address_short or full_address)
|
||||||
area = input_snapshot.get("area_m2", 0)
|
area = input_snapshot.get("area_m2", 0)
|
||||||
rooms = input_snapshot.get("rooms", 0)
|
rooms = input_snapshot.get("rooms", 0)
|
||||||
floor = input_snapshot.get("floor", 0)
|
floor = input_snapshot.get("floor") or "—"
|
||||||
total_floors = input_snapshot.get("total_floors", 0)
|
total_floors = input_snapshot.get("total_floors") or "—"
|
||||||
year_built = input_snapshot.get("year_built", "—")
|
year_built = input_snapshot.get("year_built", "—")
|
||||||
house_type = input_snapshot.get("house_type")
|
house_type = input_snapshot.get("house_type")
|
||||||
repair_state = input_snapshot.get("repair_state")
|
repair_state = input_snapshot.get("repair_state")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- 2026-05-25: PR #558 — floor/total_floors стали optional в Pydantic schema и форме.
|
||||||
|
-- Снимаем NOT NULL constraint чтобы INSERT в estimator.py / _empty_estimate
|
||||||
|
-- не крашился на NotNullViolation когда user не заполнил эти поля.
|
||||||
|
BEGIN;
|
||||||
|
ALTER TABLE trade_in_estimates ALTER COLUMN floor DROP NOT NULL;
|
||||||
|
ALTER TABLE trade_in_estimates ALTER COLUMN total_floors DROP NOT NULL;
|
||||||
|
COMMENT ON COLUMN trade_in_estimates.floor IS
|
||||||
|
'optional — UI делает поле необязательным с PR #558 (2026-05-25)';
|
||||||
|
COMMENT ON COLUMN trade_in_estimates.total_floors IS
|
||||||
|
'optional — UI делает поле необязательным с PR #558 (2026-05-25)';
|
||||||
|
COMMIT;
|
||||||
58
tradein-mvp/backend/tests/test_estimator_floor_optional.py
Normal file
58
tradein-mvp/backend/tests/test_estimator_floor_optional.py
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
"""Verifies estimator does not crash on NotNullViolation when floor/total_floors=None.
|
||||||
|
|
||||||
|
Uses MagicMock for DB (consistent with other test_estimator_* tests — no live DB needed).
|
||||||
|
The test validates schema construction + that floor=None is accepted without ValueError.
|
||||||
|
The actual NOT NULL guard is covered by migration 065.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost/test_db")
|
||||||
|
|
||||||
|
from app.schemas.trade_in import TradeInEstimateInput
|
||||||
|
|
||||||
|
|
||||||
|
def test_trade_in_input_floor_none_accepted() -> None:
|
||||||
|
"""TradeInEstimateInput accepts floor=None without ValidationError (PR #558)."""
|
||||||
|
payload = TradeInEstimateInput(
|
||||||
|
address="Екатеринбург, ул. Малышева, 84",
|
||||||
|
area_m2=55,
|
||||||
|
rooms=2,
|
||||||
|
floor=None,
|
||||||
|
total_floors=None,
|
||||||
|
has_balcony=False,
|
||||||
|
)
|
||||||
|
assert payload.floor is None
|
||||||
|
assert payload.total_floors is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_trade_in_input_floor_present_accepted() -> None:
|
||||||
|
"""TradeInEstimateInput still validates positive floor values correctly."""
|
||||||
|
payload = TradeInEstimateInput(
|
||||||
|
address="Екатеринбург, ул. Малышева, 84",
|
||||||
|
area_m2=55,
|
||||||
|
rooms=2,
|
||||||
|
floor=3,
|
||||||
|
total_floors=9,
|
||||||
|
has_balcony=True,
|
||||||
|
)
|
||||||
|
assert payload.floor == 3
|
||||||
|
assert payload.total_floors == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimator_tf_str_none_safe() -> None:
|
||||||
|
"""estimator tier-H note: tf_str with total_floors=None yields empty string (not '0-эт.')."""
|
||||||
|
# Inline the logic from estimator.py:744 to verify the `is not None` guard.
|
||||||
|
total_floors: int | None = None
|
||||||
|
tf_str = f"{total_floors}-эт." if total_floors is not None else ""
|
||||||
|
assert tf_str == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_estimator_tf_str_zero_safe() -> None:
|
||||||
|
"""total_floors=0 (edge case) also produces empty string via is not None guard."""
|
||||||
|
total_floors: int | None = 0
|
||||||
|
tf_str = f"{total_floors}-эт." if total_floors is not None else ""
|
||||||
|
# 0 passes `is not None` so it would render — this matches existing behavior
|
||||||
|
# (0 is schema-invalid via ge=1, so this is a theoretical edge case only)
|
||||||
|
assert tf_str == "0-эт."
|
||||||
|
|
@ -54,11 +54,18 @@ function validate(f: FormState): string | null {
|
||||||
if (!f.area_m2 || isNaN(a) || a <= 10 || a >= 500) return "Площадь: от 10 до 500 м²";
|
if (!f.area_m2 || isNaN(a) || a <= 10 || a >= 500) return "Площадь: от 10 до 500 м²";
|
||||||
const r = Number(f.rooms);
|
const r = Number(f.rooms);
|
||||||
if (f.rooms === "" || isNaN(r) || r < 0 || r > 10) return "Комнат: 0 (студия) — 10";
|
if (f.rooms === "" || isNaN(r) || r < 0 || r > 10) return "Комнат: 0 (студия) — 10";
|
||||||
const fl = Number(f.floor);
|
if (f.floor !== "") {
|
||||||
if (!f.floor || isNaN(fl) || fl < 1 || fl > 100) return "Этаж: 1—100";
|
const fl = Number(f.floor);
|
||||||
const tf = Number(f.total_floors);
|
if (isNaN(fl) || fl < 1 || fl > 100) return "Этаж: 1—100";
|
||||||
if (!f.total_floors || isNaN(tf) || tf < 1 || tf > 100) return "Этажей в доме: 1—100";
|
if (f.total_floors !== "") {
|
||||||
if (fl > tf) return "Этаж не может быть больше этажей в доме";
|
const tf = Number(f.total_floors);
|
||||||
|
if (!isNaN(tf) && fl > tf) return "Этаж не может быть больше этажей в доме";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (f.total_floors !== "") {
|
||||||
|
const tf = Number(f.total_floors);
|
||||||
|
if (isNaN(tf) || tf < 1 || tf > 100) return "Этажей в доме: 1—100";
|
||||||
|
}
|
||||||
if (f.year_built !== "") {
|
if (f.year_built !== "") {
|
||||||
const y = Number(f.year_built);
|
const y = Number(f.year_built);
|
||||||
if (isNaN(y) || y < 1800 || y > 2100) return "Год постройки: 1800—2100";
|
if (isNaN(y) || y < 1800 || y > 2100) return "Год постройки: 1800—2100";
|
||||||
|
|
@ -71,8 +78,8 @@ function toPayload(f: FormState): TradeInEstimateInput {
|
||||||
address: f.address.trim(),
|
address: f.address.trim(),
|
||||||
area_m2: Number(f.area_m2),
|
area_m2: Number(f.area_m2),
|
||||||
rooms: Number(f.rooms),
|
rooms: Number(f.rooms),
|
||||||
floor: Number(f.floor),
|
floor: f.floor !== "" ? Number(f.floor) : null,
|
||||||
total_floors: Number(f.total_floors),
|
total_floors: f.total_floors !== "" ? Number(f.total_floors) : null,
|
||||||
has_balcony: f.has_balcony,
|
has_balcony: f.has_balcony,
|
||||||
};
|
};
|
||||||
if (f.year_built !== "") out.year_built = Number(f.year_built);
|
if (f.year_built !== "") out.year_built = Number(f.year_built);
|
||||||
|
|
@ -212,7 +219,7 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label" htmlFor="floor">
|
<label className="field-label" htmlFor="floor">
|
||||||
Этаж <span className="req">*</span>
|
Этаж <span className="hint">если знаешь</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="control mono"
|
className="control mono"
|
||||||
|
|
@ -223,12 +230,11 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||||
max={100}
|
max={100}
|
||||||
value={form.floor}
|
value={form.floor}
|
||||||
onChange={field("floor")}
|
onChange={field("floor")}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label" htmlFor="totalfloor">
|
<label className="field-label" htmlFor="totalfloor">
|
||||||
Всего этажей <span className="req">*</span>
|
Всего этажей <span className="hint">если знаешь</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="control mono"
|
className="control mono"
|
||||||
|
|
@ -239,10 +245,12 @@ export function EstimateForm({ onSubmit, isPending, error }: Props) {
|
||||||
max={100}
|
max={100}
|
||||||
value={form.total_floors}
|
value={form.total_floors}
|
||||||
onChange={field("total_floors")}
|
onChange={field("total_floors")}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<small style={{ fontSize: 11, color: "var(--muted)", marginTop: -4, display: "block" }}>
|
||||||
|
1-й и последний этаж снижают цену на 5–10% — заполни если знаешь
|
||||||
|
</small>
|
||||||
|
|
||||||
{/* Year + House type */}
|
{/* Year + House type */}
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ interface Preset {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: Preset[] = [
|
const PRESETS: Preset[] = [
|
||||||
|
{
|
||||||
|
label: "Академика Ландау · 1к/38м²",
|
||||||
|
hint: "Академический новостройки · 686 сделок/год",
|
||||||
|
data: {
|
||||||
|
address: "Екатеринбург, ул. Академика Ландау, 27",
|
||||||
|
area_m2: 38,
|
||||||
|
rooms: 1,
|
||||||
|
floor: 12,
|
||||||
|
total_floors: 25,
|
||||||
|
year_built: 2015,
|
||||||
|
house_type: "monolith",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Малышева 84 · 2к/55м²",
|
label: "Малышева 84 · 2к/55м²",
|
||||||
hint: "Центр · 36 аналогов",
|
hint: "Центр · 36 аналогов",
|
||||||
|
|
@ -27,37 +40,37 @@ const PRESETS: Preset[] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Куйбышева 48 · 3к/75м²",
|
label: "Краснолесья · 3к/75м²",
|
||||||
hint: "Парковый · 22 аналога",
|
hint: "Академический эконом · 487 сделок/год",
|
||||||
data: {
|
data: {
|
||||||
address: "Екатеринбург, ул. Куйбышева, 48",
|
address: "Екатеринбург, ул. Краснолесья, 30",
|
||||||
area_m2: 75,
|
area_m2: 75,
|
||||||
rooms: 3,
|
rooms: 3,
|
||||||
floor: 7,
|
floor: 7,
|
||||||
total_floors: 12,
|
total_floors: 16,
|
||||||
year_built: 2005,
|
year_built: 2010,
|
||||||
house_type: "monolith",
|
house_type: "panel",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Цвиллинга 58 · 1к/46м²",
|
label: "Космонавтов 50 · 2к/55м²",
|
||||||
hint: "Гольфстрим · 17 аналогов",
|
hint: "Уралмаш бюджет · 415 сделок/год",
|
||||||
data: {
|
data: {
|
||||||
address: "Екатеринбург, улица Цвиллинга, 58",
|
address: "Екатеринбург, ул. Космонавтов, 50",
|
||||||
area_m2: 46,
|
area_m2: 55,
|
||||||
rooms: 1,
|
rooms: 2,
|
||||||
floor: 4,
|
floor: 5,
|
||||||
total_floors: 25,
|
total_floors: 9,
|
||||||
year_built: 2020,
|
year_built: 1975,
|
||||||
house_type: "monolith",
|
house_type: "panel",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "8 Марта 50 · 1к/45м²",
|
label: "8 Марта 80 · 1к/35м²",
|
||||||
hint: "Ленинский · 19 аналогов",
|
hint: "Центр Ленинский · 305 сделок/год",
|
||||||
data: {
|
data: {
|
||||||
address: "Екатеринбург, ул. 8 Марта, 50",
|
address: "Екатеринбург, ул. 8 Марта, 80",
|
||||||
area_m2: 45,
|
area_m2: 35,
|
||||||
rooms: 1,
|
rooms: 1,
|
||||||
floor: 3,
|
floor: 3,
|
||||||
total_floors: 9,
|
total_floors: 9,
|
||||||
|
|
@ -66,31 +79,18 @@ const PRESETS: Preset[] = [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Малышева 84 · 3к/68м²",
|
label: "Московская · 2к/55м²",
|
||||||
hint: "Центр · 33 аналога",
|
hint: "Центр премиум · 426 сделок/год",
|
||||||
data: {
|
data: {
|
||||||
address: "Екатеринбург, ул. Малышева, 84",
|
address: "Екатеринбург, ул. Московская, 195",
|
||||||
area_m2: 68,
|
area_m2: 55,
|
||||||
rooms: 3,
|
rooms: 2,
|
||||||
floor: 5,
|
floor: 6,
|
||||||
total_floors: 9,
|
total_floors: 9,
|
||||||
year_built: 1980,
|
year_built: 1980,
|
||||||
house_type: "brick",
|
house_type: "brick",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Белинского 200 · 2к/50м²",
|
|
||||||
hint: "ЮЗ · 7 аналогов · премиум",
|
|
||||||
data: {
|
|
||||||
address: "Екатеринбург, ул. Белинского, 200",
|
|
||||||
area_m2: 50,
|
|
||||||
rooms: 2,
|
|
||||||
floor: 6,
|
|
||||||
total_floors: 10,
|
|
||||||
year_built: 1990,
|
|
||||||
house_type: "panel",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ export interface TradeInEstimateInput {
|
||||||
address: string; // min 3, max 500
|
address: string; // min 3, max 500
|
||||||
area_m2: number; // 10 < x < 500
|
area_m2: number; // 10 < x < 500
|
||||||
rooms: number; // 0-10 (0 = студия)
|
rooms: number; // 0-10 (0 = студия)
|
||||||
floor: number; // 1-100
|
floor: number | null; // 1-100, optional
|
||||||
total_floors: number; // 1-100
|
total_floors: number | null; // 1-100, optional
|
||||||
year_built?: number; // 1800-2100
|
year_built?: number; // 1800-2100
|
||||||
house_type?: HouseType;
|
house_type?: HouseType;
|
||||||
repair_state?: RepairState;
|
repair_state?: RepairState;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue