B17/E5: ингестия полевых данных КОНТУРа

- POST /operations/{id}/field-data/sync: тянет GET /api/v1/vector/areas-checked
  → идемпотентный upsert в areas_checked (dedupe по contour_area_id:
  geom/checked_at/result/coverage_pct/team_label, source=contour)
- GET /operations/{id}/areas-checked: теперь из локальной БД после sync
  (source=db), фолбэк на живой прокси КОНТУРа если пусто (source=contour/empty)
- миграция 011_b17_field_data: areas_checked +contour_area_id/team_label/source
  (+check ck_areas_checked_source)
- аудит field_data_sync; лог vector.contour field_sync_*
- фронт: кнопка «⟳ Синхронизировать полевые данные» в КОНТУР-панели,
  слой проверенных квадратов на карте (зелёный=clear ≥99.5%, жёлтый=partial)
- тесты: +9 (267 passed) — sync/идемпотентность/502/аудит/локальное чтение
This commit is contained in:
2026-09-25 10:34:53 +03:00
parent 1fdcc04af6
commit 50a261e05e
7 changed files with 497 additions and 8 deletions
+155 -5
View File
@@ -4,11 +4,18 @@ POST /api/v1/operations/{id}/send-to-contour:
анализ кейса → SearchZones[] (GeoJSON-сектора направлений) →
POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id=<contour_operation_id>
B17/E5: ингестия полевых данных (inbound):
POST /api/v1/operations/{id}/field-data/sync — тянет из КОНТУРа
GET /api/v1/vector/areas-checked?operation_id=<contour_operation_id> и
upsert'ит в локальную areas_checked (dedupe по contour_area_id);
GET /api/v1/operations/{id}/areas-checked — читает локальную БД.
Требуется: у операции задан contour_operation_id (UUID операции в КОНТУРе),
env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets).
"""
from __future__ import annotations
import json
import math
import os
import uuid as uuid_mod
@@ -18,11 +25,12 @@ from typing import Any, Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from backend.audit import audit_log, can_access_unit
from backend.database import get_db
from backend.logging_setup import get_logger
from backend.models import SearchOperation, User
from backend.models import AreaChecked, SearchOperation, User
from backend.routers.auth import get_current_user, require_permission
log = get_logger('contour')
@@ -190,12 +198,22 @@ def _load_operation(db, user: User, operation_id: str) -> SearchOperation:
def areas_checked(
operation_id: str,
current_user: User = Depends(get_current_user),
db = Depends(get_db),
db: Session = Depends(get_db),
) -> dict[str, Any]:
"""Прокси: проверенные квадраты операции из КОНТУРа (inbound B17-заготовка)."""
"""Проверенные квадраты операции (B17): из локальной БД после sync.
Если ещё не синхронизировано (0 записей) — прозрачно проксирует в КОНТУР,
чтобы страница работала без обязательного sync (live-режим, B17-заготовка).
"""
op = _load_operation(db, current_user, operation_id)
local = _local_areas_checked(db, op)
if local:
return {'source': 'db', 'items': local}
# Фолбэк: живой прокси в КОНТУР (как было до B17)
if not op.contour_operation_id:
raise HTTPException(status_code=400, detail='contour_operation_id не задан')
return {'source': 'empty', 'items': []}
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}"
@@ -205,4 +223,136 @@ def areas_checked(
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()
return {'source': 'contour', 'items': resp.json()}
def _parse_iso(dt_str: Any) -> Optional[datetime]:
if not dt_str or not isinstance(dt_str, str):
return None
try:
return datetime.fromisoformat(str(dt_str).replace('Z', '+00:00'))
except ValueError:
return None
def _parse_contour_geom(raw: Any) -> Optional[dict]:
"""Геометрия КОНТУРа приходит JSON-строкой (ST_AsGeoJSON) — иногда dict."""
if raw is None:
return None
if isinstance(raw, dict):
return raw
try:
return json.loads(str(raw))
except (ValueError, TypeError):
return None
def _local_areas_checked(db: Session, op: SearchOperation) -> list[dict[str, Any]]:
"""Локальные проверенные квадраты операции (после field-data/sync)."""
from backend.models import Case
case = db.get(Case, op.case_id)
if case is None:
return []
rows = (db.query(AreaChecked)
.filter(AreaChecked.case_id == op.case_id)
.order_by(AreaChecked.checked_at.desc().nullslast())
.all())
return [{
'area_id': r.contour_area_id or str(r.id),
'geom': r.geom,
'team_id': r.team_label,
'checked_at': r.checked_at.isoformat() if r.checked_at else None,
'result': r.result,
'coverage_pct': r.coverage_pct,
'synced': True,
} for r in rows]
class SyncResult(BaseModel):
synced_areas: int
updated_areas: int
new_areas: int
found_events: int
last_checked_at: Optional[str] = None
contour_area_count: int
@router.post('/{operation_id}/field-data/sync')
def field_data_sync(
operation_id: str,
request: Request,
current_user: User = Depends(require_permission('update')),
db: Session = Depends(get_db),
) -> SyncResult:
"""B17: синхронизация полевых данных из КОНТУРа в локальную БД.
Тянет GET /api/v1/vector/areas-checked (весь список, idempotent upsert
по contour_area_id). found (100%) на стороне КОНТУРа сейчас не бывает —
находка фиксируется завершением поиска; счётчик found_events — резерв.
"""
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:
log.warning('field_sync_unreachable op=%s error=%s', op.id, e)
raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
if resp.status_code >= 400:
log.warning('field_sync_rejected op=%s status=%d', op.id, resp.status_code)
raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}')
items = resp.json()
if not isinstance(items, list):
raise HTTPException(status_code=502, detail='КОНТУР вернул неожиданный формат areas-checked')
case_id = op.case_id
existing = {r.contour_area_id: r for r in
db.query(AreaChecked).filter(AreaChecked.case_id == case_id).all()
if r.contour_area_id}
new_n = upd_n = 0
latest: Optional[datetime] = None
for item in items:
if not isinstance(item, dict) or not item.get('area_id'):
continue
geom = _parse_contour_geom(item.get('geom'))
checked_at = _parse_iso(item.get('checked_at'))
if checked_at and (latest is None or checked_at > latest):
latest = checked_at
row = existing.get(item['area_id'])
if row is None:
row = AreaChecked(case_id=case_id, contour_area_id=item['area_id'])
db.add(row)
new_n += 1
else:
upd_n += 1
row.geom = geom
row.checked_at = checked_at
row.result = 'clear' if item.get('result') == 'clear' else 'partial'
row.coverage_pct = float(item['coverage_pct']) if item.get('coverage_pct') is not None else None
row.team_label = str(item['team_id']) if item.get('team_id') else None
row.source = 'contour'
db.commit()
if new_n or upd_n:
log.info('field_sync op=%s квадратов=%d (новых=%d обновлено=%d) from=%s',
op.id, new_n + upd_n, new_n, _contour_url())
audit_log(db, current_user, 'field_data_sync', object_type='search_operation',
object_id=str(op.id), request=request,
details={'new': new_n, 'updated': upd_n,
'total': len(items)})
return SyncResult(
synced_areas=new_n + upd_n,
updated_areas=upd_n,
new_areas=new_n,
found_events=0,
last_checked_at=latest.isoformat() if latest else None,
contour_area_count=len(items),
)