feat(mera/estimate): ручка фактов дома для предзаполнения формы + гейт «этаж не выше дома»
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
Две правки под предзаполнение формы оценки из справочника houses (наполнен из
ГИС ЖКХ + DOM.РФ: total_floors 98.3%, year_built 86.8%).
1. GET /api/v1/geocode/house-facts?lat=&lon=[&fias_id=] отдаёт
{found, total_floors, year_built, house_type, source}. Переиспользует
_lookup_house_facts из estimator (#3234) — тот же поиск, что и при расчёте:
по target_house_id если ФИАС разрезолвился, иначе ближайший дом в 60 м.
Дома нет в справочнике → 200 с found=false, а не 404: это не ошибка запроса,
а честный ответ об отсутствии данных.
Отдельной ручкой, а не полем в /suggest, намеренно: suggest дёргается на
каждое нажатие клавиши, и запрос к houses на каждый из 8 кандидатов на каждый
keystroke был бы неоправданной нагрузкой. house-facts зовётся ОДИН раз, после
выбора конкретного адреса.
Резолв дома по ФИАС обёрнут в try/except с rollback — предзаполнение формы не
должно ронять запрос в 500, а незакрытая ошибка отравила бы сессию для
последующего geo-фолбэка. Тот же best-effort, что у _lookup_house_facts.
2. TradeInEstimateInput отклоняет floor > total_floors, когда заданы ОБА поля.
Частичный ввод легален — пользователь ещё печатает форму. Раньше «12 этаж в
9-этажном доме» уходил в расчёт молча и портил подбор аналогов по этажу.
Тесты: 10 штук в test_3257_house_facts_endpoint.py. Валидатор проверен вручную —
отклоняет 12/9, пропускает 9/9 и частичный ввод.
This commit is contained in:
parent
e523c8949c
commit
2b2e742d92
3 changed files with 285 additions and 1 deletions
|
|
@ -2,14 +2,17 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.services.estimator import _lookup_house_facts
|
||||
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -153,3 +156,103 @@ async def reverse(
|
|||
precision=result.precision,
|
||||
provider=result.provider,
|
||||
)
|
||||
|
||||
|
||||
class HouseFactsResponse(BaseModel):
|
||||
"""Ответ /house-facts."""
|
||||
|
||||
found: bool
|
||||
total_floors: int | None = None
|
||||
year_built: int | None = None
|
||||
house_type: str | None = None
|
||||
source: str | None = Field(
|
||||
default=None, description="'houses' когда факты найдены в справочнике, иначе None"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_house_id_by_fias(db: Session, fias_id: str) -> int | None:
|
||||
"""Ищет id дома в `houses` по ФИАС/ГАР guid — по трём алиасам guid-полей
|
||||
справочника (заполняются из разных источников загрузки: DOM.РФ/ГИС ЖКХ/ГАР).
|
||||
Best-effort: не нашли — вызывающий молча падает на geo-фолбэк по lat/lon.
|
||||
"""
|
||||
try:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM houses
|
||||
WHERE gar_house_guid = CAST(:g AS text)
|
||||
OR house_fias_id = CAST(:g AS text)
|
||||
OR zhkh_house_guid = CAST(:g AS text)
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"g": fias_id},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
# Тот же best-effort, что и у _lookup_house_facts ниже по цепочке:
|
||||
# предзаполнение формы НЕ должно ронять запрос в 500. Откатываем
|
||||
# транзакцию, чтобы не отравить сессию для последующего geo-фолбэка.
|
||||
logger.warning("house-facts: fias lookup failed (graceful): %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
return None
|
||||
if row is None:
|
||||
return None
|
||||
house_id = row["id"]
|
||||
return house_id if isinstance(house_id, int) else None
|
||||
|
||||
|
||||
@router.get("/house-facts", response_model=HouseFactsResponse)
|
||||
async def house_facts(
|
||||
lat: Annotated[float, Query(ge=-90, le=90)],
|
||||
lon: Annotated[float, Query(ge=-180, le=180)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
fias_id: Annotated[str | None, Query(max_length=64)] = None,
|
||||
) -> HouseFactsResponse:
|
||||
"""Предзаполнение формы оценки (этажность/год/тип дома) из справочника `houses`.
|
||||
|
||||
Покрытие справочника: total_floors — 98.3%, year_built — 86.8% (прод, 2026-08).
|
||||
Переиспользует ту же логику поиска, что и estimate_quality() (#3234): по
|
||||
target_house_id если резолвился ФИАС, иначе ближайший дом в радиусе 60м от
|
||||
lat/lon (ST_DWithin).
|
||||
|
||||
Отдельная ручка, а не поле в /suggest: suggest дёргается на КАЖДОЕ нажатие
|
||||
клавиши автокомплита (debounced, но всё равно несколько запросов на ввод
|
||||
адреса) — тащить туда ещё один запрос к houses на каждый из 8 кандидатов
|
||||
на каждый keystroke было бы неоправданной нагрузкой на БД. /house-facts
|
||||
вызывается ОДИН раз, после того как пользователь выбрал конкретный адрес
|
||||
из подсказок.
|
||||
|
||||
found=false (200, НЕ 404) означает «дома нет в справочнике» — это не
|
||||
ошибка запроса, а честный ответ об отсутствии данных.
|
||||
|
||||
Пример:
|
||||
/api/v1/geocode/house-facts?lat=56.838&lon=60.595
|
||||
/api/v1/geocode/house-facts?lat=56.838&lon=60.595&fias_id=...
|
||||
"""
|
||||
target_house_id: int | None = None
|
||||
if fias_id is not None:
|
||||
target_house_id = await asyncio.to_thread(_resolve_house_id_by_fias, db, fias_id)
|
||||
|
||||
facts = await asyncio.to_thread(
|
||||
_lookup_house_facts,
|
||||
db,
|
||||
target_house_id=target_house_id,
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
)
|
||||
if facts is None:
|
||||
return HouseFactsResponse(found=False)
|
||||
return HouseFactsResponse(
|
||||
found=True,
|
||||
total_floors=facts.total_floors,
|
||||
year_built=facts.year_built,
|
||||
house_type=facts.house_type,
|
||||
source="houses",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from datetime import date, datetime
|
|||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
from pydantic import BaseModel, Field, computed_field, model_validator
|
||||
|
||||
|
||||
class TradeInEstimateInput(BaseModel):
|
||||
|
|
@ -57,6 +57,21 @@ class TradeInEstimateInput(BaseModel):
|
|||
# закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе.
|
||||
consent: bool | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_floor_within_total_floors(self) -> TradeInEstimateInput:
|
||||
"""#3257: этаж не может быть выше этажности дома.
|
||||
|
||||
Частичный ввод легален (пользователь ещё печатает форму) — гейт срабатывает
|
||||
только когда ОБА поля заданы одновременно.
|
||||
"""
|
||||
if self.floor is not None and self.total_floors is not None:
|
||||
if self.floor > self.total_floors:
|
||||
raise ValueError(
|
||||
f"Этаж {self.floor} больше этажности дома {self.total_floors} — "
|
||||
"проверьте данные"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class AnalogLot(BaseModel):
|
||||
address: str
|
||||
|
|
|
|||
166
tradein-mvp/backend/tests/test_3257_house_facts_endpoint.py
Normal file
166
tradein-mvp/backend/tests/test_3257_house_facts_endpoint.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""#3257: предзаполнение формы оценки фактами дома.
|
||||
|
||||
Две независимые правки:
|
||||
1. TradeInEstimateInput — валидатор floor <= total_floors (когда оба заданы).
|
||||
2. GET /api/v1/geocode/house-facts — предзаполнение total_floors/year_built/
|
||||
house_type из справочника `houses` (переиспользует _lookup_house_facts).
|
||||
|
||||
Стиль моков БД/эндпоинта — как в test_geocode_reverse_api.py /
|
||||
test_3234_house_facts_fallback.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# DATABASE_URL required by config before any app import.
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
# WeasyPrint stub — not installed in CI without GTK.
|
||||
_wp_mock = MagicMock()
|
||||
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
from pydantic import ValidationError # noqa: E402
|
||||
|
||||
from app.api.v1 import geocode as geocode_module # noqa: E402
|
||||
from app.core.db import get_db # noqa: E402
|
||||
from app.schemas.trade_in import TradeInEstimateInput # noqa: E402
|
||||
from app.services.estimator import _HouseFacts # noqa: E402
|
||||
|
||||
# ── A. TradeInEstimateInput.floor <= total_floors ───────────────────────────
|
||||
|
||||
|
||||
def _payload(**overrides: object) -> dict:
|
||||
base: dict[str, object] = {"address": "ЕКБ, ул. Учителей, 18", "area_m2": 38.8, "rooms": 1}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_floor_equal_total_floors_is_allowed() -> None:
|
||||
model = TradeInEstimateInput(**_payload(floor=9, total_floors=9))
|
||||
assert model.floor == model.total_floors == 9
|
||||
|
||||
|
||||
def test_floor_less_than_total_floors_is_allowed() -> None:
|
||||
model = TradeInEstimateInput(**_payload(floor=4, total_floors=9))
|
||||
assert model.floor == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"floor": 5}, # total_floors не задан
|
||||
{"total_floors": 9}, # floor не задан
|
||||
{}, # оба не заданы
|
||||
],
|
||||
)
|
||||
def test_partial_input_skips_validator(kwargs: dict) -> None:
|
||||
"""Частичный ввод легален — гейт не срабатывает, пока не заданы ОБА поля."""
|
||||
model = TradeInEstimateInput(**_payload(**kwargs))
|
||||
assert model is not None
|
||||
|
||||
|
||||
def test_floor_greater_than_total_floors_is_rejected() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
TradeInEstimateInput(**_payload(floor=12, total_floors=9))
|
||||
message = str(exc_info.value)
|
||||
assert "12" in message
|
||||
assert "9" in message
|
||||
|
||||
|
||||
# ── B. GET /api/v1/geocode/house-facts ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app() -> FastAPI:
|
||||
application = FastAPI()
|
||||
application.include_router(geocode_module.router, prefix="/api/v1/geocode")
|
||||
application.dependency_overrides[get_db] = lambda: MagicMock()
|
||||
return application
|
||||
|
||||
|
||||
def test_house_facts_not_found_returns_200_found_false(app: FastAPI) -> None:
|
||||
client = TestClient(app)
|
||||
with patch(
|
||||
"app.api.v1.geocode._lookup_house_facts",
|
||||
return_value=None,
|
||||
):
|
||||
r = client.get("/api/v1/geocode/house-facts?lat=56.838&lon=60.595")
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {
|
||||
"found": False,
|
||||
"total_floors": None,
|
||||
"year_built": None,
|
||||
"house_type": None,
|
||||
"source": None,
|
||||
}
|
||||
|
||||
|
||||
def test_house_facts_found_returns_facts(app: FastAPI) -> None:
|
||||
facts = _HouseFacts(
|
||||
house_id=42,
|
||||
total_floors=9,
|
||||
year_built=1975,
|
||||
house_type="panel",
|
||||
material_walls=None,
|
||||
)
|
||||
client = TestClient(app)
|
||||
with patch(
|
||||
"app.api.v1.geocode._lookup_house_facts",
|
||||
return_value=facts,
|
||||
) as lookup_mock:
|
||||
r = client.get("/api/v1/geocode/house-facts?lat=56.838&lon=60.595")
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body == {
|
||||
"found": True,
|
||||
"total_floors": 9,
|
||||
"year_built": 1975,
|
||||
"house_type": "panel",
|
||||
"source": "houses",
|
||||
}
|
||||
# target_house_id не резолвился (fias_id не передан) → None прокинут дальше
|
||||
assert lookup_mock.call_args.kwargs["target_house_id"] is None
|
||||
assert lookup_mock.call_args.kwargs["lat"] == 56.838
|
||||
assert lookup_mock.call_args.kwargs["lon"] == 60.595
|
||||
|
||||
|
||||
def test_house_facts_resolves_house_id_by_fias(app: FastAPI) -> None:
|
||||
"""fias_id передан и резолвится в houses.id → он уезжает в _lookup_house_facts
|
||||
как target_house_id (обходя geo-фолбэк по lat/lon)."""
|
||||
facts = _HouseFacts(
|
||||
house_id=42,
|
||||
total_floors=9,
|
||||
year_built=1975,
|
||||
house_type="panel",
|
||||
material_walls=None,
|
||||
)
|
||||
client = TestClient(app)
|
||||
with (
|
||||
patch(
|
||||
"app.api.v1.geocode._resolve_house_id_by_fias",
|
||||
return_value=42,
|
||||
),
|
||||
patch(
|
||||
"app.api.v1.geocode._lookup_house_facts",
|
||||
return_value=facts,
|
||||
) as lookup_mock,
|
||||
):
|
||||
r = client.get("/api/v1/geocode/house-facts?lat=56.838&lon=60.595&fias_id=some-guid")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert lookup_mock.call_args.kwargs["target_house_id"] == 42
|
||||
|
||||
|
||||
def test_house_facts_missing_required_query_params_is_422(app: FastAPI) -> None:
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/v1/geocode/house-facts?lat=56.838")
|
||||
assert r.status_code == 422
|
||||
Loading…
Add table
Reference in a new issue