E4 (B21): отправка зон в КОНТУР по операции (контракт B20)

- contour_operation_id в PATCH /operations/{id} (mapping на операцию КОНТУРа).
- backend/routers/contour.py: POST /{id}/send-to-contour — последний анализ
  (search_models → legacy analysis_log) → SearchZones[] GeoJSON-сектора
  направлений (±22.5°, probability по приоритету) → POST {CONTOUR_API_URL}/
  api/v1/vector/zones?operation_id=... (Bearer CONTOUR_TOKEN). Все шаги
  аудируются (contour_send_ok/failed). GET /{id}/areas-checked — прокси
  проверенных квадратов (inbound к B17).
- env: CONTOUR_API_URL (дефолт CT130:8000), CONTOUR_TOKEN (в secrets).
- case_id-фильтр в GET /operations.
- UI: панель «КОНТУР» на странице анализа: ввод/сохранение UUID операции
  КОНТУРа, кнопка «Отправить зоны в КОНТУР» со статусом результата.
- Тесты E4 (4): build B20-payload (полигон замкнут, probability 0..1,
  operation_id в query, Bearer), недостающий contour_id 400, отказ КОНТУРа
  → 502 + аудит, геометрия сектора.
226 passed, 5 skipped.
This commit is contained in:
2026-09-09 21:39:18 +03:00
parent ab3eebe274
commit 90213e0491
6 changed files with 509 additions and 1 deletions
+34 -1
View File
@@ -679,4 +679,37 @@
.case-info { flex-direction: column; align-items: flex-start; gap: 6px; }
.separator { display: none; }
.tactics-grid { grid-template-columns: 1fr; }
}
}
/* E4: панель КОНТУР */
.contour-panel {
margin-top: 12px;
padding: 12px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
}
.contour-title { font-size: 13px; font-weight: 600; color: #e6edf3; margin-bottom: 8px; }
.contour-row { display: flex; gap: 8px; margin-bottom: 8px; }
.contour-input {
flex: 1;
padding: 8px 10px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 13px;
}
.contour-save, .contour-send {
padding: 8px 12px;
border: none;
border-radius: 6px;
color: #fff;
font-size: 13px;
cursor: pointer;
white-space: nowrap;
}
.contour-save { background: #21262d; color: #e6edf3; }
.contour-send { background: #238636; }
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
.contour-ok { font-size: 12px; color: #3fb950; }
.contour-err { font-size: 12px; color: #f85149; }
@@ -36,6 +36,9 @@ const AnalysisResult = () => {
const [caseData, setCaseData] = useState(null);
const [analysisData, setAnalysisData] = useState(null);
const [waterData, setWaterData] = useState(null);
const [operation, setOperation] = useState(null);
const [contourId, setContourId] = useState('');
const [contourStatus, setContourStatus] = useState(null); // {ok, message}
useEffect(() => {
const loadCaseAndAnalyze = async () => {
@@ -49,6 +52,18 @@ const AnalysisResult = () => {
const caseResult = await caseResponse.json();
setCaseData(caseResult);
// E4: операция этого кейса (для КОНТУР-отправки)
try {
const opsResp = await apiFetch(`/api/v1/operations?case_id=${caseId}`);
if (opsResp.ok) {
const opsData = await opsResp.json();
if (opsData.items?.length) {
setOperation(opsData.items[0]);
setContourId(opsData.items[0].contour_operation_id || '');
}
}
} catch { /* операции может не быть — панель скрыта */ }
// 2. Анализ — единый источник правды на бэкенде
const analyzeResponse = await apiFetch(`/api/v1/analyze`, {
method: 'POST',
@@ -228,6 +243,49 @@ const AnalysisResult = () => {
) : (
<div className="map-empty">ТНП не задана — карта недоступна</div>
)}
{operation && (
<div className="contour-panel">
<div className="contour-title">🔗 КОНТУР (полевая координация)</div>
<div className="contour-row">
<input
className="contour-input"
placeholder="UUID операции в КОНТУРе (создаётся в штабной панели)"
value={contourId}
onChange={(e) => setContourId(e.target.value)}
/>
<button
className="contour-save"
onClick={async () => {
const r = await apiFetch(`/api/v1/operations/${operation.id}`, {
method: 'PATCH',
body: JSON.stringify({ contour_operation_id: contourId }),
});
setContourStatus(r.ok
? { ok: true, message: 'ID операции КОНТУРа сохранён' }
: { ok: false, message: 'Не удалось сохранить ID' });
}}
>Сохранить ID</button>
</div>
<div className="contour-row">
<button
className="contour-send"
disabled={!contourId || !analysisData}
onClick={async () => {
const r = await apiFetch(`/api/v1/operations/${operation.id}/send-to-contour`, { method: 'POST' });
const body = await r.json().catch(() => ({}));
setContourStatus(r.ok
? { ok: true, message: `Отправлено зон: ${body.sent_zones}. Статус в КОНТУРе: ${body.contour_response?.status}` }
: { ok: false, message: body.detail || 'Ошибка отправки' });
}}
>Отправить зоны в КОНТУР</button>
</div>
{contourStatus && (
<div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}>
{contourStatus.message}
</div>
)}
</div>
)}
<div className="map-info">
<span>ТНП: {tnpLat ? Number(tnpLat).toFixed(5) : '—'}, {tnpLon ? Number(tnpLon).toFixed(5) : '—'}</span>
{caseData.tnp_address && <span className="sep">·</span>}