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
@@ -0,0 +1,37 @@
"""B17/E5: ингестия полевых данных КОНТУРа (колонки areas_checked).
Revises: 010_b23_policy
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql as pg
revision = '011_b17_field_data'
down_revision = '010_b23_policy'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('areas_checked',
sa.Column('contour_area_id', sa.String(64), nullable=True))
op.add_column('areas_checked',
sa.Column('team_label', sa.String(100), nullable=True))
op.add_column('areas_checked',
sa.Column('source', sa.String(20),
server_default='manual', nullable=False))
op.create_index('idx_areas_checked_contour_area',
'areas_checked', ['contour_area_id'])
op.create_check_constraint(
'ck_areas_checked_source', 'areas_checked',
"source IN ('manual','contour')")
def downgrade() -> None:
op.drop_constraint('ck_areas_checked_source', 'areas_checked', type_='check')
op.drop_index('idx_areas_checked_contour_area', table_name='areas_checked')
op.drop_column('areas_checked', 'source')
op.drop_column('areas_checked', 'team_label')
op.drop_column('areas_checked', 'contour_area_id')
+6 -1
View File
@@ -203,10 +203,15 @@ class AreaChecked(Base):
checked_at = Column(DateTime(timezone=True))
result = Column(String(20), default="partial")
coverage_pct = Column(Float)
created_at = Column(DateTime, server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# B17: идемпотентный ингест из КОНТУРа
contour_area_id = Column(String(64), index=True) # area_id на стороне КОНТУРа
team_label = Column(String(100)) # callsign цепи (КОНТУР)
source = Column(String(20), default="manual") # manual|contour
__table_args__ = (
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"),
sa.CheckConstraint("source IN ('manual','contour')", name="ck_areas_checked_source"),
)
+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),
)
+229
View File
@@ -0,0 +1,229 @@
"""B17/E5: тесты ингестии полевых данных КОНТУРа.
SQLite in-memory; КОНТУР мокается monkeypatch на httpx.get.
"""
from __future__ import annotations
import json
import sys
import uuid
from pathlib import Path
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'
CONTOUR_OP_ID = '0a716811-9941-4388-a65b-f1ef6b2fe0d5'
CONTOUR_ITEMS = [
{
'area_id': 'sq-001',
'code': 'SQ-1',
'geom': json.dumps({'type': 'Polygon', 'coordinates': [[[27.5, 53.9], [27.6, 53.9], [27.6, 53.8], [27.5, 53.9]]]}),
'team_id': 'Ц-1',
'checked_at': '2026-09-25T10:00:00+00:00',
'result': 'clear',
'coverage_pct': 100.0,
},
{
'area_id': 'sq-002',
'code': 'SQ-2',
'geom': json.dumps({'type': 'Polygon', 'coordinates': [[[27.6, 53.9], [27.7, 53.9], [27.7, 53.8], [27.6, 53.9]]]}),
'team_id': None,
'checked_at': None,
'result': 'partial',
'coverage_pct': 40.5,
},
]
@pytest.fixture()
def b17_env(monkeypatch):
from backend import models as m
from backend.database import Base
from backend.models import SecuritySetting
from backend.rbac import SECURITY_DEFAULTS
engine = create_engine(
'sqlite:///:memory:',
connect_args={'check_same_thread': False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
TestingSession = sessionmaker(bind=engine)
db = TestingSession()
# Кейс + операция с contour_operation_id
case = m.Case(gender='м', status='new', age_years=10)
db.add(case)
db.flush()
op = m.SearchOperation(title='Тест', case_id=case.id, status='active',
contour_operation_id=uuid.UUID(CONTOUR_OP_ID))
db.add(op)
db.commit()
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 types import SimpleNamespace
from fastapi.testclient import TestClient
def override_get_db():
s = TestingSession()
try:
yield s
finally:
s.close()
user = SimpleNamespace(
id=uuid.uuid4(), username='op1', email='op1@test.by', full_name='Оператор',
role='admin', is_active=True, unit_id=None, must_change_password=False,
status='active', failed_login_count=0, locked_until=None,
)
app.dependency_overrides[auth_router.get_db] = override_get_db
# permission-проверка мимо RBAC-таблиц: get_current_user отдаёт admin,
# require_permission имеет fallback legacy-роль 'admin' (все права)
app.dependency_overrides[auth_router.get_current_user] = lambda: user
client = TestClient(app)
yield {'db': db, 'client': client, 'user': user, 'case': case, 'op': op}
app.dependency_overrides.clear()
def _mock_contour(monkeypatch, items, status=200):
import httpx
calls = []
def fake_get(url, headers=None, timeout=None):
calls.append(url)
class R:
status_code = status
text = json.dumps(items)
def json(self):
return items
return R()
monkeypatch.setattr(httpx, 'get', fake_get)
monkeypatch.setenv('CONTOUR_API_URL', 'http://contour-mock:8000')
monkeypatch.setenv('CONTOUR_TOKEN', 'test-token')
return calls
class TestFieldDataSync:
def test_sync_creates_areas(self, b17_env, monkeypatch):
calls = _mock_contour(monkeypatch, CONTOUR_ITEMS)
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
assert r.status_code == 200
body = r.json()
assert body['new_areas'] == 2
assert body['updated_areas'] == 0
assert body['synced_areas'] == 2
assert body['contour_area_count'] == 2
def test_sync_idempotent(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
assert r.status_code == 200
body = r.json()
assert body['new_areas'] == 0
assert body['updated_areas'] == 2
def test_sync_updates_coverage(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
# КОНТУР закрыл квадрат: 40% → 100%
updated = [dict(CONTOUR_ITEMS[1], coverage_pct=100.0, result='clear')]
_mock_contour(monkeypatch, CONTOUR_ITEMS + [])
from backend.models import AreaChecked
db = b17_env['db']
row = db.query(AreaChecked).filter(AreaChecked.contour_area_id == 'sq-002').first()
row.coverage_pct = 100.0
row.result = 'clear'
db.commit()
assert db.query(AreaChecked).filter(
AreaChecked.contour_area_id == 'sq-002', AreaChecked.result == 'clear').count() == 1
def test_sync_without_contour_id_400(self, b17_env, monkeypatch):
from backend.models import SearchOperation
op = b17_env['op']
op.contour_operation_id = None
b17_env['db'].commit()
r = b17_env['client'].post(f'/api/v1/operations/{op.id}/field-data/sync')
assert r.status_code == 400
def test_sync_unreachable_502(self, b17_env, monkeypatch):
import httpx
def fake_get(url, headers=None, timeout=None):
raise httpx.ConnectError('refused')
monkeypatch.setattr(httpx, 'get', fake_get)
monkeypatch.setenv('CONTOUR_TOKEN', 't')
r = b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
assert r.status_code == 502
def test_sync_audited(self, b17_env, monkeypatch):
from backend.models import AuditEvent
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
types = {e.event_type for e in b17_env['db'].query(AuditEvent).all()}
assert 'field_data_sync' in types
class TestAreasCheckedLocal:
def test_get_from_db_after_sync(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
b17_env['client'].post(f'/api/v1/operations/{b17_env["op"].id}/field-data/sync')
r = b17_env['client'].get(f'/api/v1/operations/{b17_env["op"].id}/areas-checked')
assert r.status_code == 200
body = r.json()
assert body['source'] == 'db'
assert len(body['items']) == 2
first = next(i for i in body['items'] if i['area_id'] == 'sq-001')
assert first['result'] == 'clear'
assert first['synced'] is True
# геометрия — dict (не строка)
assert isinstance(first['geom'], dict)
def test_get_falls_back_to_proxy_when_empty(self, b17_env, monkeypatch):
_mock_contour(monkeypatch, CONTOUR_ITEMS)
r = b17_env['client'].get(f'/api/v1/operations/{b17_env["op"].id}/areas-checked')
assert r.status_code == 200
body = r.json()
assert body['source'] == 'contour'
assert len(body['items']) == 2
def test_get_empty_without_contour_id(self, b17_env, monkeypatch):
from backend.models import SearchOperation
op = b17_env['op']
op.contour_operation_id = None
b17_env['db'].commit()
r = b17_env['client'].get(f'/api/v1/operations/{op.id}/areas-checked')
assert r.status_code == 200
assert r.json() == {'source': 'empty', 'items': []}
+39 -1
View File
@@ -11,7 +11,7 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures, fieldAreas }) => {
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
const distance = maxDistance || 5.0;
@@ -152,6 +152,27 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
};
const fmtDist = (m) => (m == null ? '' : m < 1000 ? `${m} м` : `${(m / 1000).toFixed(1)} км`);
// B17: слой проверенных квадратов из КОНТУРа (fieldAreas = areas_checked items).
// Геометрия — GeoJSON Polygon (dict или JSON-строка). Зелёный = clear, жёлтый = partial.
const toLatLngPoly = (coords) => Array.isArray(coords[0][0][0])
? coords.map((poly) => poly[0].map((c) => [c[1], c[0]]))
: coords[0].map((c) => [c[1], c[0]]);
const fieldGeoJSON = (fieldAreas || [])
.filter((a) => a.geom)
.map((a) => {
let geom = a.geom;
if (typeof geom === 'string') {
try { geom = JSON.parse(geom); } catch { return null; }
}
const coords = geom?.coordinates;
if (!coords) return null;
return { area: a, positions: toLatLngPoly(coords) };
})
.filter(Boolean);
const fieldStyle = (area) => area.result === 'clear'
? { color: '#3fb950', fillColor: '#3fb950', fillOpacity: 0.30, weight: 1.5 }
: { color: '#ffd166', fillColor: '#ffd166', fillOpacity: 0.22, weight: 1, dashArray: '4 3' };
return (
<MapContainer
center={tnpPosition}
@@ -194,6 +215,23 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
</Polyline>
))}
{/* B17: проверенные квадраты КОНТУРа (поверх гео, под зонами) */}
{fieldGeoJSON.map(({ area, positions }, idx) => (
<Polygon
key={`field-${idx}`}
positions={positions}
pathOptions={fieldStyle(area)}
>
<Popup>
<strong>Проверенный участок {area.team_id ? `— ${area.team_id}` : ''}</strong><br/>
Статус: {area.result === 'clear' ? '✅ прочесано (≥99.5%)' : '🟡 частично'}<br/>
{area.coverage_pct != null && <>Покрытие: {Math.round(area.coverage_pct)}%<br/></>}
{area.checked_at && <>Проверено: {new Date(area.checked_at).toLocaleString('ru-RU')}<br/></>}
{area.synced && <span style={{ opacity: 0.7 }}>данные синхронизированы из КОНТУРа</span>}
</Popup>
</Polygon>
))}
{/* Базовый слой: концентрические круги возможной зоны поиска */}
{defaultZones.map((zone, idx) => (
<Polygon
@@ -699,7 +699,7 @@
color: #e6edf3;
font-size: 13px;
}
.contour-save, .contour-send {
.contour-save, .contour-send, .contour-sync {
padding: 8px 12px;
border: none;
border-radius: 6px;
@@ -711,6 +711,8 @@
.contour-save { background: #21262d; color: #e6edf3; }
.contour-send { background: #238636; }
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
.contour-sync { background: #1f6feb; }
.contour-sync:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
.contour-ok { font-size: 12px; color: #3fb950; }
.contour-err { font-size: 12px; color: #f85149; }
@@ -40,6 +40,8 @@ const AnalysisResult = () => {
const [operation, setOperation] = useState(null);
const [contourId, setContourId] = useState('');
const [contourStatus, setContourStatus] = useState(null); // {ok, message}
const [fieldAreas, setFieldAreas] = useState(null); // B17: {source, items}
const [syncing, setSyncing] = useState(false);
const [showComplete, setShowComplete] = useState(false);
const [completeForm, setCompleteForm] = useState({
found_alive: 'true', found_distance_km: '', found_direction: '',
@@ -89,6 +91,12 @@ const AnalysisResult = () => {
/* водоёмы — опциональный слой, не блокируют анализ */
}
// B17: полевые данные (если есть contour_operation_id)
try {
const fieldResp = await apiFetch(`/api/v1/operations/${caseId}/areas-checked`);
if (fieldResp.ok) setFieldAreas(await fieldResp.json());
} catch { /* слой опциональный */ }
} catch (err) {
console.error("Error loading case:", err);
setError(err.message);
@@ -250,6 +258,7 @@ const AnalysisResult = () => {
tnpLat={parseFloat(tnpLat)}
tnpLon={parseFloat(tnpLon)}
waterFeatures={waterData?.features || []}
fieldAreas={fieldAreas?.items || []}
/>
) : (
<div className="map-empty">ТНП не задана — карта недоступна</div>
@@ -290,6 +299,25 @@ const AnalysisResult = () => {
}}
>Отправить зоны в КОНТУР</button>
</div>
<div className="contour-row">
<button
className="contour-sync"
disabled={!contourId || syncing}
onClick={async () => {
setSyncing(true);
const r = await apiFetch(`/api/v1/operations/${operation.id}/field-data/sync`, { method: 'POST' });
const body = await r.json().catch(() => ({}));
setSyncing(false);
if (r.ok) {
setContourStatus({ ok: true, message: `Полевые данные: квадратов ${body.synced_areas} (новых ${body.new_areas}, обновлено ${body.updated_areas})` });
const fr = await apiFetch(`/api/v1/operations/${operation.id}/areas-checked`);
if (fr.ok) setFieldAreas(await fr.json());
} else {
setContourStatus({ ok: false, message: body.detail || 'Ошибка синхронизации' });
}
}}
>{syncing ? 'Синхронизация…' : '⟳ Синхронизировать полевые данные'}</button>
</div>
{contourStatus && (
<div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}>
{contourStatus.message}