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
+200
View File
@@ -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()
+12
View File
@@ -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: