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:
@@ -10,6 +10,7 @@ from backend.database import init_db
|
||||
from backend.routers.admin import router as admin_router
|
||||
from backend.routers.admin_users import router as admin_users_router
|
||||
from backend.routers.operations import router as operations_router
|
||||
from backend.routers.contour import router as contour_router
|
||||
from backend.routers.analyze import router as analyze_router
|
||||
from backend.routers.auth import router as auth_router
|
||||
from backend.routers.cases import router as cases_router
|
||||
@@ -57,6 +58,7 @@ app.include_router(stats_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(admin_users_router)
|
||||
app.include_router(operations_router)
|
||||
app.include_router(contour_router)
|
||||
app.include_router(water_router)
|
||||
app.include_router(geocode_router)
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""B21/E4: отправка приоритетных зон в КОНТУР (контракт B20, outbound).
|
||||
|
||||
POST /api/v1/operations/{id}/send-to-contour:
|
||||
анализ кейса → SearchZones[] (GeoJSON-сектора направлений) →
|
||||
POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id=<contour_operation_id>
|
||||
|
||||
Требуется: у операции задан contour_operation_id (UUID операции в КОНТУРе),
|
||||
env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import uuid as uuid_mod
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.audit import audit_log, can_access_unit
|
||||
from backend.database import get_db
|
||||
from backend.models import SearchOperation, User
|
||||
from backend.routers.auth import get_current_user, require_permission
|
||||
|
||||
router = APIRouter(prefix='/api/v1/operations', tags=['operations-contour'])
|
||||
|
||||
def _contour_url() -> str:
|
||||
return os.getenv('CONTOUR_API_URL', 'http://192.168.0.130:8000')
|
||||
|
||||
|
||||
def _contour_token() -> str:
|
||||
return os.getenv('CONTOUR_TOKEN', '')
|
||||
SCHEMA_VERSION = '1.0'
|
||||
|
||||
_DIRECTIONS = {
|
||||
'N': 0, 'NE': 45, 'E': 90, 'SE': 135,
|
||||
'S': 180, 'SW': 225, 'W': 270, 'NW': 315,
|
||||
}
|
||||
|
||||
|
||||
class SendResult(BaseModel):
|
||||
sent_zones: int
|
||||
contour_operation_id: str
|
||||
contour_response: dict[str, Any]
|
||||
|
||||
|
||||
def _sector_polygon(lat: float, lon: float, radius_km: float, direction: str,
|
||||
segments: int = 24) -> dict:
|
||||
"""GeoJSON Polygon сектора ±22.5° вокруг направления (для контракта B20)."""
|
||||
az = _DIRECTIONS.get(direction, 0)
|
||||
half = 22.5
|
||||
ring: list[list[float]] = [[lon, lat]]
|
||||
for i in range(segments + 1):
|
||||
bearing = math.radians(az - half + (2 * half) * i / segments)
|
||||
dlat = (radius_km / 111.0) * math.cos(bearing)
|
||||
dlon = (radius_km / (111.0 * math.cos(math.radians(lat)))) * math.sin(bearing)
|
||||
ring.append([round(lon + dlon, 7), round(lat + dlat, 7)])
|
||||
ring.append([lon, lat])
|
||||
return {'type': 'Polygon', 'coordinates': [ring]}
|
||||
|
||||
|
||||
def _probability_from_priority(priority: int, total: int) -> float:
|
||||
"""Градация: топ-зона → наибольшая вероятность (0..1)."""
|
||||
if total <= 1:
|
||||
return 0.8
|
||||
return round(max(0.1, 0.9 - 0.7 * (priority - 1) / max(1, total - 1)), 3)
|
||||
|
||||
|
||||
def _load_analysis(db, case_id) -> dict:
|
||||
"""Последний анализ кейса: search_models (B15) или analysis_log (legacy)."""
|
||||
from sqlalchemy import text as sa_text
|
||||
row = db.execute(
|
||||
sa_text("SELECT model_json FROM search_models "
|
||||
"WHERE case_id = :cid ORDER BY version DESC LIMIT 1"),
|
||||
{'cid': str(case_id)},
|
||||
).first()
|
||||
if row and row[0]:
|
||||
return dict(row[0])
|
||||
from backend.models import Case
|
||||
case = db.get(__import__('backend.models', fromlist=['Case']).Case, case_id)
|
||||
if case and case.analysis_log and isinstance(case.analysis_log, dict):
|
||||
return dict(case.analysis_log)
|
||||
raise HTTPException(status_code=400, detail='У карточки нет анализа — запустите анализ сначала')
|
||||
|
||||
|
||||
def _build_b20_payload(op: SearchOperation, analysis: dict) -> dict:
|
||||
"""SearchZones[] из primary_zones анализа (полигоны направлений)."""
|
||||
lat = analysis.get('lat') or analysis.get('tnp_lat')
|
||||
lon = analysis.get('lon') or analysis.get('tnp_lon')
|
||||
if lat is None or lon is None:
|
||||
raise HTTPException(status_code=400,
|
||||
detail='В анализе нет координат ТНП — невозможны геометрии зон')
|
||||
max_distance = float(analysis.get('max_distance_km') or 3.0)
|
||||
zones_in = analysis.get('primary_zones') or []
|
||||
if not zones_in:
|
||||
raise HTTPException(status_code=400, detail='В анализе нет приоритетных зон')
|
||||
|
||||
out_zones = []
|
||||
for z in zones_in[:5]:
|
||||
direction = z.get('direction')
|
||||
distance = float(z.get('distance') or max_distance * 0.5)
|
||||
priority = int(z.get('priority') or 1)
|
||||
out_zones.append({
|
||||
'zone_id': f"vec:{direction}:{int(distance * 1000)}m",
|
||||
'geom': _sector_polygon(float(lat), float(lon), distance, direction),
|
||||
'priority': priority,
|
||||
'probability': _probability_from_priority(priority, len(zones_in[:5])),
|
||||
'reasoning': z.get('reason') or f'Приоритет {priority} по модели ВЕКТОРа',
|
||||
})
|
||||
|
||||
return {
|
||||
'schema_version': SCHEMA_VERSION,
|
||||
'case_id': str(op.case_id),
|
||||
'model_version': f"vector:{(analysis.get('analyzed_at') or datetime.now(timezone.utc).isoformat())[:19]}",
|
||||
'generated_at': analysis.get('analyzed_at') or datetime.now(timezone.utc).isoformat(),
|
||||
'max_distance_km': max_distance,
|
||||
'zones': out_zones,
|
||||
}
|
||||
|
||||
|
||||
@router.post('/{operation_id}/send-to-contour')
|
||||
def send_to_contour(
|
||||
operation_id: str,
|
||||
request: Request,
|
||||
current_user: User = Depends(require_permission('update')),
|
||||
db = Depends(get_db),
|
||||
) -> SendResult:
|
||||
op = _load_operation(db, current_user, operation_id)
|
||||
|
||||
if not op.contour_operation_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='У операции не задан contour_operation_id (UUID операции в КОНТУРе) — '
|
||||
'укажите его в карточке операции (PATCH /operations/{id})',
|
||||
)
|
||||
if not _contour_token():
|
||||
raise HTTPException(status_code=503, detail='CONTOUR_TOKEN не настроен')
|
||||
|
||||
analysis = _load_analysis(db, op.case_id)
|
||||
payload = _build_b20_payload(op, analysis)
|
||||
|
||||
url = f"{_contour_url()}/api/v1/vector/zones?operation_id={op.contour_operation_id}"
|
||||
try:
|
||||
resp = httpx.post(url, json=payload,
|
||||
headers={'Authorization': f'Bearer {_contour_token()}'},
|
||||
timeout=30.0)
|
||||
except httpx.HTTPError as e:
|
||||
audit_log(db, current_user, 'contour_send_failed', object_type='operation',
|
||||
object_id=str(op.id), request=request, details={'error': str(e)})
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
|
||||
|
||||
if resp.status_code >= 400:
|
||||
audit_log(db, current_user, 'contour_send_failed', object_type='operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'status': resp.status_code, 'body': resp.text[:500]})
|
||||
raise HTTPException(status_code=502,
|
||||
detail=f'КОНТУР отклонил импорт: {resp.status_code} {resp.text[:300]}')
|
||||
|
||||
result = resp.json()
|
||||
audit_log(db, current_user, 'contour_send_ok', object_type='operation',
|
||||
object_id=str(op.id), request=request,
|
||||
details={'zones': result.get('zones_total'), 'status': result.get('status')})
|
||||
return SendResult(
|
||||
sent_zones=payload['zones'].__len__(),
|
||||
contour_operation_id=str(op.contour_operation_id),
|
||||
contour_response=result,
|
||||
)
|
||||
|
||||
|
||||
def _load_operation(db, user: User, operation_id: str) -> SearchOperation:
|
||||
op = db.get(SearchOperation, uuid_mod.UUID(operation_id)) if operation_id else None
|
||||
if not op:
|
||||
raise HTTPException(status_code=404, detail='Операция не найдена')
|
||||
if not can_access_unit(db, user, str(op.unit_id) if op.unit_id else None):
|
||||
raise HTTPException(status_code=403, detail='Операция вне вашего скоупа')
|
||||
return op
|
||||
|
||||
|
||||
@router.get('/{operation_id}/areas-checked')
|
||||
def areas_checked(
|
||||
operation_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db = Depends(get_db),
|
||||
) -> dict[str, Any]:
|
||||
"""Прокси: проверенные квадраты операции из КОНТУРа (inbound B17-заготовка)."""
|
||||
op = _load_operation(db, current_user, operation_id)
|
||||
if not op.contour_operation_id:
|
||||
raise HTTPException(status_code=400, detail='contour_operation_id не задан')
|
||||
if not _contour_token():
|
||||
raise HTTPException(status_code=503, detail='CONTOUR_TOKEN не настроен')
|
||||
url = f"{_contour_url()}/api/v1/vector/areas-checked?operation_id={op.contour_operation_id}"
|
||||
try:
|
||||
resp = httpx.get(url, headers={'Authorization': f'Bearer {_contour_token()}'}, timeout=30.0)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
|
||||
if resp.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}')
|
||||
return resp.json()
|
||||
@@ -38,6 +38,7 @@ 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:
|
||||
@@ -63,6 +64,7 @@ def _get_operation_checked(db: Session, user: User, operation_id: str) -> Search
|
||||
@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]:
|
||||
@@ -72,6 +74,9 @@ def list_operations(
|
||||
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 = {}
|
||||
@@ -199,6 +204,13 @@ def update_operation(
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""E4 (B21): тесты отправки зон в КОНТУР — build payload, мок httpx, аудиты."""
|
||||
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'
|
||||
|
||||
|
||||
ANALYSIS = {
|
||||
'lat': 53.9, 'lon': 27.56, 'max_distance_km': 5.4,
|
||||
'analyzed_at': '2026-09-09T12:00:00+00:00',
|
||||
'primary_zones': [
|
||||
{'priority': 1, 'direction': 'NE', 'distance': 2.0, 'reason': 'лес+вода'},
|
||||
{'priority': 2, 'direction': 'E', 'distance': 2.0, 'reason': 'лес'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def contour_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')
|
||||
db.add(case); db.flush()
|
||||
case.analysis_log = dict(ANALYSIS)
|
||||
db.commit()
|
||||
|
||||
contour_op_id = '22222222-2222-4222-8222-000000000001' # как в КОНТУРе e2e
|
||||
|
||||
op = SearchOperation(title='Тестовая операция', case_id=case.id, status='active',
|
||||
unit_id=rcu.id, created_by=root.id,
|
||||
contour_operation_id=__import__('uuid').UUID(contour_op_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
|
||||
client = TestClient(app)
|
||||
tok = client.post('/api/v1/auth/login', data={'username': 'root', 'password': 'rootpass123'}).json()['access_token']
|
||||
|
||||
# env для contour
|
||||
monkeypatch.setenv('CONTOUR_API_URL', 'http://contour-mock:8000')
|
||||
monkeypatch.setenv('CONTOUR_TOKEN', 'test-token')
|
||||
|
||||
yield {'db': db, 'client': client, 'token': tok, 'op': op,
|
||||
'case': case, 'contour_op_id': contour_op_id}
|
||||
|
||||
app.dependency_overrides.pop(auth_router.get_db, None)
|
||||
|
||||
|
||||
class TestSendToContour:
|
||||
def test_missing_contour_id_400(self, contour_env):
|
||||
from backend.models import SearchOperation
|
||||
op = contour_env['op']
|
||||
op.contour_operation_id = None
|
||||
contour_env['db'].commit()
|
||||
r = contour_env['client'].post(
|
||||
f'/api/v1/operations/{op.id}/send-to-contour',
|
||||
headers={'Authorization': f"Bearer {contour_env['token']}"})
|
||||
assert r.status_code == 400
|
||||
assert 'contour_operation_id' in r.json()['detail']
|
||||
|
||||
def test_send_builds_valid_b20_payload(self, contour_env, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
captured['url'] = url
|
||||
captured['json'] = json
|
||||
captured['headers'] = headers
|
||||
|
||||
class R:
|
||||
status_code = 200
|
||||
text = ''
|
||||
def json(self):
|
||||
return {'status': 'applied', 'zones_total': 2, 'accepted': 2}
|
||||
return R()
|
||||
|
||||
import httpx
|
||||
monkeypatch.setattr(httpx, 'post', fake_post)
|
||||
|
||||
r = contour_env['client'].post(
|
||||
f"/api/v1/operations/{contour_env['op'].id}/send-to-contour",
|
||||
headers={'Authorization': f"Bearer {contour_env['token']}"})
|
||||
assert r.status_code == 200, r.json()
|
||||
body = r.json()
|
||||
assert body['sent_zones'] == 2
|
||||
assert body['contour_operation_id'] == contour_env['contour_op_id']
|
||||
|
||||
payload = captured['json']
|
||||
assert payload['schema_version'] == '1.0'
|
||||
assert payload['max_distance_km'] == 5.4
|
||||
assert len(payload['zones']) == 2
|
||||
z1 = payload['zones'][0]
|
||||
assert z1['zone_id'] == 'vec:NE:2000m'
|
||||
assert z1['geom']['type'] == 'Polygon'
|
||||
ring = z1['geom']['coordinates'][0]
|
||||
assert ring[0] == ring[-1] # замкнут
|
||||
assert 0 < z1['probability'] <= 1
|
||||
# operation_id в query
|
||||
assert contour_env['contour_op_id'] in captured['url']
|
||||
# auth на КОНТУР
|
||||
assert captured['headers']['Authorization'] == 'Bearer test-token'
|
||||
|
||||
def test_send_failure_audited(self, contour_env, monkeypatch):
|
||||
import httpx
|
||||
|
||||
def fake_post(url, json=None, headers=None, timeout=None):
|
||||
class R:
|
||||
status_code = 400
|
||||
text = 'bad zones'
|
||||
def json(self):
|
||||
return {'detail': 'bad zones'}
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(httpx, 'post', fake_post)
|
||||
r = contour_env['client'].post(
|
||||
f"/api/v1/operations/{contour_env['op'].id}/send-to-contour",
|
||||
headers={'Authorization': f"Bearer {contour_env['token']}"})
|
||||
assert r.status_code == 502
|
||||
from backend.models import AuditEvent
|
||||
types = {e.event_type for e in contour_env['db'].query(AuditEvent).all()}
|
||||
assert 'contour_send_failed' in types
|
||||
|
||||
def test_sector_polygon_geometry(self, contour_env):
|
||||
from backend.routers.contour import _sector_polygon
|
||||
poly = _sector_polygon(53.9, 27.56, 2.0, 'N')
|
||||
ring = poly['coordinates'][0]
|
||||
assert poly['type'] == 'Polygon'
|
||||
assert ring[0] == ring[-1]
|
||||
# Север: lat растёт
|
||||
lats = [p[1] for p in ring[1:-1]]
|
||||
assert max(lats) > 53.9
|
||||
# Дуга ~2 км
|
||||
assert max(lats) < 53.9 + (2.0 / 111.0) * 1.05
|
||||
@@ -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>}
|
||||
|
||||
Reference in New Issue
Block a user