Compare commits
6 Commits
1fdcc04af6
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ef18f8e28f | |||
| cdfbf30232 | |||
| 31757f18b3 | |||
| 05d6b3ce68 | |||
| a1de5794e2 | |||
| 50a261e05e |
@@ -334,6 +334,27 @@ SPA на react-scripts (CRA). Роутинг:
|
||||
|
||||
---
|
||||
|
||||
## 11а. Полевые данные КОНТУРа (B17/E5)
|
||||
|
||||
Двусторонний обмен с КОНТУРом по операции:
|
||||
|
||||
- **Outbound** (E4): `POST /operations/{id}/send-to-contour` — зоны расчёта.
|
||||
- **Inbound** (B17): `POST /operations/{id}/field-data/sync` — тянет из КОНТУРа
|
||||
`GET {CONTOUR_API_URL}/api/v1/vector/areas-checked?operation_id=<contour_operation_id>`
|
||||
и делает идемпотентный upsert в `areas_checked` (dedupe по `contour_area_id`,
|
||||
хранит geom GeoJSON / checked_at / result clear|partial / coverage_pct / callsign цепи,
|
||||
`source=contour`). Каждая синхронизация аудируется (`field_data_sync`).
|
||||
- `GET /operations/{id}/areas-checked` — после sync читает локальную БД (`source: db`),
|
||||
до первой синхронизации прозрачно проксирует КОНТУР (`source: contour`).
|
||||
- Фронт: кнопка «⟳ Синхронизировать полевые данные» в КОНТУР-панели страницы анализа;
|
||||
проверенные квадраты рисуются на карте **поверх зон** — зелёный = прочесано (≥99.5%),
|
||||
жёлтый пунктир = частично; в попапе покрытие %, время проверки, callsign.
|
||||
|
||||
Статусы КОНТУРа: `clear` (покрытие ≥99.5%) / `partial`. Нахождение субъекта на стороне
|
||||
КОНТУРа статусом не передаётся — находка фиксируется завершением поиска в ВЕКТОРе.
|
||||
|
||||
---
|
||||
|
||||
## 12. Интеграция с КОНТУР (отправка зон)
|
||||
|
||||
КОНТУР — система полевой координации (ATAK + Meshtastic). ВЕКТОР отдаёт приоритетные зоны (outbound):
|
||||
@@ -403,6 +424,7 @@ SPA на react-scripts (CRA). Роутинг:
|
||||
- Карта: концентрические круги + подсветка направлений (дизайн согласованный)
|
||||
- РБАК: иерархия РЦУ РЧС → ОУМЧС → Г(Р)ОЧС (142 юнита), 4 роли / 9 прав, блокировки, сессии
|
||||
- Мультипоиск: операции, статусная машина, дашборд «Активные поиски»
|
||||
- B17/E5: ингестия полевых данных КОНТУРа (проверенные квадраты на карте операции)
|
||||
- Завершение поиска с внесением исхода, архив завершённых с «прогноз vs факт»
|
||||
- Аудит 90 дней с field-level деталями
|
||||
- Прикладное логирование `vector.*`: события анализа, входов, КОНТУРа, аудита (README §13а)
|
||||
@@ -413,8 +435,8 @@ SPA на react-scripts (CRA). Роутинг:
|
||||
|
||||
### В планах
|
||||
|
||||
- **E5 (B17)** — приём полевых данных от КОНТУРа: треки групп, проверенные квадраты (areas-checked) на карте операции
|
||||
- **Живая интеграция с КОНТУР** (CT130) — настройка токена и реальная отправка зон
|
||||
- **E5/B17 расширение** — треки групп (tracks/live) как field_observations; полевой статус «найден»
|
||||
- **Авто-sync полевых данных** — периодический (сейчас кнопка вручную)
|
||||
- **Локальный tile-сервер** — для полностью offline-фронта (сейчас тайлы OSM с CDN в браузере)
|
||||
- **Калибровка settlement_score** — вкладка населённых пунктов в скоринге секторов (открытый методический вопрос)
|
||||
- **Калибровка зонального скоринга** по накопленному архиву «прогноз vs факт»
|
||||
|
||||
@@ -21,11 +21,13 @@ def upgrade() -> None:
|
||||
sa.Column('password_changed_at', sa.DateTime(timezone=True),
|
||||
server_default=sa.text('NOW()'), nullable=True),
|
||||
)
|
||||
# История паролей (проверка переиспользования, password_history_count)
|
||||
# История паролей (проверка переиспользования, password_history_count).
|
||||
# id — INTEGER monotonic (обрезка «последние N» детерминирована; created_at
|
||||
# в SQLite точен до секунды, UUID-порядок случайный — см. модели).
|
||||
op.create_table(
|
||||
'password_history',
|
||||
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True,
|
||||
server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||
sa.Column('public_id', pg.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', pg.UUID(as_uuid=True),
|
||||
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('hashed_password', sa.String(255), nullable=False),
|
||||
|
||||
@@ -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')
|
||||
+14
-3
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -340,10 +345,16 @@ class SecuritySetting(Base):
|
||||
|
||||
class PasswordHistoryEntry(Base):
|
||||
"""B23: история паролей (bcrypt-хэши). Записывается при каждой смене,
|
||||
проверяется против password_history_count последних записей."""
|
||||
проверяется против password_history_count последних записей.
|
||||
|
||||
id — monotonic-последовательность (не UUID): обрезка «последние N» должна
|
||||
быть детерминированной, а created_at в SQLite имеет точность до секунды
|
||||
(несколько смен внутри одной секунды → равные метки → случайный порядок).
|
||||
"""
|
||||
__tablename__ = "password_history"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
public_id = Column(UUID(as_uuid=True), default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
hashed_password = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -83,7 +83,8 @@ def record_password_change(db: Session, user: User, new_hash: str,
|
||||
|
||||
В историю кладётся ПРЕДЫДУЩИЙ хэш (previous_hash — хэш, который заменяют),
|
||||
чтобы его нельзя было вернуть, пока он не выпал из password_history_count
|
||||
последних записей. Обрезка хранит ровно password_history_count записей.
|
||||
последних записей. Обрезка хранит ровно password_history_count записей;
|
||||
порядок — по monotonic id (created_at в SQLite точен до секунды).
|
||||
"""
|
||||
history_count = int(get_setting(db, 'password_history_count', '5'))
|
||||
old_hash = previous_hash if previous_hash is not None else user.hashed_password
|
||||
@@ -93,8 +94,7 @@ def record_password_change(db: Session, user: User, new_hash: str,
|
||||
if history_count > 0:
|
||||
keep = (db.query(PasswordHistoryEntry.id)
|
||||
.filter(PasswordHistoryEntry.user_id == user.id)
|
||||
.order_by(PasswordHistoryEntry.created_at.desc(),
|
||||
PasswordHistoryEntry.id.desc())
|
||||
.order_by(PasswordHistoryEntry.id.desc())
|
||||
.limit(history_count).all())
|
||||
keep_ids = [row[0] for row in keep]
|
||||
if keep_ids:
|
||||
|
||||
+155
-5
@@ -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),
|
||||
)
|
||||
@@ -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': []}
|
||||
+3
-1
@@ -34,9 +34,11 @@ services:
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:***@postgres:5432/vector_mchs}
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/vector_mchs}
|
||||
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
|
||||
- CONTOUR_API_URL=${CONTOUR_API_URL:-}
|
||||
- CONTOUR_TOKEN=${CONTOUR_TOKEN:-}
|
||||
- PYTHONPATH=/app
|
||||
depends_on:
|
||||
postgres:
|
||||
|
||||
@@ -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}
|
||||
@@ -214,6 +235,23 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{/* Подсветка приоритетных направлений (поверх кругов) */}
|
||||
{highlightSectors.map((sector, 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}
|
||||
|
||||
@@ -1074,3 +1074,66 @@ mchs_units); admin (РЦУ РЧС) — без фильтра. Тот же ско
|
||||
ретенция 90 дней.
|
||||
- Зоны операции уезжают в КОНТУР и видны в штаб-панели CT130:8080 под своим
|
||||
operation_id; вторая операция не смешивается.
|
||||
|
||||
---
|
||||
|
||||
## B24 — Мёртвые веса скоринга: анализ зафиксирован, данные решают (2026-09-25)
|
||||
**Тип:** анализ завершён, РЕШЕНИЕ ЗАФИКСИРОВАНО. НЕ чистить веса «по чутью» — оживлять данными.
|
||||
|
||||
### Диагностика (проверено прогонами, A/B-тест)
|
||||
|
||||
`BASE_WEIGHTS` (services/scoring_service.py, методика §9) содержит 7 факторов.
|
||||
Реально на ранжирование зон влияют только 5:
|
||||
|
||||
| Фактор | Базовый вес | Подставляется в zone_dict? | Реальный вклад |
|
||||
|--------|-------------|---------------------------|----------------|
|
||||
| forest | 0.25 | да (forest_pct из OSM) | 29.4% |
|
||||
| water | 0.20 | да (water_distance_km из OSM) | 23.5% |
|
||||
| roads | 0.18 | да (road_density из OSM) | 21.2% |
|
||||
| settlement | 0.15 | да (settlement_distance_km) | 17.6% |
|
||||
| historical | 0.12 | **НЕТ** — всегда дефолт 0.5 | 0% (константа) |
|
||||
| direction | 0.07 | да (direction_match при last_seen_direction) | 8.2% |
|
||||
| shelter | 0.03 | **НЕТ** — всегда дефолт 0.3 | 0% (константа) |
|
||||
|
||||
Третья разновидность мёртвого — **railway**: вес добавляется динамически
|
||||
(профиль РАС, ×2.5, `apply_profile` → `weights['railway'] = 0.05`), но
|
||||
`railway_distance_km` никто не вычисляет → вклад в score всегда 0.
|
||||
При этом critical_warning РАС требует «немедленно проверить ВСЕ ж/д пути».
|
||||
|
||||
**A/B-прогон подтверждает**: приоритеты 8 секторов с dead-весами и без них
|
||||
попарно идентичны (константа historical 0.5 / shelter 0.3 добавляет всем
|
||||
зонам одинаковую добавку, на разницу оценок не влияет).
|
||||
|
||||
### РЕШЕНИЕ (фиксируем)
|
||||
|
||||
1. **НЕ удалять веса из BASE_WEIGHTS.** Методика §9 согласована с руководством;
|
||||
историческая частота — осмысленный фактор, у него нет ДАННЫХ, а не смысла.
|
||||
Правка весов руками = калибровка «по чутью», запрещена политикой
|
||||
(«коэффициенты меняются только по явному решению руководства»).
|
||||
|
||||
2. **Оживать по мере появления данных:**
|
||||
- **railway_distance_km** — можно сразу: railway=rail уже есть в локальном
|
||||
OSM (planet_osm_line, water-эндпоинт выгружает ж/д объекты). Нужна функция
|
||||
«расстояние до ближайшего ж/д пути в секторе» в osm_local (аналог
|
||||
settlement/water-кандидатов) + подстановка в zones_dict в rules_analysis.
|
||||
Тогда railway-фактор для РАС заработает, как обещает critical_warning.
|
||||
- **shelter_pct** — из локального OSM (доля укрытий в секторе: лес + отдельные
|
||||
объекты natural=scrub/tree/wetland). Требует выбора методики «укрытие» —
|
||||
согласовать с руководством, потом код.
|
||||
- **historical_freq** — ТОЛЬКО после набора архива «прогноз vs факт»
|
||||
(сейчас 2 записи — данных нет, выдумывать нельзя). Когда закрытых поисков
|
||||
станет десятки: посчитать места находок вокруг ТНП по секторам и подавать
|
||||
в скоринг. Это ЗАКРЫВАЕТ калибровку settlement_score из «в планах».
|
||||
|
||||
3. **Порядок работы (предложение):**
|
||||
a) railway из OSM (данные есть, ~1 день) — оживает critical_warning РАС;
|
||||
b) копить архив закрытых кейсов (каждый поиск — точка);
|
||||
c) historical_freq по архиву + калибровка весов по фактам (решение руководства);
|
||||
d) shelter — по согласованной методике.
|
||||
|
||||
4. **Тест-страж:** при любых правках скоринга сверять контрольные точки
|
||||
матрасчёта (README §7) и прогонять A/B (ранжирование до/после) —
|
||||
приоритеты не должны меняться «молча».
|
||||
|
||||
**Не делать:** подстановку «правдоподобных» констант, включение weights без
|
||||
источника данных, изменение BASE_WEIGHTS без решения руководства.
|
||||
|
||||
Reference in New Issue
Block a user