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 (
+ {data.lost_time + ? "Заполняется автоматически от времени потери; можно скорректировать" + : "Заполните дату и время потери — посчитается автоматически"} +
- Адрес будет преобразован в координаты автоматически -
+ ++ {data.geocode_error} +
+ )} + {data.geocode_found && ( ++ Найдено: {data.geocode_found} +
+ )} + {!data.geocode_error && !data.geocode_found && ( ++ Формат «пункт, адрес» (район и область не нужны). Координаты заполнятся в поля выше. +
+ )}