diff --git a/backend/database.py b/backend/database.py index ee40ecd..0b5b330 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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: diff --git a/backend/main.py b/backend/main.py index a898bb2..d795a1d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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('/') diff --git a/backend/routers/geocode.py b/backend/routers/geocode.py new file mode 100644 index 0000000..56e0fe5 --- /dev/null +++ b/backend/routers/geocode.py @@ -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 + ]} \ No newline at end of file diff --git a/frontend/src/components/CaseForm/CaseForm.jsx b/frontend/src/components/CaseForm/CaseForm.jsx index 9e3c668..d3fa222 100644 --- a/frontend/src/components/CaseForm/CaseForm.jsx +++ b/frontend/src/components/CaseForm/CaseForm.jsx @@ -51,9 +51,6 @@ const CaseForm = ({ isMobile = false }) => { if (!formData.lost_time) { newErrors.push("Укажите дату и время потери"); } - if (!formData.last_known_place) { - newErrors.push("Укажите последнее известное место"); - } } // Для десктопа: Шаг 5 = Среда и местность diff --git a/frontend/src/components/CaseForm/Step3Circumstances.jsx b/frontend/src/components/CaseForm/Step3Circumstances.jsx index 46665c8..91abf0b 100644 --- a/frontend/src/components/CaseForm/Step3Circumstances.jsx +++ b/frontend/src/components/CaseForm/Step3Circumstances.jsx @@ -1,10 +1,27 @@ -import React from "react"; +import React, { useEffect } from "react"; const Step3Circumstances = ({ data, updateData }) => { + // «Прошло времени» — автоподсчёт от времени потери (пересчёт каждые 30 с, + // чтобы долго открытая форма не показывала устаревшее значение). + useEffect(() => { + if (!data.lost_time) return; + const recalc = () => { + const lost = new Date(data.lost_time); + if (isNaN(lost.getTime())) return; + const hours = (Date.now() - lost.getTime()) / 3600000; + if (hours >= 0) { + updateData({ elapsed_hours: Math.round(hours * 10) / 10 }); + } + }; + recalc(); + const timer = setInterval(recalc, 30000); + return () => clearInterval(timer); + }, [data.lost_time]); + return (

Шаг 3: Обстоятельства потери

- +
@@ -59,22 +76,17 @@ const Step3Circumstances = ({ data, updateData }) => { updateData({ elapsed_hours: parseFloat(e.target.value) })} + value={data.elapsed_hours ?? ""} + onChange={(e) => updateData({ elapsed_hours: e.target.value === "" ? null : parseFloat(e.target.value) })} min="0" step="0.5" - placeholder="Например: 2.5" - /> -
- -
- - updateData({ last_known_place: e.target.value })} - placeholder="Деревня Заречье, у магазина" + placeholder="Рассчитается из времени потери" /> +

+ {data.lost_time + ? "Заполняется автоматически от времени потери; можно скорректировать" + : "Заполните дату и время потери — посчитается автоматически"} +

@@ -159,4 +171,4 @@ const Step3Circumstances = ({ data, updateData }) => { ); }; -export default Step3Circumstances; +export default Step3Circumstances; \ No newline at end of file diff --git a/frontend/src/components/CaseForm/Step4Environment.jsx b/frontend/src/components/CaseForm/Step4Environment.jsx index cc96fcc..3d09004 100644 --- a/frontend/src/components/CaseForm/Step4Environment.jsx +++ b/frontend/src/components/CaseForm/Step4Environment.jsx @@ -1,4 +1,5 @@ import React, { useEffect } from "react"; +import { apiFetch } from "../../api/client"; const Step4Environment = ({ data, updateData }) => { const toggleTerrain = (terrain) => { @@ -25,6 +26,40 @@ const Step4Environment = ({ data, updateData }) => { } }, [data.lost_time]); + const handleGeocode = async () => { + const query = (data.tnp_address || "").trim(); + if (query.length < 3) { + updateData({ geocode_error: "Введите адрес (минимум 3 символа)" }); + return; + } + updateData({ geocode_loading: true, geocode_error: null, geocode_found: null }); + try { + const resp = await apiFetch(`/api/v1/geocode?q=${encodeURIComponent(query)}&limit=1`); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `Ошибка ${resp.status}`); + } + const result = await resp.json(); + const hit = result.results && result.results[0]; + if (!hit) { + updateData({ + geocode_loading: false, + geocode_error: "Адрес не найден. Уточните: «пункт, адрес» без района/области.", + }); + return; + } + updateData({ + tnp_lat: hit.lat.toFixed(6), + tnp_lon: hit.lng.toFixed(6), + geocode_loading: false, + geocode_error: null, + geocode_found: hit.display_name, + }); + } catch (e) { + updateData({ geocode_loading: false, geocode_error: "Не удалось найти адрес: " + e.message }); + } + }; + const handleGetLocation = () => { if (!navigator.geolocation) { updateData({ gps_error: 'Геолокация не поддерживается вашим браузером' }); @@ -256,16 +291,40 @@ const Step4Environment = ({ data, updateData }) => {
- - updateData({ tnp_address: e.target.value })} - placeholder="Минск, ул. Ленина 10" - /> -

- Адрес будет преобразован в координаты автоматически -

+ +
+ updateData({ tnp_address: e.target.value })} + placeholder="Минск, ул. Ленина 10" + /> + +
+ {data.geocode_error && ( +

+ {data.geocode_error} +

+ )} + {data.geocode_found && ( +

+ Найдено: {data.geocode_found} +

+ )} + {!data.geocode_error && !data.geocode_found && ( +

+ Формат «пункт, адрес» (район и область не нужны). Координаты заполнятся в поля выше. +

+ )}
);