From 50a261e05e1e98f4bd1feab5ebdf3152e8bb14e9 Mon Sep 17 00:00:00 2001 From: viktot14-ai Date: Fri, 25 Sep 2026 10:34:53 +0300 Subject: [PATCH] =?UTF-8?q?B17/E5:=20=D0=B8=D0=BD=D0=B3=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20=D0=9A=D0=9E=D0=9D=D0=A2?= =?UTF-8?q?=D0=A3=D0=A0=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/аудит/локальное чтение --- .../alembic/versions/011_b17_field_data.py | 37 +++ backend/models.py | 7 +- backend/routers/contour.py | 160 +++++++++++- backend/tests/test_b17_field_data.py | 229 ++++++++++++++++++ frontend/src/components/SearchMap.jsx | 40 ++- .../src/pages/analysis/AnalysisResult.css | 4 +- .../src/pages/analysis/AnalysisResult.jsx | 28 +++ 7 files changed, 497 insertions(+), 8 deletions(-) create mode 100644 backend/alembic/versions/011_b17_field_data.py create mode 100644 backend/tests/test_b17_field_data.py diff --git a/backend/alembic/versions/011_b17_field_data.py b/backend/alembic/versions/011_b17_field_data.py new file mode 100644 index 0000000..f0e80f3 --- /dev/null +++ b/backend/alembic/versions/011_b17_field_data.py @@ -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') \ No newline at end of file diff --git a/backend/models.py b/backend/models.py index 212d327..d62edf9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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"), ) diff --git a/backend/routers/contour.py b/backend/routers/contour.py index 67aa3c6..bf58a11 100644 --- a/backend/routers/contour.py +++ b/backend/routers/contour.py @@ -4,11 +4,18 @@ POST /api/v1/operations/{id}/send-to-contour: анализ кейса → SearchZones[] (GeoJSON-сектора направлений) → POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id= +B17/E5: ингестия полевых данных (inbound): + POST /api/v1/operations/{id}/field-data/sync — тянет из КОНТУРа + GET /api/v1/vector/areas-checked?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() \ No newline at end of file + 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), + ) \ No newline at end of file diff --git a/backend/tests/test_b17_field_data.py b/backend/tests/test_b17_field_data.py new file mode 100644 index 0000000..9807a25 --- /dev/null +++ b/backend/tests/test_b17_field_data.py @@ -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': []} \ No newline at end of file diff --git a/frontend/src/components/SearchMap.jsx b/frontend/src/components/SearchMap.jsx index 060bd05..93eb62c 100644 --- a/frontend/src/components/SearchMap.jsx +++ b/frontend/src/components/SearchMap.jsx @@ -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 ( { ))} + {/* B17: проверенные квадраты КОНТУРа (поверх гео, под зонами) */} + {fieldGeoJSON.map(({ area, positions }, idx) => ( + + + Проверенный участок {area.team_id ? `— ${area.team_id}` : ''}
+ Статус: {area.result === 'clear' ? '✅ прочесано (≥99.5%)' : '🟡 частично'}
+ {area.coverage_pct != null && <>Покрытие: {Math.round(area.coverage_pct)}%
} + {area.checked_at && <>Проверено: {new Date(area.checked_at).toLocaleString('ru-RU')}
} + {area.synced && данные синхронизированы из КОНТУРа} +
+
+ ))} + {/* Базовый слой: концентрические круги возможной зоны поиска */} {defaultZones.map((zone, idx) => ( { 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 || []} /> ) : (
ТНП не задана — карта недоступна
@@ -290,6 +299,25 @@ const AnalysisResult = () => { }} >Отправить зоны в КОНТУР +
+ +
{contourStatus && (
{contourStatus.message}