UI: один адрес на шаге с координатами + рабочее геокодирование + авто«прошло времени»

- Step3Circumstances: удалено дублирующее «Последнее известное место»
  (last_known_place); остаётся только tnp_address на шаге координат.
- Step4Environment: кнопка «Найти координаты» теперь РАБОЧАЯ — вызывает
  новый GET /api/v1/geocode (прокси Nominatim, countrycodes=by, короткий
  запрос «пункт, адрес» по граблям geocoding), заполняет lat/lon,
  показывает найденный display_name или ошибку. Без debounce-автоплейса
  (Pitfall 2) — только явный клик.
- Step3Circumstances: «Прошло времени» считается автоматически из
  lost_time (пересчёт каждые 30 с, округление до 0.1 ч); поле остаётся
  редактируемым для корректировки.
- БАГФИКС: форма шлёт lost_time, модель хранит loss_time — алиас в
  _normalize_case_data. Раньше время пропажи молча терялось
  (Pydantic игнорирует неизвестные поля) — elapsed_hours приходилось
  вводить руками.
- last_known_place убран из валидации шага 4.
- backend/routers/geocode.py: Nominatim-прокси с auth (operator/field/admin).

192 passed, JSX валиден.
This commit is contained in:
2026-09-09 19:18:20 +03:00
parent 6ec9536203
commit fa466fc5ed
6 changed files with 177 additions and 29 deletions
+4
View File
@@ -67,6 +67,10 @@ def _normalize_case_data(data: dict[str, Any]) -> dict[str, Any]:
'last_known_place': 'tnp_address',
'reason': 'loss_reason',
'clothes_shoes': 'shoes',
# Форма шлёт lost_time (datetime-local), модель хранит loss_time.
# До 2026-09-09 время пропажи молча терялось (Pydantic игнорирует
# неизвестные поля) — elapsed_hours приходилось вводить руками.
'lost_time': 'loss_time',
}
for source, target in aliases.items():
if source in normalized and target not in normalized:
+2
View File
@@ -12,6 +12,7 @@ from backend.routers.analyze import router as analyze_router
from backend.routers.auth import router as auth_router
from backend.routers.cases import router as cases_router
from backend.routers.closed_cases import router as closed_cases_router
from backend.routers.geocode import router as geocode_router
from backend.routers.health import router as health_router
from backend.routers.stats import router as stats_router
from backend.routers.water import router as water_router
@@ -53,6 +54,7 @@ app.include_router(closed_cases_router)
app.include_router(stats_router)
app.include_router(admin_router)
app.include_router(water_router)
app.include_router(geocode_router)
@app.get('/')
+74
View File
@@ -0,0 +1,74 @@
"""Геокодирование: прокси к Nominatim (OpenStreetMap).
GET /api/v1/geocode?q=<запрос>&limit=5
Возвращает {results: [{lat, lng, display_name, type}]}.
Nominatim Usage Policy: User-Agent обязателен, ≤1 rps — проксируем через
бэкенд с корректным UA и ограничением countrycodes=by.
Правила запросов (nominatim-грабли, см. homelab-ops skill):
- Короткий запрос: пункт + адрес, БЕЗ района/области.
- Пункт ПЕРВЫМ словом.
"""
import os
from typing import List, Optional
import httpx
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from backend.routers.auth import require_roles
from fastapi import Depends
router = APIRouter(prefix='/api/v1/geocode', tags=['geocode'])
NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search'
USER_AGENT = os.getenv('NOMINATIM_USER_AGENT', 'Vector-MChS/1.0 (geocoding proxy)')
COUNTRY_CODE = 'by'
class GeocodeResult(BaseModel):
lat: float
lng: float
display_name: str
type: Optional[str] = None
class GeocodeResponse(BaseModel):
results: List[GeocodeResult]
@router.get('', response_model=GeocodeResponse,
dependencies=[Depends(require_roles(['operator', 'field', 'admin']))])
async def geocode(
q: str = Query(..., min_length=2, description='Адрес: «пункт, улица»'),
limit: int = Query(5, ge=1, le=10),
):
"""Геокодирование через Nominatim, ограничено Беларусью."""
headers = {'User-Agent': USER_AGENT}
params = {
'q': q.strip(),
'format': 'jsonv2',
'limit': limit,
'countrycodes': COUNTRY_CODE,
'addressdetails': 1,
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(NOMINATIM_URL, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail='Сервис геокодирования не ответил')
except Exception as e:
raise HTTPException(status_code=502, detail=f'Ошибка геокодирования: {e}')
return {'results': [
GeocodeResult(
lat=float(item['lat']),
lng=float(item['lon']),
display_name=item.get('display_name', ''),
type=item.get('type'),
)
for item in data
]}