"""B21/E3: операции мультипоиска. Скоуп: unit_id операции ∈ {своё подразделение + подчинённые} (РЦУ РЧС — все). Статусная машина: planned → active ⇄ paused → completed → archived. """ from __future__ import annotations from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from sqlalchemy.orm import Session from backend.audit import audit_log, visible_unit_ids from backend.logging_setup import get_logger from backend.database import get_db from backend.models import Case, SearchOperation, User from backend.routers.auth import get_current_user, require_permission oplog = get_logger('operations') router = APIRouter(prefix='/api/v1/operations', tags=['operations']) ALLOWED_TRANSITIONS = { 'planned': {'active', 'archived'}, 'active': {'paused', 'completed', 'archived'}, 'paused': {'active', 'completed', 'archived'}, 'completed': {'archived'}, 'archived': set(), } class OperationCreate(BaseModel): title: str case_id: str unit_id: Optional[str] = None class OperationUpdate(BaseModel): title: Optional[str] = None status: Optional[str] = None unit_id: Optional[str] = None contour_operation_id: Optional[str] = None # UUID операции в КОНТУРе def _op_scope(db: Session, user: User) -> list | None: """UUID-объекты юнитов из скоупа, либо None (без фильтра).""" scope = visible_unit_ids(db, user) if scope is None: return None import uuid return [uuid.UUID(h) if isinstance(h, str) else h for h in scope] def _get_operation_checked(db: Session, user: User, operation_id: str) -> SearchOperation: import uuid as uuid_mod op = db.get(SearchOperation, uuid_mod.UUID(operation_id)) if operation_id else None if not op: raise HTTPException(status_code=404, detail='Операция не найдена') scope = _op_scope(db, user) if scope is not None and (not op.unit_id or op.unit_id not in scope): raise HTTPException(status_code=403, detail='Операция вне вашего скоупа') return op @router.get('') def list_operations( status_filter: Optional[str] = Query(default=None, alias='status'), case_id: Optional[str] = Query(default=None), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict[str, Any]: scope = _op_scope(db, current_user) query = db.query(SearchOperation) if scope is not None: query = query.filter(SearchOperation.unit_id.in_(scope)) if status_filter: query = query.filter(SearchOperation.status == status_filter) if case_id: import uuid as uuid_mod query = query.filter(SearchOperation.case_id == uuid_mod.UUID(case_id)) ops = query.order_by(SearchOperation.updated_at.desc()).all() from backend.models import User as UserModel users_by_id = {} if ops: creator_ids = {o.created_by for o in ops if o.created_by} if creator_ids: for u in db.query(UserModel).filter(UserModel.id.in_(creator_ids)).all(): users_by_id[str(u.id)] = u.full_name or u.username units_by_id = {} unit_ids = {o.unit_id for o in ops if o.unit_id} if unit_ids: from backend.models import MchsUnit for un in db.query(MchsUnit).filter(MchsUnit.id.in_(unit_ids)).all(): units_by_id[str(un.id)] = un.name return {'items': [_operation_dto(o, users_by_id, units_by_id) for o in ops], 'total': len(ops)} def _operation_dto(op: SearchOperation, users_by_id: dict | None = None, units_by_id: dict | None = None) -> dict[str, Any]: u = users_by_id or {} units = units_by_id or {} return { 'id': str(op.id), 'title': op.title, 'case_id': str(op.case_id), 'status': op.status, 'unit_id': str(op.unit_id) if op.unit_id else None, 'unit_name': units.get(str(op.unit_id)) if op.unit_id else None, 'contour_operation_id': str(op.contour_operation_id) if op.contour_operation_id else None, 'created_by_name': u.get(str(op.created_by)) if op.created_by else None, 'created_at': op.created_at.isoformat() if op.created_at else None, 'updated_at': op.updated_at.isoformat() if op.updated_at else None, 'closed_at': op.closed_at.isoformat() if op.closed_at else None, } 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() oplog.info('operation_complete op=%s case=%s исход=%s юзер=%s', op.id, case.id, 'найден живым' if payload.found_alive else 'найден погибшим', getattr(current_user, 'username', '—')) 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), db: Session = Depends(get_db), ) -> dict[str, Any]: """Счётчики для дашборда: активных / всего / завершённых за 24 ч.""" scope = _op_scope(db, current_user) query = db.query(SearchOperation) if scope is not None: query = query.filter(SearchOperation.unit_id.in_(scope)) total = query.count() active = query.filter(SearchOperation.status == 'active').count() day_ago = datetime.now(timezone.utc) - __import__('datetime').timedelta(hours=24) completed_24h = query.filter( SearchOperation.status == 'completed', SearchOperation.closed_at >= day_ago, ).count() return {'active': active, 'total': total, 'completed_24h': completed_24h} @router.get('/{operation_id}') def get_operation( operation_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> dict[str, Any]: op = _get_operation_checked(db, current_user, operation_id) return _operation_dto(op) @router.post('', status_code=201) def create_operation( payload: OperationCreate, request: Request, current_user: User = Depends(require_permission('create')), db: Session = Depends(get_db), ) -> dict[str, Any]: import uuid as uuid_mod from backend.models import Case case_uuid = uuid_mod.UUID(payload.case_id) case = db.get(Case, case_uuid) if not case: raise HTTPException(status_code=404, detail='Карточка не найдена') existing = db.query(SearchOperation).filter(SearchOperation.case_id == case_uuid).first() if existing: raise HTTPException(status_code=400, detail='Операция для этой карточки уже существует') unit_id = payload.unit_id if unit_id: from backend.audit import can_access_unit if not can_access_unit(db, current_user, unit_id): raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа') else: # По умолчанию — подразделение создателя unit_id = getattr(current_user, 'unit_id', None) unit_id = str(unit_id) if unit_id else None op = SearchOperation( title=payload.title, case_id=case_uuid, status='active', unit_id=uuid_mod.UUID(unit_id) if unit_id else None, created_by=current_user.id, ) db.add(op) db.commit() db.refresh(op) from backend.audit import audit_log audit_log(db, current_user, 'operation_create', object_type='operation', object_id=str(op.id), request=request, details={'title': op.title, 'case_id': payload.case_id}) return _operation_dto(op) @router.patch('/{operation_id}') def update_operation( operation_id: str, payload: OperationUpdate, request: Request, current_user: User = Depends(require_permission('update')), db: Session = Depends(get_db), ) -> dict[str, Any]: import uuid as uuid_mod op = _get_operation_checked(db, current_user, operation_id) changes: dict[str, tuple] = {} if payload.title is not None and payload.title != op.title: changes['title'] = (op.title, payload.title) op.title = payload.title if payload.unit_id is not None: from backend.audit import can_access_unit, _uuid_hex if not can_access_unit(db, current_user, payload.unit_id): raise HTTPException(status_code=403, detail='Подразделение вне вашего скоупа') new_unit = uuid_mod.UUID(payload.unit_id) if new_unit != op.unit_id: changes['unit'] = (str(op.unit_id) if op.unit_id else None, payload.unit_id) op.unit_id = new_unit if payload.contour_operation_id is not None: new_contour = uuid_mod.UUID(payload.contour_operation_id) if new_contour != op.contour_operation_id: changes['contour_operation_id'] = (str(op.contour_operation_id) if op.contour_operation_id else None, payload.contour_operation_id) op.contour_operation_id = new_contour if payload.status is not None and payload.status != op.status: allowed = ALLOWED_TRANSITIONS.get(op.status, set()) if payload.status not in allowed: raise HTTPException( status_code=400, detail=f'Недопустимый переход {op.status} → {payload.status}. ' f'Разрешено: {", ".join(sorted(allowed)) or "нет"}', ) changes['status'] = (op.status, payload.status) op.status = payload.status if payload.status in ('completed', 'archived'): op.closed_at = datetime.now(timezone.utc) if not changes: return _operation_dto(op) op.updated_at = datetime.now(timezone.utc) db.commit() from backend.audit import audit_log audit_log(db, current_user, 'operation_update', object_type='operation', object_id=str(op.id), changes=changes, request=request) return _operation_dto(op)