Compare commits

...

6 Commits

Author SHA1 Message Date
Sadmin ef18f8e28f B24 в vector_tasks.md: мёртвые веса скоринга — диагностика (числа, A/B) и план оживления данными (railway сразу, historical после архива) 2026-09-25 15:10:42 +03:00
Sadmin cdfbf30232 Доки B17/E5: README §11а (полевые данные КОНТУРа: inbound sync, локальное чтение, слой карты) 2026-09-25 11:05:39 +03:00
Sadmin 31757f18b3 SearchMap: слой проверенных квадратов КОНТУРа поверх зон (оперативные данные важнее расчётных) 2026-09-25 11:00:36 +03:00
Sadmin 05d6b3ce68 compose: проброс CONTOUR_API_URL/CONTOUR_TOKEN в backend (без них env из .env не доходит до контейнера) 2026-09-25 10:51:55 +03:00
Sadmin a1de5794e2 fix: password_history.id → INTEGER monotonic (детерминированная обрезка)
Грабля: created_at в SQLite точен до секунды — 7 быстрых смен дают
равные метки, а secondary-сортировка по UUID-id случайна → «последние 5»
выбираются произвольно (флакущий тест + неверно хранимая история).
id теперь autoincrement (обрезка order_by id desc), UUID остаётся
как public_id. Миграция 010 обновлена (таблица только на CT108,
пересоздаётся миграцией заново — см. деплой).
2026-09-25 10:46:18 +03:00
Sadmin 50a261e05e 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/аудит/локальное чтение
2026-09-25 10:34:53 +03:00
12 changed files with 603 additions and 19 deletions
+24 -2
View File
@@ -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. Интеграция с КОНТУР (отправка зон) ## 12. Интеграция с КОНТУР (отправка зон)
КОНТУР — система полевой координации (ATAK + Meshtastic). ВЕКТОР отдаёт приоритетные зоны (outbound): КОНТУР — система полевой координации (ATAK + Meshtastic). ВЕКТОР отдаёт приоритетные зоны (outbound):
@@ -403,6 +424,7 @@ SPA на react-scripts (CRA). Роутинг:
- Карта: концентрические круги + подсветка направлений (дизайн согласованный) - Карта: концентрические круги + подсветка направлений (дизайн согласованный)
- РБАК: иерархия РЦУ РЧС → ОУМЧС → Г(Р)ОЧС (142 юнита), 4 роли / 9 прав, блокировки, сессии - РБАК: иерархия РЦУ РЧС → ОУМЧС → Г(Р)ОЧС (142 юнита), 4 роли / 9 прав, блокировки, сессии
- Мультипоиск: операции, статусная машина, дашборд «Активные поиски» - Мультипоиск: операции, статусная машина, дашборд «Активные поиски»
- B17/E5: ингестия полевых данных КОНТУРа (проверенные квадраты на карте операции)
- Завершение поиска с внесением исхода, архив завершённых с «прогноз vs факт» - Завершение поиска с внесением исхода, архив завершённых с «прогноз vs факт»
- Аудит 90 дней с field-level деталями - Аудит 90 дней с field-level деталями
- Прикладное логирование `vector.*`: события анализа, входов, КОНТУРа, аудита (README §13а) - Прикладное логирование `vector.*`: события анализа, входов, КОНТУРа, аудита (README §13а)
@@ -413,8 +435,8 @@ SPA на react-scripts (CRA). Роутинг:
### В планах ### В планах
- **E5 (B17)** — приём полевых данных от КОНТУРа: треки групп, проверенные квадраты (areas-checked) на карте операции - **E5/B17 расширение** — треки групп (tracks/live) как field_observations; полевой статус «найден»
- **Живая интеграция с КОНТУР** (CT130) — настройка токена и реальная отправка зон - **Авто-sync полевых данных** — периодический (сейчас кнопка вручную)
- **Локальный tile-сервер** — для полностью offline-фронта (сейчас тайлы OSM с CDN в браузере) - **Локальный tile-сервер** — для полностью offline-фронта (сейчас тайлы OSM с CDN в браузере)
- **Калибровка settlement_score** — вкладка населённых пунктов в скоринге секторов (открытый методический вопрос) - **Калибровка settlement_score** — вкладка населённых пунктов в скоринге секторов (открытый методический вопрос)
- **Калибровка зонального скоринга** по накопленному архиву «прогноз vs факт» - **Калибровка зонального скоринга** по накопленному архиву «прогноз vs факт»
+5 -3
View File
@@ -21,11 +21,13 @@ def upgrade() -> None:
sa.Column('password_changed_at', sa.DateTime(timezone=True), sa.Column('password_changed_at', sa.DateTime(timezone=True),
server_default=sa.text('NOW()'), nullable=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( op.create_table(
'password_history', 'password_history',
sa.Column('id', pg.UUID(as_uuid=True), primary_key=True, sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
server_default=sa.text('gen_random_uuid()')), 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.Column('user_id', pg.UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('hashed_password', sa.String(255), 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
View File
@@ -203,10 +203,15 @@ class AreaChecked(Base):
checked_at = Column(DateTime(timezone=True)) checked_at = Column(DateTime(timezone=True))
result = Column(String(20), default="partial") result = Column(String(20), default="partial")
coverage_pct = Column(Float) 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__ = ( __table_args__ = (
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"), 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): class PasswordHistoryEntry(Base):
"""B23: история паролей (bcrypt-хэши). Записывается при каждой смене, """B23: история паролей (bcrypt-хэши). Записывается при каждой смене,
проверяется против password_history_count последних записей.""" проверяется против password_history_count последних записей.
id — monotonic-последовательность (не UUID): обрезка «последние N» должна
быть детерминированной, а created_at в SQLite имеет точность до секунды
(несколько смен внутри одной секунды → равные метки → случайный порядок).
"""
__tablename__ = "password_history" __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) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
hashed_password = Column(String(255), nullable=False) hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
+3 -3
View File
@@ -83,7 +83,8 @@ def record_password_change(db: Session, user: User, new_hash: str,
В историю кладётся ПРЕДЫДУЩИЙ хэш (previous_hash — хэш, который заменяют), В историю кладётся ПРЕДЫДУЩИЙ хэш (previous_hash — хэш, который заменяют),
чтобы его нельзя было вернуть, пока он не выпал из password_history_count чтобы его нельзя было вернуть, пока он не выпал из password_history_count
последних записей. Обрезка хранит ровно password_history_count записей. последних записей. Обрезка хранит ровно password_history_count записей;
порядок — по monotonic id (created_at в SQLite точен до секунды).
""" """
history_count = int(get_setting(db, 'password_history_count', '5')) history_count = int(get_setting(db, 'password_history_count', '5'))
old_hash = previous_hash if previous_hash is not None else user.hashed_password 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: if history_count > 0:
keep = (db.query(PasswordHistoryEntry.id) keep = (db.query(PasswordHistoryEntry.id)
.filter(PasswordHistoryEntry.user_id == user.id) .filter(PasswordHistoryEntry.user_id == user.id)
.order_by(PasswordHistoryEntry.created_at.desc(), .order_by(PasswordHistoryEntry.id.desc())
PasswordHistoryEntry.id.desc())
.limit(history_count).all()) .limit(history_count).all())
keep_ids = [row[0] for row in keep] keep_ids = [row[0] for row in keep]
if keep_ids: if keep_ids:
+155 -5
View File
@@ -4,11 +4,18 @@ POST /api/v1/operations/{id}/send-to-contour:
анализ кейса → SearchZones[] (GeoJSON-сектора направлений) → анализ кейса → SearchZones[] (GeoJSON-сектора направлений) →
POST {CONTOUR_API_URL}/api/v1/vector/zones?operation_id=<contour_operation_id> 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 операции в КОНТУРе), Требуется: у операции задан contour_operation_id (UUID операции в КОНТУРе),
env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets). env CONTOUR_API_URL и CONTOUR_TOKEN (межсервисный, в secrets).
""" """
from __future__ import annotations from __future__ import annotations
import json
import math import math
import os import os
import uuid as uuid_mod import uuid as uuid_mod
@@ -18,11 +25,12 @@ from typing import Any, Optional
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session
from backend.audit import audit_log, can_access_unit from backend.audit import audit_log, can_access_unit
from backend.database import get_db from backend.database import get_db
from backend.logging_setup import get_logger 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 from backend.routers.auth import get_current_user, require_permission
log = get_logger('contour') log = get_logger('contour')
@@ -190,12 +198,22 @@ def _load_operation(db, user: User, operation_id: str) -> SearchOperation:
def areas_checked( def areas_checked(
operation_id: str, operation_id: str,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Прокси: проверенные квадраты операции из КОНТУРа (inbound B17-заготовка).""" """Проверенные квадраты операции (B17): из локальной БД после sync.
Если ещё не синхронизировано (0 записей) — прозрачно проксирует в КОНТУР,
чтобы страница работала без обязательного sync (live-режим, B17-заготовка).
"""
op = _load_operation(db, current_user, operation_id) 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: if not op.contour_operation_id:
raise HTTPException(status_code=400, detail='contour_operation_id не задан') return {'source': 'empty', 'items': []}
if not _contour_token(): if not _contour_token():
raise HTTPException(status_code=503, detail='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}" 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 raise HTTPException(status_code=502, detail=f'КОНТУР недоступен: {e}') from e
if resp.status_code >= 400: if resp.status_code >= 400:
raise HTTPException(status_code=502, detail=f'КОНТУР: {resp.status_code} {resp.text[:300]}') 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': []}
+3 -1
View File
@@ -34,9 +34,11 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: 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} - JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:3000}
- CONTOUR_API_URL=${CONTOUR_API_URL:-}
- CONTOUR_TOKEN=${CONTOUR_TOKEN:-}
- PYTHONPATH=/app - PYTHONPATH=/app
depends_on: depends_on:
postgres: postgres:
+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', 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 tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
const distance = maxDistance || 5.0; 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)} км`); 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 ( return (
<MapContainer <MapContainer
center={tnpPosition} center={tnpPosition}
@@ -214,6 +235,23 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
</Polygon> </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) => ( {highlightSectors.map((sector, idx) => (
<Polygon <Polygon
@@ -699,7 +699,7 @@
color: #e6edf3; color: #e6edf3;
font-size: 13px; font-size: 13px;
} }
.contour-save, .contour-send { .contour-save, .contour-send, .contour-sync {
padding: 8px 12px; padding: 8px 12px;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
@@ -711,6 +711,8 @@
.contour-save { background: #21262d; color: #e6edf3; } .contour-save { background: #21262d; color: #e6edf3; }
.contour-send { background: #238636; } .contour-send { background: #238636; }
.contour-send:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; } .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-ok { font-size: 12px; color: #3fb950; }
.contour-err { font-size: 12px; color: #f85149; } .contour-err { font-size: 12px; color: #f85149; }
@@ -40,6 +40,8 @@ const AnalysisResult = () => {
const [operation, setOperation] = useState(null); const [operation, setOperation] = useState(null);
const [contourId, setContourId] = useState(''); const [contourId, setContourId] = useState('');
const [contourStatus, setContourStatus] = useState(null); // {ok, message} 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 [showComplete, setShowComplete] = useState(false);
const [completeForm, setCompleteForm] = useState({ const [completeForm, setCompleteForm] = useState({
found_alive: 'true', found_distance_km: '', found_direction: '', 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) { } catch (err) {
console.error("Error loading case:", err); console.error("Error loading case:", err);
setError(err.message); setError(err.message);
@@ -250,6 +258,7 @@ const AnalysisResult = () => {
tnpLat={parseFloat(tnpLat)} tnpLat={parseFloat(tnpLat)}
tnpLon={parseFloat(tnpLon)} tnpLon={parseFloat(tnpLon)}
waterFeatures={waterData?.features || []} waterFeatures={waterData?.features || []}
fieldAreas={fieldAreas?.items || []}
/> />
) : ( ) : (
<div className="map-empty">ТНП не задана — карта недоступна</div> <div className="map-empty">ТНП не задана — карта недоступна</div>
@@ -290,6 +299,25 @@ const AnalysisResult = () => {
}} }}
>Отправить зоны в КОНТУР</button> >Отправить зоны в КОНТУР</button>
</div> </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 && ( {contourStatus && (
<div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}> <div className={contourStatus.ok ? 'contour-ok' : 'contour-err'}>
{contourStatus.message} {contourStatus.message}
+63
View File
@@ -1074,3 +1074,66 @@ mchs_units); admin (РЦУ РЧС) — без фильтра. Тот же ско
ретенция 90 дней. ретенция 90 дней.
- Зоны операции уезжают в КОНТУР и видны в штаб-панели CT130:8080 под своим - Зоны операции уезжают в КОНТУР и видны в штаб-панели CT130:8080 под своим
operation_id; вторая операция не смешивается. 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 без решения руководства.