"""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 (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()