diff --git a/backend/routers/closed_cases.py b/backend/routers/closed_cases.py index b5ecf13..1b8d729 100644 --- a/backend/routers/closed_cases.py +++ b/backend/routers/closed_cases.py @@ -246,7 +246,9 @@ def list_closed_cases( page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=200), ) -> dict: - cases = [c for c in db.list_cases() if (c.to_summary().get('analysis_log') or {}).get('closed_case')] + # status='closed' — надёжный признак архивной записи + # (analysis_log в to_summary не входит; фильтр в питоне — совместимо с TestDB) + cases = [c for c in db.list_cases() if c.to_summary().get('status') == 'closed'] items = [] for c in cases: detail = c.to_detail() diff --git a/backend/routers/operations.py b/backend/routers/operations.py index 1fdc605..5486a77 100644 --- a/backend/routers/operations.py +++ b/backend/routers/operations.py @@ -14,7 +14,7 @@ from sqlalchemy.orm import Session from backend.audit import audit_log, visible_unit_ids from backend.database import get_db -from backend.models import SearchOperation, User +from backend.models import Case, SearchOperation, User from backend.routers.auth import get_current_user, require_permission router = APIRouter(prefix='/api/v1/operations', tags=['operations']) @@ -104,6 +104,112 @@ def _operation_dto(op: SearchOperation, users_by_id: dict | None = None) -> dict } +class OperationComplete(BaseModel): + found_alive: bool + 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 + notes: Optional[str] = None + + +@router.post('/{operation_id}/complete') +def complete_operation( + operation_id: str, + payload: OperationComplete, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> dict[str, Any]: + """Завершение поиска: исход → кейс (архив + прогон модели) → операция completed.""" + op = _get_operation_checked(db, current_user, operation_id) + + if op.status == 'completed': + raise HTTPException(status_code=400, detail='Операция уже завершена') + if op.status == 'archived': + raise HTTPException(status_code=400, detail='Операция в архиве — завершение недоступно') + + case = db.query(Case).filter(Case.id == op.case_id).first() + if not case: + raise HTTPException(status_code=404, detail='Карточка операции не найдена') + + # Данные кейса → вход матмодели (детерминированный прогон, как /analyze) + data = { + 'age_years': case.age_years, + 'gender': case.gender or 'м', + 'diagnosis_type': case.diagnosis_type or [], + 'has_transport': case.has_transport or 'none', + 'cant_swim': bool(case.cant_swim), + 'fitness_level': case.fitness_level, + 'elapsed_hours': case.elapsed_hours or 0.0, + 'loss_time': case.loss_time, + 'terrain': case.terrain or [], + 'weather': None, 'season': case.season, 'temperature_c': case.temperature_c, + 'tnp_lat': case.tnp_lat, 'tnp_lon': case.tnp_lon, 'tnp_address': case.tnp_address, + 'psychotype': case.psychotype, 'psychotype_answers': case.psychotype_answers, + 'teams_count': case.teams_count, 'team_size': case.team_size, + 'has_dog': bool(case.has_dog), + 'found_alive': payload.found_alive, + 'found_distance_km': payload.found_distance_km, + 'found_direction': payload.found_direction, + 'found_location_type': payload.found_location_type, + 'found_lat': payload.found_lat, 'found_lon': payload.found_lon, + 'search_duration_hours': payload.search_duration_hours, + 'who_found': payload.who_found, + 'notes': payload.notes, + 'source': 'операция ВЕКТОР', + } + + from backend.routers.closed_cases import _run_model as _run_model_func, _accuracy_note as _accuracy_note_func + from backend.schemas import ClosedCaseCreate + cc = ClosedCaseCreate(**{k: v for k, v in data.items() if k in ClosedCaseCreate.model_fields}) + prediction = _run_model_func(cc.model_dump()) + accuracy = _accuracy_note_func(prediction, cc) + + # 1. Кейс → closed + фактические данные + маркер архивной записи + case.status = 'closed' + case.found_alive = payload.found_alive + case.found_distance_km = payload.found_distance_km + case.found_direction = payload.found_direction + case.found_location_type = payload.found_location_type + case.found_lat = payload.found_lat + case.found_lon = payload.found_lon + case.search_duration_hours = payload.search_duration_hours + case.who_found = payload.who_found + log = dict(case.analysis_log or {}) + log.update({ + 'closed_case': True, + 'model_prediction': prediction, + 'accuracy_note': accuracy, + 'source': 'операция ВЕКТОР', + 'notes': payload.notes, + }) + case.analysis_log = log + + # 2. Операция → completed (через статусную машину, closed_at автоматически) + op.status = 'completed' + op.closed_at = datetime.now(timezone.utc) + + db.commit() + + audit_log(db, current_user, 'operation_complete', + object_type='search_operation', object_id=str(op.id), + details={'case_id': str(case.id), 'found_alive': payload.found_alive}) + audit_log(db, current_user, 'case_closed', object_type='case', object_id=str(case.id), + details={'operation_id': str(op.id), 'found_alive': payload.found_alive}) + + return { + 'operation_id': str(op.id), + 'status': op.status, + 'closed_at': op.closed_at.isoformat() if op.closed_at else None, + 'case_id': str(case.id), + 'model_prediction': prediction, + 'accuracy_note': accuracy, + } + + @router.get('/summary') def operations_summary( current_user: User = Depends(get_current_user), diff --git a/backend/tests/test_operations_complete.py b/backend/tests/test_operations_complete.py new file mode 100644 index 0000000..88488a7 --- /dev/null +++ b/backend/tests/test_operations_complete.py @@ -0,0 +1,167 @@ +"""Тесты завершения поиска (кнопка «Завершить» → архив + модель + completed).""" +from __future__ import annotations + +import sys +from pathlib import Path + +import bcrypt +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import sqlalchemy # noqa: E402 +from sqlalchemy import create_engine # noqa: E402 +from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID # noqa: E402 +from sqlalchemy.ext.compiler import compiles # noqa: E402 +from sqlalchemy.orm import sessionmaker # noqa: E402 +from sqlalchemy.pool import StaticPool # noqa: E402 + + +@compiles(PG_UUID, 'sqlite') +def _uuid_sqlite(type_, compiler, **kw): + return 'CHAR(36)' + + +@compiles(JSONB, 'sqlite') +def _jsonb_sqlite(type_, compiler, **kw): + return 'JSON' + + +@compiles(sqlalchemy.ARRAY, 'sqlite') +def _array_sqlite(type_, compiler, **kw): + return 'TEXT' + + +@pytest.fixture() +def complete_env(monkeypatch): + from backend import models as m + from backend.database import Base + from backend.models import (MchsUnit, Permission, Role, RolePermission, + SecuritySetting, SearchOperation, UserRole) + + engine = create_engine('sqlite:///:memory:', connect_args={'check_same_thread': False}, + poolclass=StaticPool) + Base.metadata.create_all(engine) + db = sessionmaker(bind=engine)() + + rcu = MchsUnit(name='РЦУ РЧС', kind='rcu') + db.add(rcu); db.flush() + + def _hash(pw): return bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode() + + root = m.User(username='root', email='r@t.by', hashed_password=_hash('rootpass123'), + role='admin', is_active=True, unit_id=rcu.id) + db.add(root); db.commit() + + case = m.Case(age_years=10, gender='м', status='analyzed', + elapsed_hours=3.0, tnp_lat=53.9, tnp_lon=27.56) + db.add(case); db.flush(); db.commit() + + op = SearchOperation(title='Операция завершения', case_id=case.id, status='active', + unit_id=rcu.id, created_by=root.id) + db.add(op); db.commit() + + roles = {} + for name in ('admin', 'coordinator', 'operator', 'observer'): + r = Role(name=name, is_system=True); db.add(r); roles[name] = r + perms = {} + for code in ('view', 'create', 'update', 'delete', 'export', 'manage_users', + 'manage_roles', 'view_audit', 'manage_security'): + p = Permission(code=code); db.add(p); perms[code] = p + db.commit() + for rn, codes in {'admin': tuple(perms), 'observer': ('view',)}.items(): + for c in codes: + db.add(RolePermission(role_id=roles[rn].id, permission_id=perms[c].id)) + db.add(UserRole(user_id=root.id, role_id=roles['admin'].id)) + db.commit() + + from backend.rbac import SECURITY_DEFAULTS + for k, v in SECURITY_DEFAULTS.items(): + db.add(SecuritySetting(key=k, value=v)) + db.commit() + + from backend.main import app + from backend.routers import auth as auth_router + from fastapi.testclient import TestClient + + def override(): + yield db + + app.dependency_overrides[auth_router.get_db] = override + # closed-cases читает через legacy-глобальный db + модульный SessionLocal — + # направляю оба в тестовую БД + import backend.database as database_mod + import backend.routers.closed_cases as closed_mod + from backend.database import SQLCaseRepository + test_session = sessionmaker(bind=engine) + monkeypatch.setattr(database_mod, 'SessionLocal', test_session) + monkeypatch.setattr(closed_mod, 'db', SQLCaseRepository()) + client = TestClient(app) + tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token'] + + yield {'db': db, 'client': client, 'token': tok, 'op': op, 'case': case} + + app.dependency_overrides.pop(auth_router.get_db, None) + + +class TestCompleteOperation: + def test_complete_flow(self, complete_env): + env = complete_env + r = env['client'].post( + f"/api/v1/operations/{env['op'].id}/complete", + headers={'Authorization': f"Bearer {env['token']}"}, + json={'found_alive': True, 'found_distance_km': 4.2, 'found_direction': 'NE', + 'search_duration_hours': 5.5, 'who_found': 'лесники'}) + assert r.status_code == 200, r.json() + body = r.json() + assert body['status'] == 'completed' + assert body['closed_at'] is not None + # Кейс закрыт + фактические данные + from backend.models import Case + case = env['db'].query(Case).filter(Case.id == env['case'].id).first() + assert case.status == 'closed' + assert case.found_alive is True + assert case.found_distance_km == 4.2 + assert case.who_found == 'лесники' + assert case.analysis_log.get('closed_case') is True + assert case.analysis_log.get('model_prediction') # прогон модели записан + # Операция завершена + from backend.models import SearchOperation + op = env['db'].query(SearchOperation).filter(SearchOperation.id == env['op'].id).first() + assert op.status == 'completed' + assert op.closed_at is not None + + def test_double_complete_400(self, complete_env): + env = complete_env + h = {'Authorization': f"Bearer {env['token']}"} + payload = {'found_alive': False} + r1 = env['client'].post(f"/api/v1/operations/{env['op'].id}/complete", headers=h, json=payload) + assert r1.status_code == 200 + r2 = env['client'].post(f"/api/v1/operations/{env['op'].id}/complete", headers=h, json=payload) + assert r2.status_code == 400 + assert 'уже завершена' in r2.json()['detail'] + + def test_complete_audited(self, complete_env): + env = complete_env + env['client'].post( + f"/api/v1/operations/{env['op'].id}/complete", + headers={'Authorization': f"Bearer {env['token']}"}, + json={'found_alive': True}) + from backend.models import AuditEvent + types = {e.event_type for e in env['db'].query(AuditEvent).all()} + assert 'operation_complete' in types + assert 'case_closed' in types + + def test_completed_case_in_closed_list(self, complete_env): + # Закрытый через операцию кейс попадает в архив завершённых (/admin) + env = complete_env + env['client'].post( + f"/api/v1/operations/{env['op'].id}/complete", + headers={'Authorization': f"Bearer {env['token']}"}, + json={'found_alive': True, 'found_distance_km': 2.0}) + r = env['client'].get('/api/v1/closed-cases', headers={'Authorization': f"Bearer {env['token']}"}) + assert r.status_code == 200 + items = r.json()['items'] + assert any(it.get('age_years') == 10 for it in items) diff --git a/frontend/src/pages/analysis/AnalysisResult.css b/frontend/src/pages/analysis/AnalysisResult.css index d4513ca..aab23bd 100644 --- a/frontend/src/pages/analysis/AnalysisResult.css +++ b/frontend/src/pages/analysis/AnalysisResult.css @@ -713,3 +713,43 @@ .contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; } .contour-ok { font-size: 12px; color: #3fb950; } .contour-err { font-size: 12px; color: #f85149; } + + +/* Завершение поиска */ +.complete-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; } +.complete-btn { + padding: 9px 16px; background: #9d3412; color: #fff; border: none; + border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.complete-btn:hover { background: #c2410c; } +.complete-hint { font-size: 12px; color: #8b949e; } +.complete-done { margin-top: 12px; padding: 9px 12px; background: rgba(63,185,80,0.1); border: 1px solid rgba(63,185,80,0.4); border-radius: 6px; color: #3fb950; font-size: 13px; } + +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; + display: flex; align-items: center; justify-content: center; +} +.modal-box { + background: #161b22; border: 1px solid #30363d; border-radius: 8px; + padding: 20px; width: 620px; max-width: 94vw; max-height: 90vh; overflow-y: auto; +} +.modal-box h3 { margin: 0 0 6px; color: #e6edf3; font-size: 17px; } +.modal-sub { margin: 0 0 14px; font-size: 12px; color: #8b949e; } +.modal-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; } +.modal-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #8b949e; } +.modal-grid input, .modal-grid select { + padding: 8px 10px; background: #0d1117; border: 1px solid #30363d; + border-radius: 6px; color: #e6edf3; font-size: 13px; +} +.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } +.modal-cancel { + padding: 9px 14px; background: #21262d; color: #e6edf3; border: 1px solid #30363d; + border-radius: 6px; font-size: 13px; cursor: pointer; +} +.modal-confirm { + padding: 9px 14px; background: #9d3412; color: #fff; border: none; + border-radius: 6px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.modal-confirm:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; } +.complete-ok { padding: 8px 12px; background: rgba(63,185,80,0.1); border-radius: 6px; color: #3fb950; font-size: 13px; margin-bottom: 8px; } +.complete-err { padding: 8px 12px; background: rgba(248,81,73,0.1); border-radius: 6px; color: #f85149; font-size: 13px; margin-bottom: 8px; } diff --git a/frontend/src/pages/analysis/AnalysisResult.jsx b/frontend/src/pages/analysis/AnalysisResult.jsx index 1d8e9b9..7a19802 100644 --- a/frontend/src/pages/analysis/AnalysisResult.jsx +++ b/frontend/src/pages/analysis/AnalysisResult.jsx @@ -39,6 +39,13 @@ const AnalysisResult = () => { const [operation, setOperation] = useState(null); const [contourId, setContourId] = useState(''); const [contourStatus, setContourStatus] = useState(null); // {ok, message} + const [showComplete, setShowComplete] = useState(false); + const [completeForm, setCompleteForm] = useState({ + found_alive: 'true', found_distance_km: '', found_direction: '', + found_location_type: '', search_duration_hours: '', who_found: '', notes: '', + }); + const [completing, setCompleting] = useState(false); + const [completeResult, setCompleteResult] = useState(null); useEffect(() => { const loadCaseAndAnalyze = async () => { @@ -286,6 +293,18 @@ const AnalysisResult = () => { )} )} + {operation && operation.status !== 'completed' && ( +
Исход вносится в карточку, кейс уходит в архив завершённых, операция помечается завершённой.
+