From 2b2e742d9263fe4a45820f7c7f65d837b82c70be Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 23:24:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(mera/estimate):=20=D1=80=D1=83=D1=87=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=84=D0=B0=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20+=20=D0=B3=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=20=C2=AB=D1=8D=D1=82=D0=B0=D0=B6=20=D0=BD=D0=B5=20=D0=B2=D1=8B?= =?UTF-8?q?=D1=88=D0=B5=20=D0=B4=D0=BE=D0=BC=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Две правки под предзаполнение формы оценки из справочника 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 и частичный ввод. --- tradein-mvp/backend/app/api/v1/geocode.py | 103 +++++++++++ tradein-mvp/backend/app/schemas/trade_in.py | 17 +- .../tests/test_3257_house_facts_endpoint.py | 166 ++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/backend/tests/test_3257_house_facts_endpoint.py diff --git a/tradein-mvp/backend/app/api/v1/geocode.py b/tradein-mvp/backend/app/api/v1/geocode.py index e9c25631..99ecfd9b 100644 --- a/tradein-mvp/backend/app/api/v1/geocode.py +++ b/tradein-mvp/backend/app/api/v1/geocode.py @@ -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", + ) diff --git a/tradein-mvp/backend/app/schemas/trade_in.py b/tradein-mvp/backend/app/schemas/trade_in.py index c25db60f..4517b236 100644 --- a/tradein-mvp/backend/app/schemas/trade_in.py +++ b/tradein-mvp/backend/app/schemas/trade_in.py @@ -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 diff --git a/tradein-mvp/backend/tests/test_3257_house_facts_endpoint.py b/tradein-mvp/backend/tests/test_3257_house_facts_endpoint.py new file mode 100644 index 00000000..6e08308f --- /dev/null +++ b/tradein-mvp/backend/tests/test_3257_house_facts_endpoint.py @@ -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 -- 2.45.3