Завершение поиска: исход → архив + матмодель + операция 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:
2026-09-09 21:55:12 +03:00
parent af98ec94e2
commit 5a86755e90
5 changed files with 409 additions and 2 deletions
@@ -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 = () => {
)}
</div>
)}
{operation && operation.status !== 'completed' && (
<div className="complete-row">
<button
className="complete-btn"
onClick={() => { setShowComplete(true); setCompleteResult(null); }}
>✓ Завершить поиск</button>
<span className="complete-hint">Внесение исхода → кейс в архив + прогон матмодели</span>
</div>
)}
{operation && operation.status === 'completed' && (
<div className="complete-done">✓ Поиск завершён {operation.closed_at ? new Date(operation.closed_at).toLocaleString('ru-RU') : ''} — запись в архиве завершённых</div>
)}
<div className="map-info">
<span>ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'}</span>
{caseData.tnp_address && <span className="sep">·</span>}
@@ -422,6 +441,79 @@ const AnalysisResult = () => {
</div>
</div>
{showComplete && (
<div className="modal-overlay" onClick={() => setShowComplete(false)}>
<div className="modal-box" onClick={(e) => e.stopPropagation()}>
<h3>Завершение поиска</h3>
<p className="modal-sub">Исход вносится в карточку, кейс уходит в архив завершённых, операция помечается завершённой.</p>
<div className="modal-grid">
<label>Найден?
<select value={completeForm.found_alive} onChange={(e) => setCompleteForm({ ...completeForm, found_alive: e.target.value })}>
<option value="true">Живым</option>
<option value="false">Погибшим</option>
</select>
</label>
<label>Дистанция, км
<input type="number" step="0.1" value={completeForm.found_distance_km} onChange={(e) => setCompleteForm({ ...completeForm, found_distance_km: e.target.value })} />
</label>
<label>Направление (N/NE/E/...)
<input value={completeForm.found_direction} onChange={(e) => setCompleteForm({ ...completeForm, found_direction: e.target.value })} />
</label>
<label>Кто нашёл
<input value={completeForm.who_found} onChange={(e) => setCompleteForm({ ...completeForm, who_found: e.target.value })} />
</label>
<label>Длительность поиска, ч
<input type="number" step="0.5" value={completeForm.search_duration_hours} onChange={(e) => setCompleteForm({ ...completeForm, search_duration_hours: e.target.value })} />
</label>
<label>Тип места находки
<input value={completeForm.found_location_type} onChange={(e) => setCompleteForm({ ...completeForm, found_location_type: e.target.value })} />
</label>
</div>
{completeResult && (
<div className={completeResult.ok ? 'complete-ok' : 'complete-err'}>{completeResult.message}</div>
)}
<div className="modal-actions">
<button className="modal-cancel" onClick={() => setShowComplete(false)}>Отмена</button>
<button
className="modal-confirm"
disabled={completing}
onClick={async () => {
setCompleting(true);
setCompleteResult(null);
try {
const f = completeForm;
const body = {
found_alive: f.found_alive === 'true',
found_distance_km: f.found_distance_km ? Number(f.found_distance_km) : null,
found_direction: f.found_direction || null,
found_location_type: f.found_location_type || null,
search_duration_hours: f.search_duration_hours ? Number(f.search_duration_hours) : null,
who_found: f.who_found || null,
};
const r = await apiFetch(`/api/v1/operations/${operation.id}/complete`, {
method: 'POST', body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (r.ok) {
const acc = d.accuracy_note ? ` Точность прогноза: ${d.accuracy_note}` : '';
setCompleteResult({ ok: true, message: `Поиск завершён, кейс в архиве.${acc}` });
setOperation({ ...operation, status: 'completed', closed_at: d.closed_at });
setTimeout(() => setShowComplete(false), 1800);
} else {
setCompleteResult({ ok: false, message: d.detail || 'Ошибка завершения' });
}
} catch (e) {
setCompleteResult({ ok: false, message: e.message });
} finally {
setCompleting(false);
}
}}
>{completing ? 'Завершение...' : 'Завершить поиск'}</button>
</div>
</div>
</div>
)}
</div>
);
};