Завершение поиска: исход → архив + матмодель + операция completed
- POST /operations/{id}/complete: found_alive/дистанция/направление/кто нашёл/
длительность → кейс status=closed + фактические данные + прогон детерминированной
матмодели (model_prediction, accuracy_note в analysis_log) → операция
status=completed + closed_at. Аудит: operation_complete + case_closed.
- ФИКС list_closed_cases: фильтр по to_summary().analysis_log всегда давал None
(analysis_log только в to_detail) → список завершённых был пуст; теперь
фильтр по status='closed' (совместимо с TestDB).
- UI: кнопка «✓ Завершить поиск» на странице анализа (для активных операций),
модал с исходом, после завершения — плашка «Поиск завершён», в архиве
(/admin завершённые) появляется запись с прогнозом модели и точностью.
- Тесты (4): полный флоу, повторное завершение 400, аудит, появление в
/closed-cases. 230 passed.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user