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

Две правки под предзаполнение формы оценки из справочника 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:
bot-backend 2026-08-29 23:24:12 +03:00
parent e523c8949c
commit 2b2e742d92
3 changed files with 285 additions and 1 deletions

View file

@ -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",
)

View file

@ -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

View 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