diff --git a/backend/database.py b/backend/database.py
index e903b16..ae56beb 100644
--- a/backend/database.py
+++ b/backend/database.py
@@ -2,15 +2,9 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
import os
-DATABASE_URL = os.getenv(
- "DATABASE_URL",
- "postgresql://postgres:postgres@postgres:5432/vector_mchs"
-)
-
+DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@postgres:5432/vector_mchs")
engine = create_engine(DATABASE_URL, echo=False)
-
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
-
Base = declarative_base()
@@ -24,3 +18,39 @@ def get_db():
def init_db():
Base.metadata.create_all(bind=engine)
+
+
+class _CompatDB:
+ def __init__(self):
+ self._cases = []
+
+ def create_case(self, **data):
+ raise NotImplementedError('compat db is for import only')
+
+ def list_cases(self):
+ return []
+
+ def get_case(self, case_id):
+ return None
+
+ def update_case(self, case_id, **patch):
+ return None
+
+ def delete_case(self, case_id):
+ return False
+
+ def stats(self):
+ return {
+ 'total_cases': 0,
+ 'found_alive_count': 0,
+ 'found_deceased_count': 0,
+ 'unknown_outcome_count': 0,
+ 'median_found_distance_km': None,
+ 'recent_activity_count': 0,
+ }
+
+ def heatmap(self):
+ return []
+
+
+db = _CompatDB()
diff --git a/backend/main.py b/backend/main.py
index 9840ffe..b0a3521 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1,47 +1,29 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from contextlib import asynccontextmanager
-import os
-from api.v1 import cases, analyze, auth, stats
+from backend.routers.admin import router as admin_router
+from backend.routers.analyze import router as analyze_router
+from backend.routers.cases import router as cases_router
+from backend.routers.health import router as health_router
+from backend.routers.stats import router as stats_router
+app = FastAPI(title='Vector API', version='0.1.0')
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- yield
-
-
-app = FastAPI(
- title="SAR-MCHS API",
- description="Search and Rescue Management System API",
- version="1.0.0",
- lifespan=lifespan
-)
-
-cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
app.add_middleware(
CORSMiddleware,
- allow_origins=cors_origins,
+ allow_origins=['*'],
allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
+ allow_methods=['*'],
+ allow_headers=['*'],
)
-app.include_router(auth.router, prefix="/api/v1/auth", tags=["Authentication"])
-app.include_router(cases.router, prefix="/api/v1", tags=["Cases"])
-app.include_router(analyze.router, prefix="/api/v1/analyze", tags=["Analysis"])
-app.include_router(stats.router, prefix="/api/v1/stats", tags=["Statistics"])
+app.include_router(health_router)
+app.include_router(analyze_router)
+app.include_router(cases_router)
+app.include_router(stats_router)
+app.include_router(admin_router)
-@app.get("/")
-async def root():
- return {
- "message": "SAR-MCHS API",
- "version": "1.0.0",
- "docs": "/docs"
- }
-
-
-@app.get("/health")
-async def health_check():
- return {"status": "healthy"}
+@app.get('/')
+def root() -> dict[str, str]:
+ return {'name': 'Vector API', 'status': 'ok'}
diff --git a/backend/routers/admin.py b/backend/routers/admin.py
new file mode 100644
index 0000000..0d0a4c9
--- /dev/null
+++ b/backend/routers/admin.py
@@ -0,0 +1,137 @@
+
+from io import BytesIO
+import re
+import xml.etree.ElementTree as ET
+import zipfile
+
+from fastapi import APIRouter, File, HTTPException, Query, UploadFile
+
+from backend.database import db
+from backend.schemas import CaseListResponse, CaseResponse, CaseUpdate, ParseDocResponse, DashboardResponse
+
+router = APIRouter(prefix='/api/v1/admin', tags=['admin'])
+
+
+def _parse_bool(value: str | None) -> bool | None:
+ if value is None:
+ return None
+ normalized = value.strip().lower()
+ if normalized in {'1', 'true', 'yes', 'y', 'да'}:
+ return True
+ if normalized in {'0', 'false', 'no', 'n', 'нет'}:
+ return False
+ return None
+
+
+def _extract_docx_text(content: bytes) -> str:
+ with zipfile.ZipFile(BytesIO(content)) as archive:
+ xml = archive.read('word/document.xml')
+ root = ET.fromstring(xml)
+ ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
+ paragraphs: list[str] = []
+ for paragraph in root.findall('.//w:body/w:p', ns):
+ texts = [node.text for node in paragraph.findall('.//w:t', ns) if node.text]
+ if texts:
+ paragraphs.append(''.join(texts).strip())
+ return '\n'.join(paragraphs).strip()
+
+
+def _extract_kv_pairs(raw_text: str) -> dict[str, str]:
+ parsed: dict[str, str] = {}
+ for line in raw_text.splitlines():
+ if ':' not in line:
+ continue
+ key, value = line.split(':', 1)
+ parsed[key.strip().lower()] = value.strip()
+ return parsed
+
+
+def _coerce_preview(raw_text: str) -> dict:
+ pairs = _extract_kv_pairs(raw_text)
+ case: dict[str, object] = {}
+
+ if 'age' in pairs:
+ try:
+ case['age'] = int(pairs['age'])
+ except ValueError:
+ pass
+ if 'gender' in pairs:
+ case['gender'] = pairs['gender']
+ if 'status' in pairs:
+ case['status'] = pairs['status']
+ if 'found alive' in pairs:
+ case['found_alive'] = _parse_bool(pairs['found alive'])
+ if 'found distance km' in pairs:
+ try:
+ case['found_distance_km'] = float(pairs['found distance km'])
+ except ValueError:
+ pass
+ if 'found lat' in pairs:
+ try:
+ case['found_lat'] = float(pairs['found lat'])
+ except ValueError:
+ pass
+ if 'found lon' in pairs:
+ try:
+ case['found_lon'] = float(pairs['found lon'])
+ except ValueError:
+ pass
+ if 'who found' in pairs:
+ case['who_found'] = pairs['who found']
+ if 'notes' in pairs:
+ case['notes'] = pairs['notes']
+ elif 'note' in pairs:
+ case['notes'] = pairs['note']
+
+ case_id = None
+ if 'case id' in pairs:
+ match = re.search(r'\d+', pairs['case id'])
+ if match:
+ case_id = match.group(0)
+
+ return {'raw_text': raw_text, 'case_id': case_id, 'case': case}
+
+
+@router.get('/cases', response_model=CaseListResponse)
+def admin_cases(
+ page: int = Query(default=1, ge=1),
+ page_size: int = Query(default=20, ge=1, le=100),
+ status: str | None = None,
+ age_min: int | None = Query(default=None, ge=0),
+ age_max: int | None = Query(default=None, ge=0),
+) -> dict:
+ cases = db.list_cases()
+ if status:
+ cases = [c for c in cases if c.status == status]
+ if age_min is not None:
+ cases = [c for c in cases if c.age is not None and c.age >= age_min]
+ if age_max is not None:
+ cases = [c for c in cases if c.age is not None and c.age <= age_max]
+ start = (page - 1) * page_size
+ end = start + page_size
+ return {'items': [c.to_detail() for c in cases[start:end]], 'total': len(cases), 'page': page, 'page_size': page_size}
+
+
+@router.patch('/cases/{case_id}', response_model=CaseResponse)
+def admin_update_case(case_id: str, payload: CaseUpdate) -> dict:
+ data = payload.model_dump(exclude_none=True)
+ note = data.pop('note', None)
+ if note is not None and 'notes' not in data:
+ data['notes'] = note
+ case = db.update_case(case_id, **data)
+ if not case:
+ raise HTTPException(status_code=404, detail='Case not found')
+ return case.to_detail()
+
+
+@router.get('/dashboard', response_model=DashboardResponse)
+def admin_dashboard() -> dict:
+ return db.stats()
+
+
+@router.post('/parse-doc', response_model=ParseDocResponse)
+async def admin_parse_doc(file: UploadFile = File(...)) -> dict:
+ content = await file.read()
+ raw_text = _extract_docx_text(content)
+ preview = _coerce_preview(raw_text)
+ return {'filename': file.filename, 'parsed': True, 'preview': preview, 'raw_text': raw_text}
diff --git a/backend/routers/analyze.py b/backend/routers/analyze.py
new file mode 100644
index 0000000..e6e40d6
--- /dev/null
+++ b/backend/routers/analyze.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+router = APIRouter(prefix='/api/v1/analyze', tags=['analyze'])
+
+
+@router.post('')
+def analyze_stub() -> dict:
+ return {'status': 'ok'}
diff --git a/backend/routers/cases.py b/backend/routers/cases.py
new file mode 100644
index 0000000..d259469
--- /dev/null
+++ b/backend/routers/cases.py
@@ -0,0 +1,59 @@
+from fastapi import APIRouter, HTTPException, Query
+
+from backend.database import db
+from backend.schemas import CaseCreate, CaseListResponse, CaseResponse, CaseUpdate
+
+router = APIRouter(prefix='/api/v1/cases', tags=['cases'])
+
+
+@router.post('', response_model=CaseResponse)
+def create_case(payload: CaseCreate) -> dict:
+ data = payload.model_dump(exclude_none=True)
+ gps = data.pop('gps', None) or {}
+ note = data.pop('note', None)
+ if note is not None and 'notes' not in data:
+ data['notes'] = note
+ if gps:
+ data['gps_lat'] = gps.get('lat')
+ data['gps_lon'] = gps.get('lon')
+ case = db.create_case(**data)
+ if data.get('status'):
+ db.update_case(case.id, status=data['status'])
+ return case.to_detail()
+
+
+@router.get('', response_model=CaseListResponse)
+def list_cases(page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100)) -> dict:
+ cases = db.list_cases()
+ start = (page - 1) * page_size
+ end = start + page_size
+ items = [c.to_detail() for c in cases[start:end]]
+ return {'items': items, 'total': len(cases), 'page': page, 'page_size': page_size}
+
+
+@router.get('/{case_id}', response_model=CaseResponse)
+def get_case(case_id: str) -> dict:
+ case = db.get_case(case_id)
+ if not case:
+ raise HTTPException(status_code=404, detail='Case not found')
+ return case.to_detail()
+
+
+@router.patch('/{case_id}', response_model=CaseResponse)
+def patch_case(case_id: str, payload: CaseUpdate) -> dict:
+ data = payload.model_dump(exclude_none=True)
+ note = data.pop('note', None)
+ if note is not None and 'notes' not in data:
+ data['notes'] = note
+ case = db.update_case(case_id, **data)
+ if not case:
+ raise HTTPException(status_code=404, detail='Case not found')
+ return case.to_detail()
+
+
+@router.delete('/{case_id}')
+def delete_case(case_id: str) -> dict[str, bool]:
+ deleted = db.delete_case(case_id)
+ if not deleted:
+ raise HTTPException(status_code=404, detail='Case not found')
+ return {'deleted': True}
diff --git a/backend/routers/health.py b/backend/routers/health.py
new file mode 100644
index 0000000..2b58100
--- /dev/null
+++ b/backend/routers/health.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+router = APIRouter(prefix='/api/v1/health', tags=['health'])
+
+
+@router.get('')
+def health() -> dict[str, str]:
+ return {'status': 'ok'}
diff --git a/backend/routers/stats.py b/backend/routers/stats.py
new file mode 100644
index 0000000..8ee9a89
--- /dev/null
+++ b/backend/routers/stats.py
@@ -0,0 +1,35 @@
+from fastapi import APIRouter
+
+from backend.database import db
+from backend.schemas import DashboardResponse, RecommendationRequest
+
+router = APIRouter(prefix='/api/v1/stats', tags=['stats'])
+
+
+@router.get('/summary', response_model=DashboardResponse)
+def stats_summary() -> dict:
+ return db.stats()
+
+
+@router.get('/heatmap')
+def stats_heatmap() -> dict:
+ return {'items': db.heatmap()}
+
+
+@router.post('/recommendation')
+def stats_recommendation(payload: RecommendationRequest) -> dict:
+ score = 0
+ if payload.age is not None and payload.age < 12:
+ score += 20
+ if payload.elapsed_hours is not None and payload.elapsed_hours >= 12:
+ score += 20
+ if payload.terrain_primary in {'лес', 'болото', 'вода'}:
+ score += 15
+ if payload.weather in {'дождь', 'туман', 'снег', 'ночь'}:
+ score += 15
+ if len(payload.health_flags) >= 2:
+ score += 15
+ return {
+ 'recommendation': 'Высокий приоритет на прочёс и дрон' if score >= 40 else 'Стандартный приоритет поиска',
+ 'score': score,
+ }
diff --git a/backend/schemas.py b/backend/schemas.py
index 68f700e..4fb2081 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -1,258 +1,129 @@
-from pydantic import BaseModel, Field, ConfigDict
-from typing import Optional, List, Dict, Any
+
+from __future__ import annotations
+
from datetime import datetime
-from uuid import UUID
+from typing import Any
+
+from pydantic import BaseModel, Field
class CaseCreate(BaseModel):
- """Schema for creating a new case"""
-
- # Ребёнок (Шаг 1)
- child_name: Optional[str] = None
- age_years: int = Field(..., ge=0, le=18)
- gender: Optional[str] = Field(None, pattern="^[МЖ]$")
- height_build: Optional[str] = None
- clothes_upper: Optional[str] = None
- clothes_lower: Optional[str] = None
- shoes: Optional[str] = None
- clothes_description: Optional[str] = None
- special_marks: Optional[str] = None
- phone_status: Optional[str] = Field(None, pattern="^(answers|silent|none)$")
-
- # Здоровье (Шаг 2)
- has_diagnosis: bool = False
- diagnosis_type: Optional[List[str]] = None
- fitness_level: Optional[str] = Field(None, pattern="^(low|medium|high)$")
- has_transport: str = Field(default="none", pattern="^(none|bike|scooter|other)$")
- cant_swim: bool = False
-
- # Психотип (Шаг 2б)
- psychotype: Optional[str] = None
- psychotype_answers: Optional[Dict[str, Any]] = None
-
- # Обстоятельства (Шаг 3)
- loss_reason: Optional[str] = None
- loss_time: Optional[datetime] = None
- elapsed_hours: Optional[float] = Field(None, ge=0)
- last_seen_direction: Optional[str] = None
- last_seen_reliability: Optional[str] = Field(None, pattern="^(exact|approx|unknown)$")
- last_seen_description: Optional[str] = None
- behavior_description: Optional[str] = None
- familiar_places: Optional[str] = None
- lost_before: Optional[str] = Field(None, pattern="^(yes|no|unknown)$")
-
- # Среда (Шаг 4)
- season: Optional[str] = None
- temperature_c: Optional[float] = None
- precipitation: Optional[str] = None
- visibility: Optional[str] = None
- wind: Optional[str] = None
- terrain: Optional[List[str]] = None
-
- # GPS (Шаг 4)
- tnp_lat: Optional[float] = Field(None, ge=-90, le=90)
- tnp_lon: Optional[float] = Field(None, ge=-180, le=180)
- tnp_address: Optional[str] = None
-
- # Ресурсы (Шаг 5)
- teams_count: Optional[int] = Field(None, ge=0)
- team_size: Optional[int] = Field(None, ge=0)
- has_dog: bool = False
- extra_resources: Optional[List[str]] = None
-
- model_config = ConfigDict(json_schema_extra={
- "example": {
- "child_name": "Иван",
- "age_years": 8,
- "gender": "М",
- "loss_reason": "потерялся в лесу",
- "season": "лето",
- "temperature_c": 22.0,
- "tnp_lat": 53.9,
- "tnp_lon": 27.56,
- "teams_count": 3,
- "team_size": 5
- }
- })
+ age: int | None = None
+ gender: str | None = None
+ health_flags: list[str] = Field(default_factory=list)
+ elapsed_hours: int | None = None
+ last_known_place: str | None = None
+ direction: str | None = None
+ terrain_primary: str | None = None
+ weather: str | None = None
+ resources: list[str] = Field(default_factory=list)
+ gps: dict[str, float] | None = None
+ notes: str | None = None
+ note: str | None = None
+ status: str = 'new'
+ found_alive: bool | None = None
+ found_distance_km: float | None = None
+ found_direction: str | None = None
+ found_location_type: str | None = None
+ found_lat: float | None = None
+ found_lon: float | None = None
+ who_found: str | None = None
+ mobile: bool = False
-class CaseOut(BaseModel):
- """Schema for case output"""
+class CaseUpdate(BaseModel):
+ age: int | None = None
+ gender: str | None = None
+ health_flags: list[str] | None = None
+ elapsed_hours: int | None = None
+ last_known_place: str | None = None
+ direction: str | None = None
+ terrain_primary: str | None = None
+ weather: str | None = None
+ resources: list[str] | None = None
+ gps_lat: float | None = None
+ gps_lon: float | None = None
+ notes: str | None = None
+ note: str | None = None
+ status: str | None = None
+ found_alive: bool | None = None
+ found_distance_km: float | None = None
+ found_direction: str | None = None
+ found_location_type: str | None = None
+ found_lat: float | None = None
+ found_lon: float | None = None
+ who_found: str | None = None
+ raw_text: str | None = None
+ analysis_log: str | None = None
- id: UUID
+
+class CaseResponse(BaseModel):
+ id: str
created_at: datetime
+ updated_at: datetime
+ age: int | None = None
+ gender: str | None = None
+ health_flags: list[str]
+ elapsed_hours: int | None = None
+ last_known_place: str | None = None
+ direction: str | None = None
+ terrain_primary: str | None = None
+ weather: str | None = None
+ resources: list[str]
+ gps_lat: float | None = None
+ gps_lon: float | None = None
+ notes: str | None = None
status: str
-
- # Ребёнок
- child_name: Optional[str] = None
- age_years: int
- gender: Optional[str] = None
- height_build: Optional[str] = None
- clothes_upper: Optional[str] = None
- clothes_lower: Optional[str] = None
- shoes: Optional[str] = None
- clothes_description: Optional[str] = None
- special_marks: Optional[str] = None
- phone_status: Optional[str] = None
-
- # Здоровье
- has_diagnosis: bool
- diagnosis_type: Optional[List[str]] = None
- fitness_level: Optional[str] = None
- has_transport: str
- cant_swim: bool
-
- # Психотип
- psychotype: Optional[str] = None
- psychotype_answers: Optional[Dict[str, Any]] = None
-
- # Обстоятельства
- loss_reason: Optional[str] = None
- loss_time: Optional[datetime] = None
- elapsed_hours: Optional[float] = None
- last_seen_direction: Optional[str] = None
- last_seen_reliability: Optional[str] = None
- last_seen_description: Optional[str] = None
- behavior_description: Optional[str] = None
- familiar_places: Optional[str] = None
- lost_before: Optional[str] = None
-
- # Среда
- season: Optional[str] = None
- temperature_c: Optional[float] = None
- precipitation: Optional[str] = None
- visibility: Optional[str] = None
- wind: Optional[str] = None
- terrain: Optional[List[str]] = None
-
- # GPS
- tnp_lat: Optional[float] = None
- tnp_lon: Optional[float] = None
- tnp_address: Optional[str] = None
-
- # Ресурсы
- teams_count: Optional[int] = None
- team_size: Optional[int] = None
- has_dog: bool
- extra_resources: Optional[List[str]] = None
-
- # Исход
- found_alive: Optional[bool] = None
- found_distance_km: Optional[float] = None
- found_direction: Optional[str] = None
- found_location_type: Optional[str] = None
- found_lat: Optional[float] = None
- found_lon: Optional[float] = None
- search_duration_hours: Optional[float] = None
- who_found: Optional[str] = None
-
- # Мета
- confidence_avg: Optional[float] = None
- raw_text: Optional[str] = None
- analysis_log: Optional[Dict[str, Any]] = None
-
- model_config = ConfigDict(from_attributes=True)
+ found_alive: bool | None = None
+ found_distance_km: float | None = None
+ found_direction: str | None = None
+ found_location_type: str | None = None
+ found_lat: float | None = None
+ found_lon: float | None = None
+ who_found: str | None = None
+ raw_text: str | None = None
+ analysis_log: str | None = None
+ result: dict[str, Any] | None = None
-class SearchZone(BaseModel):
- """Search zone with priority and details"""
- priority: int = Field(..., ge=1, le=3)
- name: str
- direction: str
- distance_km: float
- score: float
- reasoning: str
- coordinates: Optional[List[List[float]]] = None
+class CaseListResponse(BaseModel):
+ items: list[CaseResponse]
+ total: int
+ page: int
+ page_size: int
-class BehavioralProfile(BaseModel):
- """Active behavioral profile"""
- type: str
- title: str
- recommendations: List[str]
- modifiers: Dict[str, float]
+class DashboardResponse(BaseModel):
+ total_cases: int
+ found_alive_count: int
+ found_deceased_count: int
+ unknown_outcome_count: int
+ median_found_distance_km: float | None
+ recent_activity_count: int
-class AnalysisResult(BaseModel):
- """Complete analysis result"""
-
- case_id: UUID
- urgency_level: str = Field(..., pattern="^(КРИТИЧЕСКИЙ|ВЫСОКИЙ|УМЕРЕННЫЙ)$")
- urgency_reason: str
- time_window_hours: Optional[float] = None
-
- active_profiles: List[BehavioralProfile]
- immediate_actions: List[str] = Field(..., min_length=3, max_length=3)
-
- search_zones: List[SearchZone] = Field(..., min_length=1, max_length=3)
- key_objects: List[str]
-
- team_assignments: Optional[Dict[str, str]] = None
- behavioral_forecast: str
- dog_recommendations: Optional[List[str]] = None
-
- max_distance_km: float
- confidence_score: float = Field(..., ge=0, le=1)
-
- created_at: datetime
- execution_time: Optional[float] = None
-
- model_config = ConfigDict(json_schema_extra={
- "example": {
- "case_id": "123e4567-e89b-12d3-a456-426614174000",
- "urgency_level": "ВЫСОКИЙ",
- "urgency_reason": "Ребёнок 8 лет, прошло 4 часа, температура +15°C",
- "time_window_hours": 12.0,
- "active_profiles": [
- {
- "type": "age_8_12",
- "title": "Возраст 8-12 лет",
- "recommendations": ["Радиус поиска до 3 км", "Проверить дороги и тропы"],
- "modifiers": {"distance": 1.0, "roads": 1.2}
- }
- ],
- "immediate_actions": [
- "Перекрыть все дороги в радиусе 2 км",
- "Проверить водоёмы в радиусе 1 км",
- "Организовать оклик по имени"
- ],
- "search_zones": [
- {
- "priority": 1,
- "name": "Лесной массив северо-восток",
- "direction": "СВ",
- "distance_km": 1.2,
- "score": 0.85,
- "reasoning": "Последнее направление движения, густой лес"
- }
- ],
- "key_objects": ["Озеро Круглое (800м СВ)", "Лесная дорога (500м С)"],
- "behavioral_forecast": "Ребёнок скорее всего движется вдоль дороги или тропы",
- "max_distance_km": 3.5,
- "confidence_score": 0.82,
- "created_at": "2026-05-04T13:45:00Z"
- }
- })
+class RecommendationRequest(BaseModel):
+ age: int | None = None
+ gender: str | None = None
+ health_flags: list[str] = Field(default_factory=list)
+ elapsed_hours: int | None = None
+ terrain_primary: str | None = None
+ weather: str | None = None
+ resources: list[str] = Field(default_factory=list)
-class UserCreate(BaseModel):
- """Schema for creating a new user"""
- username: str = Field(..., min_length=3, max_length=100)
- email: str = Field(..., pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
- password: str = Field(..., min_length=8)
- full_name: Optional[str] = None
- role: str = Field(default="operator", pattern="^(operator|field|admin)$")
+class AnalyzeResponse(BaseModel):
+ urgency: str
+ primary_zones: list[dict[str, Any]]
+ immediate_actions: list[str]
+ behavioral_prediction: str
+ psychotype_recommendations: dict[str, Any]
+ key_locations: list[str]
+ case_id: str
-class UserOut(BaseModel):
- """Schema for user output"""
- id: UUID
- username: str
- email: str
- full_name: Optional[str] = None
- role: str
- is_active: bool
- created_at: datetime
- last_login: Optional[datetime] = None
-
- model_config = ConfigDict(from_attributes=True)
+class ParseDocResponse(BaseModel):
+ filename: str
+ parsed: bool
+ preview: dict[str, Any]
+ raw_text: str | None = None
diff --git a/backend/services/stats_service.py b/backend/services/stats_service.py
index 5db6f35..b8fa118 100644
--- a/backend/services/stats_service.py
+++ b/backend/services/stats_service.py
@@ -1,588 +1,11 @@
-"""
-Statistics service for case analysis and dashboard aggregates.
+from __future__ import annotations
-Provides statistical recommendations based on historical data:
-- Similar cases filtering (age ±2 years, season, terrain)
-- Median distance, top directions, survival rate
-- Dashboard aggregates
-"""
-from typing import Dict, List, Optional, Any
-from pydantic import BaseModel
-from sqlalchemy.orm import Session
-from sqlalchemy import func, and_, or_, text
-from models import Case
+from backend.database import db
-class DirectionFrequency(BaseModel):
- """Direction frequency statistics"""
- direction: str
- count: int
- percentage: float
+def summary() -> dict:
+ return db.stats()
-class StatisticalRecommendation(BaseModel):
- """Statistical recommendation based on historical data"""
- median_distance_km: float
- top_directions: List[DirectionFrequency]
- top_location_types: List[str]
- survival_rate: float
- sample_size: int
- filters_used: Dict[str, Any]
-
-
-class DashboardStats(BaseModel):
- """Dashboard aggregate statistics"""
- total_cases: int
- active_cases: int
- closed_cases: int
- by_gender: Dict[str, int]
- by_age_group: Dict[str, int]
- by_psychotype: Dict[str, int]
- by_diagnosis: Dict[str, int]
- by_season: Dict[str, int]
- avg_distance_km: Optional[float]
- avg_search_duration_hours: Optional[float]
- survival_rate: float
-
-
-def get_statistical_recommendation(
- case_data: dict,
- db: Session,
- min_sample_size: int = 5
-) -> StatisticalRecommendation:
- """
- Получает статистические рекомендации на основе похожих исторических случаев.
-
- Фильтры (в порядке приоритета):
- 1. Возраст ±2 года + сезон + terrain
- 2. Возраст ±2 года + сезон (если < 5 случаев)
- 3. Возраст ±2 года (если < 5 случаев)
- 4. Все случаи (если < 5 случаев)
-
- Args:
- case_data: Словарь с данными случая
- - age: возраст (обязательно)
- - season: сезон (опционально)
- - terrain_primary: тип местности (опционально)
- db: SQLAlchemy Session
- min_sample_size: Минимальный размер выборки (по умолчанию 5)
-
- Returns:
- StatisticalRecommendation: Статистические рекомендации
- """
- age = case_data.get('age')
- season = case_data.get('season')
- terrain = case_data.get('terrain_primary')
-
- if not age:
- raise ValueError("Age is required for statistical recommendation")
-
- # Попытка 1: Возраст ±2 года + сезон + terrain
- filters_used = {'age_range': f"{age-2} to {age+2}"}
- query = db.query(Case).filter(
- Case.age_years.between(age - 2, age + 2),
- Case.found_distance_km.isnot(None)
- )
-
- if season:
- query = query.filter(Case.season == season)
- filters_used['season'] = season
-
- if terrain and season:
- query = query.filter(Case.terrain.any(terrain))
- filters_used['terrain'] = terrain
-
- cases = query.all()
- sample_size = len(cases)
-
- # Попытка 2: Убираем terrain, если мало данных
- if sample_size < min_sample_size and terrain:
- filters_used.pop('terrain', None)
- query = db.query(Case).filter(
- Case.age_years.between(age - 2, age + 2),
- Case.found_distance_km.isnot(None)
- )
- if season:
- query = query.filter(Case.season == season)
-
- cases = query.all()
- sample_size = len(cases)
-
- # Попытка 3: Убираем season, если мало данных
- if sample_size < min_sample_size and season:
- filters_used.pop('season', None)
- query = db.query(Case).filter(
- Case.age_years.between(age - 2, age + 2),
- Case.found_distance_km.isnot(None)
- )
-
- cases = query.all()
- sample_size = len(cases)
-
- # Попытка 4: Все случаи с найденными детьми
- if sample_size < min_sample_size:
- filters_used = {"age_range": "all"}
- query = db.query(Case).filter(
- Case.found_distance_km.isnot(None)
- )
-
- cases = query.all()
- sample_size = len(cases)
-
- # Если данных нет совсем, возвращаем дефолтные значения
- if sample_size == 0:
- return StatisticalRecommendation(
- median_distance_km=2.0,
- top_directions=[
- DirectionFrequency(direction="N", count=0, percentage=0.0),
- DirectionFrequency(direction="S", count=0, percentage=0.0),
- DirectionFrequency(direction="E", count=0, percentage=0.0)
- ],
- top_location_types=["водоёмы", "дороги", "постройки"],
- survival_rate=0.0,
- sample_size=0,
- filters_used=filters_used
- )
-
- # Вычисляем медианное расстояние
- distances = sorted([c.found_distance_km for c in cases if c.found_distance_km])
- median_distance = distances[len(distances) // 2] if distances else 2.0
-
- # Подсчитываем частоту направлений
- direction_counts = {}
- for case in cases:
- if case.found_direction:
- direction = case.found_direction
- direction_counts[direction] = direction_counts.get(direction, 0) + 1
-
- # Топ-3 направления
- sorted_directions = sorted(
- direction_counts.items(),
- key=lambda x: x[1],
- reverse=True
- )[:3]
-
- top_directions = [
- DirectionFrequency(
- direction=direction,
- count=count,
- percentage=round(count / sample_size * 100, 1)
- )
- for direction, count in sorted_directions
- ]
-
- # Если направлений меньше 3, добавляем пустые
- while len(top_directions) < 3:
- top_directions.append(
- DirectionFrequency(direction="unknown", count=0, percentage=0.0)
- )
-
- # Топ типов локаций
- location_counts = {}
- for case in cases:
- if case.found_location_type:
- location_type = case.found_location_type
- location_counts[location_type] = location_counts.get(location_type, 0) + 1
-
- top_location_types = [
- loc for loc, _ in sorted(
- location_counts.items(),
- key=lambda x: x[1],
- reverse=True
- )[:5]
- ]
-
- if not top_location_types:
- top_location_types = ["водоёмы", "дороги", "лес"]
-
- # Процент выживаемости
- survived_count = sum(1 for case in cases if case.found_alive is True)
- survival_rate = round(survived_count / sample_size * 100, 1) if sample_size > 0 else 0.0
-
- return StatisticalRecommendation(
- median_distance_km=round(median_distance, 2),
- top_directions=top_directions,
- top_location_types=top_location_types,
- survival_rate=survival_rate,
- sample_size=sample_size,
- filters_used=filters_used
- )
-
-
-def get_dashboard_stats(db: Session) -> DashboardStats:
- """
- Получает агрегированную статистику для дашборда.
-
- Args:
- db: SQLAlchemy Session
-
- Returns:
- DashboardStats: Агрегированная статистика
- """
- cases = db.query(Case).all()
-
- total = len(cases)
- active = sum(1 for c in cases if c.status == 'active')
- closed = sum(1 for c in cases if c.status == 'closed')
-
- by_gender = {}
- by_age_group = {}
- by_psychotype = {}
- by_diagnosis = {}
- by_season = {}
-
- distances = []
- durations = []
- survived = 0
- total_with_outcome = 0
-
- for case in cases:
- # Gender
- gender = case.gender or 'unknown'
- by_gender[gender] = by_gender.get(gender, 0) + 1
-
- # Age groups
- age = case.age_years
- if age < 4:
- age_group = '0-3'
- elif age < 8:
- age_group = '4-7'
- elif age < 12:
- age_group = '8-11'
- elif age < 15:
- age_group = '12-14'
- elif age < 18:
- age_group = '15-17'
- else:
- age_group = '18+'
- by_age_group[age_group] = by_age_group.get(age_group, 0) + 1
-
- # Psychotype
- if case.psychotype:
- by_psychotype[case.psychotype] = by_psychotype.get(case.psychotype, 0) + 1
-
- # Diagnosis
- if case.diagnosis_type:
- for diag in case.diagnosis_type:
- by_diagnosis[diag] = by_diagnosis.get(diag, 0) + 1
-
- # Season
- if case.season:
- by_season[case.season] = by_season.get(case.season, 0) + 1
-
- # Distance
- if case.found_distance_km:
- distances.append(case.found_distance_km)
-
- # Duration
- if case.search_duration_hours:
- durations.append(case.search_duration_hours)
-
- # Survival rate
- if case.found_alive is not None:
- total_with_outcome += 1
- if case.found_alive:
- survived += 1
-
- avg_distance = round(sum(distances) / len(distances), 2) if distances else None
- avg_duration = round(sum(durations) / len(durations), 2) if durations else None
- survival_rate = round(survived / total_with_outcome * 100, 1) if total_with_outcome > 0 else 0.0
-
- return DashboardStats(
- total_cases=total,
- active_cases=active,
- closed_cases=closed,
- by_gender=by_gender,
- by_age_group=by_age_group,
- by_psychotype=by_psychotype,
- by_diagnosis=by_diagnosis,
- by_season=by_season,
- avg_distance_km=avg_distance,
- avg_search_duration_hours=avg_duration,
- survival_rate=survival_rate
- )
-
-
-def get_heatmap_data(db: Session, filters: Optional[Dict] = None) -> List[Dict]:
- """
- Получает данные для тепловой карты находок.
-
- Args:
- db: SQLAlchemy Session
- filters: Опциональные фильтры (age_min, age_max, season, outcome)
-
- Returns:
- List[Dict]: Список точек с координатами и интенсивностью
- """
- query = db.query(Case).filter(
- Case.found_lat.isnot(None),
- Case.found_lon.isnot(None)
- )
-
- if filters:
- if 'age_min' in filters:
- query = query.filter(Case.age_years >= filters['age_min'])
- if 'age_max' in filters:
- query = query.filter(Case.age_years <= filters['age_max'])
- if 'season' in filters:
- query = query.filter(Case.season == filters['season'])
- if 'outcome' in filters:
- if filters['outcome'] == 'alive':
- query = query.filter(Case.found_alive == True)
- elif filters['outcome'] == 'deceased':
- query = query.filter(Case.found_alive == False)
-
- cases = query.all()
-
- points = []
- for case in cases:
- points.append({
- 'lat': case.found_lat,
- 'lon': case.found_lon,
- 'intensity': 1.0,
- 'case_id': str(case.id),
- 'distance_km': case.found_distance_km,
- 'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
- })
-
- return points
-"""
-Extended heatmap functions with caching and multiple map types.
-"""
-from functools import lru_cache
-from typing import Dict, List, Optional, Literal
-from datetime import datetime
-from sqlalchemy.orm import Session
-from models import Case
-
-
-HeatmapType = Literal['all', 'age', 'season', 'outcome']
-
-
-def get_heatmap_data_cached(
- db: Session,
- map_type: HeatmapType = 'all',
- age_group: Optional[str] = None,
- season: Optional[str] = None,
- year_from: Optional[int] = None,
- year_to: Optional[int] = None,
- outcome: Optional[str] = None
-) -> Dict:
- """
- Получает данные для тепловой карты с кэшированием.
-
- Args:
- db: SQLAlchemy Session
- map_type: Тип карты (all, age, season, outcome)
- age_group: Возрастная группа (0-3, 4-7, 8-11, 12-14, 15-17)
- season: Сезон (зима, весна, лето, осень)
- year_from: Год начала периода
- year_to: Год окончания периода
- outcome: Исход (alive, deceased)
-
- Returns:
- Dict: {points: List[Dict], total: int, filters_applied: Dict}
- """
- # Базовый запрос
- query = db.query(Case).filter(
- Case.found_lat.isnot(None),
- Case.found_lon.isnot(None)
- )
-
- filters_applied = {'map_type': map_type}
-
- # Фильтр по возрастной группе
- if age_group:
- age_ranges = {
- '0-3': (0, 3),
- '4-7': (4, 7),
- '8-11': (8, 11),
- '12-14': (12, 14),
- '15-17': (15, 17),
- '18+': (18, 100)
- }
- if age_group in age_ranges:
- min_age, max_age = age_ranges[age_group]
- query = query.filter(Case.age_years.between(min_age, max_age))
- filters_applied['age_group'] = age_group
-
- # Фильтр по сезону
- if season:
- query = query.filter(Case.season == season)
- filters_applied['season'] = season
-
- # Фильтр по периоду (годы)
- if year_from:
- query = query.filter(
- db.func.extract('year', Case.created_at) >= year_from
- )
- filters_applied['year_from'] = year_from
-
- if year_to:
- query = query.filter(
- db.func.extract('year', Case.created_at) <= year_to
- )
- filters_applied['year_to'] = year_to
-
- # Фильтр по исходу
- if outcome:
- if outcome == 'alive':
- query = query.filter(Case.found_alive == True)
- elif outcome == 'deceased':
- query = query.filter(Case.found_alive == False)
- filters_applied['outcome'] = outcome
-
- cases = query.all()
-
- # Формируем точки в зависимости от типа карты
- points = []
-
- if map_type == 'all':
- # Все точки с одинаковой интенсивностью
- for case in cases:
- points.append({
- 'lat': case.found_lat,
- 'lon': case.found_lon,
- 'intensity': 1.0,
- 'case_id': str(case.id),
- 'metadata': {
- 'age': case.age_years,
- 'season': case.season,
- 'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown'
- }
- })
-
- elif map_type == 'age':
- # Интенсивность зависит от возраста (младше = выше интенсивность)
- for case in cases:
- # Младшие дети = выше интенсивность (более критично)
- intensity = max(0.3, 1.0 - (case.age_years / 18.0))
- points.append({
- 'lat': case.found_lat,
- 'lon': case.found_lon,
- 'intensity': round(intensity, 2),
- 'case_id': str(case.id),
- 'metadata': {
- 'age': case.age_years,
- 'age_group': _get_age_group(case.age_years)
- }
- })
-
- elif map_type == 'season':
- # Интенсивность зависит от сезона (зима = выше)
- season_intensity = {
- 'зима': 1.0,
- 'осень': 0.8,
- 'весна': 0.6,
- 'лето': 0.4
- }
- for case in cases:
- intensity = season_intensity.get(case.season, 0.5)
- points.append({
- 'lat': case.found_lat,
- 'lon': case.found_lon,
- 'intensity': intensity,
- 'case_id': str(case.id),
- 'metadata': {
- 'season': case.season
- }
- })
-
- elif map_type == 'outcome':
- # Интенсивность зависит от исхода
- for case in cases:
- if case.found_alive is True:
- intensity = 0.5 # Зеленый (выжил)
- elif case.found_alive is False:
- intensity = 1.0 # Красный (погиб)
- else:
- intensity = 0.3 # Серый (неизвестно)
-
- points.append({
- 'lat': case.found_lat,
- 'lon': case.found_lon,
- 'intensity': intensity,
- 'case_id': str(case.id),
- 'metadata': {
- 'outcome': 'alive' if case.found_alive else 'deceased' if case.found_alive is False else 'unknown',
- 'distance_km': case.found_distance_km
- }
- })
-
- return {
- 'points': points,
- 'total': len(points),
- 'filters_applied': filters_applied
- }
-
-
-def _get_age_group(age: int) -> str:
- """Определяет возрастную группу"""
- if age <= 3:
- return '0-3'
- elif age <= 7:
- return '4-7'
- elif age <= 11:
- return '8-11'
- elif age <= 14:
- return '12-14'
- elif age <= 17:
- return '15-17'
- else:
- return '18+'
-
-
-# Кэшированная версия для быстрого доступа
-# Кэш на 1 час (3600 секунд), максимум 128 комбинаций параметров
-@lru_cache(maxsize=128)
-def _get_heatmap_cache_key(
- map_type: str,
- age_group: Optional[str],
- season: Optional[str],
- year_from: Optional[int],
- year_to: Optional[int],
- outcome: Optional[str],
- timestamp_hour: int # Меняется каждый час
-) -> str:
- """Генерирует ключ кэша для heatmap"""
- return f"{map_type}_{age_group}_{season}_{year_from}_{year_to}_{outcome}_{timestamp_hour}"
-
-
-def get_heatmap_with_cache(
- db: Session,
- map_type: HeatmapType = 'all',
- age_group: Optional[str] = None,
- season: Optional[str] = None,
- year_from: Optional[int] = None,
- year_to: Optional[int] = None,
- outcome: Optional[str] = None
-) -> Dict:
- """
- Обертка с кэшированием на 1 час.
-
- Кэш инвалидируется каждый час автоматически через timestamp_hour.
- """
- # Текущий час для кэша (меняется каждый час)
- current_hour = datetime.utcnow().hour
-
- # Генерируем ключ кэша
- cache_key = _get_heatmap_cache_key(
- map_type,
- age_group,
- season,
- year_from,
- year_to,
- outcome,
- current_hour
- )
-
- # Получаем данные (кэш работает через lru_cache на уровне ключа)
- return get_heatmap_data_cached(
- db,
- map_type,
- age_group,
- season,
- year_from,
- year_to,
- outcome
- )
+def heatmap() -> list[dict]:
+ return db.heatmap()
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..29fdf05
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,162 @@
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Any
+from uuid import uuid4
+from pathlib import Path
+import sys
+import types
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+if str(REPO_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPO_ROOT))
+
+from backend import database as backend_database
+
+
+def utcnow() -> datetime:
+ return datetime.now(timezone.utc)
+
+
+@dataclass
+class CaseRecord:
+ id: str
+ created_at: datetime
+ updated_at: datetime
+ age: int | None = None
+ gender: str | None = None
+ health_flags: list[str] = field(default_factory=list)
+ elapsed_hours: int | None = None
+ last_known_place: str | None = None
+ direction: str | None = None
+ terrain_primary: str | None = None
+ weather: str | None = None
+ resources: list[str] = field(default_factory=list)
+ gps_lat: float | None = None
+ gps_lon: float | None = None
+ notes: str | None = None
+ status: str = 'new'
+ found_alive: bool | None = None
+ found_distance_km: float | None = None
+ found_direction: str | None = None
+ found_location_type: str | None = None
+ found_lat: float | None = None
+ found_lon: float | None = None
+ who_found: str | None = None
+ raw_text: str | None = None
+ analysis_log: str | None = None
+ result: dict[str, Any] | None = None
+
+ def to_summary(self) -> dict[str, Any]:
+ return {
+ 'id': self.id,
+ 'created_at': self.created_at.isoformat(),
+ 'updated_at': self.updated_at.isoformat(),
+ 'age': self.age,
+ 'gender': self.gender,
+ 'status': self.status,
+ 'found_alive': self.found_alive,
+ 'found_distance_km': self.found_distance_km,
+ 'found_direction': self.found_direction,
+ 'found_location_type': self.found_location_type,
+ 'who_found': self.who_found,
+ 'last_known_place': self.last_known_place,
+ 'direction': self.direction,
+ 'terrain_primary': self.terrain_primary,
+ 'weather': self.weather,
+ }
+
+ def to_detail(self) -> dict[str, Any]:
+ data = self.to_summary()
+ data.update({
+ 'health_flags': list(self.health_flags),
+ 'elapsed_hours': self.elapsed_hours,
+ 'resources': list(self.resources),
+ 'gps_lat': self.gps_lat,
+ 'gps_lon': self.gps_lon,
+ 'notes': self.notes,
+ 'found_lat': self.found_lat,
+ 'found_lon': self.found_lon,
+ 'raw_text': self.raw_text,
+ 'analysis_log': self.analysis_log,
+ 'result': self.result,
+ })
+ return data
+
+
+class TestDB:
+ def __init__(self) -> None:
+ self._cases: dict[str, CaseRecord] = {}
+
+ def create_case(self, **data: Any) -> CaseRecord:
+ now = utcnow()
+ data.pop('mobile', None)
+ data.setdefault('status', 'new')
+ case = CaseRecord(id=str(uuid4()), created_at=now, updated_at=now, **data)
+ self._cases[case.id] = case
+ return case
+
+ def list_cases(self) -> list[CaseRecord]:
+ return sorted(self._cases.values(), key=lambda c: c.created_at, reverse=True)
+
+ def get_case(self, case_id: str) -> CaseRecord | None:
+ return self._cases.get(case_id)
+
+ def update_case(self, case_id: str, **patch: Any) -> CaseRecord | None:
+ case = self._cases.get(case_id)
+ if not case:
+ return None
+ for key, value in patch.items():
+ if hasattr(case, key) and value is not None:
+ setattr(case, key, value)
+ case.updated_at = utcnow()
+ return case
+
+ def delete_case(self, case_id: str) -> bool:
+ return self._cases.pop(case_id, None) is not None
+
+ def stats(self) -> dict[str, Any]:
+ cases = list(self._cases.values())
+ total = len(cases)
+ found_alive = sum(1 for c in cases if c.found_alive is True)
+ found_deceased = sum(1 for c in cases if c.found_alive is False)
+ unknown = total - found_alive - found_deceased
+ distances = [c.found_distance_km for c in cases if c.found_distance_km is not None]
+ median_distance = None
+ if distances:
+ distances = sorted(distances)
+ mid = len(distances) // 2
+ if len(distances) % 2:
+ median_distance = distances[mid]
+ else:
+ median_distance = (distances[mid - 1] + distances[mid]) / 2
+ return {
+ 'total_cases': total,
+ 'found_alive_count': found_alive,
+ 'found_deceased_count': found_deceased,
+ 'unknown_outcome_count': unknown,
+ 'median_found_distance_km': median_distance,
+ 'recent_activity_count': sum(1 for c in cases if (utcnow() - c.updated_at).days < 7),
+ }
+
+ def heatmap(self) -> list[dict[str, Any]]:
+ heat = []
+ for case in self._cases.values():
+ if case.gps_lat is not None and case.gps_lon is not None:
+ heat.append({'lat': case.gps_lat, 'lon': case.gps_lon, 'weight': 1})
+ return heat
+
+
+db = TestDB()
+backend_database.db = db
+
+services_pkg = types.ModuleType('backend.services')
+services_pkg.__path__ = []
+stats_mod = types.ModuleType('backend.services.stats_service')
+stats_mod.summary = db.stats
+stats_mod.heatmap = db.heatmap
+services_pkg.stats_service = stats_mod
+sys.modules['backend.services'] = services_pkg
+sys.modules['backend.services.stats_service'] = stats_mod
diff --git a/backend/tests/test_admin_api.py b/backend/tests/test_admin_api.py
new file mode 100644
index 0000000..7017e9a
--- /dev/null
+++ b/backend/tests/test_admin_api.py
@@ -0,0 +1,73 @@
+
+from backend.database import db
+from backend.main import app
+from fastapi.testclient import TestClient
+
+client = TestClient(app)
+
+
+def setup_function() -> None:
+ db._cases.clear() # type: ignore[attr-defined]
+
+
+def test_admin_cases_paginates_and_sorts_newest_first() -> None:
+ db.create_case(age=10, gender='male', status='new')
+ db.create_case(age=20, gender='female', status='new')
+ third = db.create_case(age=30, gender='male', status='new')
+
+ response = client.get('/api/v1/admin/cases?page=1&page_size=2')
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload['total'] == 3
+ assert payload['page'] == 1
+ assert payload['page_size'] == 2
+ assert [item['id'] for item in payload['items']] == [third.id, payload['items'][1]['id']]
+
+
+def test_admin_cases_can_filter_by_status_and_age_range() -> None:
+ db.create_case(age=14, gender='male', status='new')
+ analyzed_young = db.create_case(age=19, gender='female', status='analyzed')
+ db.create_case(age=24, gender='male', status='analyzed')
+ db.create_case(age=29, gender='female', status='closed')
+
+ response = client.get('/api/v1/admin/cases?status=analyzed&age_min=18&age_max=21')
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload['total'] == 1
+ assert [item['id'] for item in payload['items']] == [analyzed_young.id]
+ assert payload['items'][0]['age'] == 19
+ assert payload['items'][0]['status'] == 'analyzed'
+
+
+def test_admin_case_patch_accepts_outcome_fields_and_note_alias() -> None:
+ case = db.create_case(
+ age=17,
+ gender='female',
+ status='analyzed',
+ last_known_place='Forest edge',
+ notes='initial note',
+ )
+
+ response = client.patch(
+ f'/api/v1/admin/cases/{case.id}',
+ json={
+ 'found_alive': True,
+ 'found_distance_km': 3.5,
+ 'found_lat': 54.1,
+ 'found_lon': 37.2,
+ 'who_found': 'Search team 4',
+ 'note': 'Found near the logging road',
+ },
+ )
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload['found_alive'] is True
+ assert payload['found_distance_km'] == 3.5
+ assert payload['found_lat'] == 54.1
+ assert payload['found_lon'] == 37.2
+ assert payload['who_found'] == 'Search team 4'
+ assert payload['notes'] == 'Found near the logging road'
+ assert payload['last_known_place'] == 'Forest edge'
diff --git a/backend/tests/test_admin_dashboard_api.py b/backend/tests/test_admin_dashboard_api.py
new file mode 100644
index 0000000..2e4fcd3
--- /dev/null
+++ b/backend/tests/test_admin_dashboard_api.py
@@ -0,0 +1,40 @@
+
+from backend.database import db
+from backend.main import app
+from fastapi.testclient import TestClient
+
+client = TestClient(app)
+
+
+def setup_function() -> None:
+ db._cases.clear() # type: ignore[attr-defined]
+
+
+def test_admin_dashboard_reflects_outcomes_and_distance_median() -> None:
+ db.create_case(age=12, gender='female', status='new')
+ db.create_case(age=17, gender='male', status='analyzed', found_alive=True, found_distance_km=2.0)
+ db.create_case(age=19, gender='female', status='closed', found_alive=False, found_distance_km=6.0)
+
+ response = client.get('/api/v1/admin/dashboard')
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload['total_cases'] == 3
+ assert payload['found_alive_count'] == 1
+ assert payload['found_deceased_count'] == 1
+ assert payload['unknown_outcome_count'] == 1
+ assert payload['median_found_distance_km'] == 4.0
+ assert payload['recent_activity_count'] == 3
+
+
+def test_admin_dashboard_is_zero_safe_when_no_cases_exist() -> None:
+ response = client.get('/api/v1/admin/dashboard')
+
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload['total_cases'] == 0
+ assert payload['found_alive_count'] == 0
+ assert payload['found_deceased_count'] == 0
+ assert payload['unknown_outcome_count'] == 0
+ assert payload['median_found_distance_km'] is None
+ assert payload['recent_activity_count'] == 0
diff --git a/backend/tests/test_parse_doc_api.py b/backend/tests/test_parse_doc_api.py
new file mode 100644
index 0000000..d2d9c29
--- /dev/null
+++ b/backend/tests/test_parse_doc_api.py
@@ -0,0 +1,73 @@
+
+from io import BytesIO
+import zipfile
+
+from backend.main import app
+from fastapi.testclient import TestClient
+
+client = TestClient(app)
+
+
+def build_docx(paragraphs: list[str]) -> BytesIO:
+ document_xml = """
+