feat(mera/estimate): ручка фактов дома для предзаполнения формы + гейт «этаж не выше дома» (#3257)
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Has been cancelled
Some checks failed
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 4m2s
Deploy Trade-In / build-backend (push) Successful in 1m4s
Deploy Trade-In / deploy (push) Has been cancelled
This commit is contained in:
parent
35ff431c48
commit
1d45ac0747
3 changed files with 285 additions and 1 deletions
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
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
|
from app.services.geocoder import GeocodeResult, geocode, reverse_geocode, suggest
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -153,3 +156,103 @@ async def reverse(
|
||||||
precision=result.precision,
|
precision=result.precision,
|
||||||
provider=result.provider,
|
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 typing import Any, Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, computed_field
|
from pydantic import BaseModel, Field, computed_field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class TradeInEstimateInput(BaseModel):
|
class TradeInEstimateInput(BaseModel):
|
||||||
|
|
@ -57,6 +57,21 @@ class TradeInEstimateInput(BaseModel):
|
||||||
# закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе.
|
# закрыто договором, а не UI-чекбоксом, и их фронт НЕ шлёт это поле вовсе.
|
||||||
consent: bool | None = None
|
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):
|
class AnalogLot(BaseModel):
|
||||||
address: str
|
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