Merge branch 'feat/service-catalog'
lct-38: каталог 209 служб из «СЛУЖБЫ 112» для ручного добавления в карточку
This commit is contained in:
commit
8ff51a8955
15 changed files with 1853 additions and 16 deletions
|
|
@ -48,3 +48,14 @@ async def signs(prefix: list[str] = Query(default=[]), group: int | None = None)
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services")
|
||||||
|
async def services() -> dict:
|
||||||
|
"""Каталог служб для ручного добавления в карточку — как окно «Добавьте службы».
|
||||||
|
|
||||||
|
Каталог — справочник боевого АРМ, а не содержимое вызова: отдавать его
|
||||||
|
курсанту так же безопасно, как группы происшествий.
|
||||||
|
"""
|
||||||
|
data = ekp.catalog()
|
||||||
|
return {"source": data.source, "services": [item.model_dump() for item in data.services]}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from app.domain.events import (
|
||||||
TraineeToServer,
|
TraineeToServer,
|
||||||
)
|
)
|
||||||
from app.dialog.slots import TurnResult
|
from app.dialog.slots import TurnResult
|
||||||
|
from app.domain.kio import PatchRejected, get_field
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.scenarios import store
|
from app.scenarios import store
|
||||||
from app.session.dds import prepare_handoff_queue
|
from app.session.dds import prepare_handoff_queue
|
||||||
|
|
@ -270,16 +271,37 @@ async def _handle(session_id: UUID, state, event) -> None:
|
||||||
|
|
||||||
case "kio.patch":
|
case "kio.patch":
|
||||||
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
|
old_code, old_notify = state.kio.incident_code, list(state.kio.notify)
|
||||||
state.patch_kio(event.fields)
|
old_extra = list(state.kio.notify_extra)
|
||||||
|
try:
|
||||||
|
state.patch_kio(event.fields)
|
||||||
|
except (ValidationError, PatchRejected) as exc:
|
||||||
|
reason = exc.errors()[0].get("msg", "") if isinstance(exc, ValidationError) else str(exc)
|
||||||
|
# Недопустимое значение — отказ курсанту, а не сбой операции:
|
||||||
|
# сбой закрыл бы занятие на этом узле.
|
||||||
|
hub.to_trainee(session_id, ErrorEvent(
|
||||||
|
code=ErrorKind.UNSUPPORTED_EVENT,
|
||||||
|
message=f"Поле карточки не принято: {reason}"[:200],
|
||||||
|
))
|
||||||
|
# Пакет отклонён целиком, а фронт держит его в pending до эха.
|
||||||
|
# Серверное значение с `auto` снимает pending — иначе на экране
|
||||||
|
# останутся правки, которых на сервере нет.
|
||||||
|
hub.to_trainee(session_id, KioPatchOut(
|
||||||
|
fields={path: get_field(state.kio, path) for path in event.fields},
|
||||||
|
source=PatchSource.AUTO,
|
||||||
|
))
|
||||||
|
return
|
||||||
hub.to_trainee(session_id, KioPatchOut(
|
hub.to_trainee(session_id, KioPatchOut(
|
||||||
fields=event.fields, source=PatchSource.OPERATOR,
|
fields=event.fields, source=PatchSource.OPERATOR,
|
||||||
))
|
))
|
||||||
|
auto: dict = {}
|
||||||
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
|
if (state.kio.incident_code, state.kio.notify) != (old_code, old_notify):
|
||||||
hub.to_trainee(session_id, KioPatchOut(
|
auto |= {"incident_code": state.kio.incident_code, "notify": list(state.kio.notify)}
|
||||||
fields={"incident_code": state.kio.incident_code,
|
# Добавки сервер чистит сам (повторы, службы из notify) — фронту
|
||||||
"notify": list(state.kio.notify)},
|
# нужно итоговое значение, а не эхо присланного.
|
||||||
source=PatchSource.AUTO,
|
if state.kio.notify_extra != event.fields.get("notify_extra", old_extra):
|
||||||
))
|
auto["notify_extra"] = list(state.kio.notify_extra)
|
||||||
|
if auto:
|
||||||
|
hub.to_trainee(session_id, KioPatchOut(fields=auto, source=PatchSource.AUTO))
|
||||||
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
|
# Наблюдателю уходит карточка целиком: рассинхрон на внешнем мониторе
|
||||||
# посреди занятия дороже лишних килобайт.
|
# посреди занятия дороже лишних килобайт.
|
||||||
hub.to_observers(session_id, KioState(kio=state.kio))
|
hub.to_observers(session_id, KioState(kio=state.kio))
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,9 @@ from pathlib import Path
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
DATA = Path(__file__).parent / "ekp.json"
|
DATA = Path(__file__).parent / "ekp.json"
|
||||||
|
#: Каталог окна «Добавьте службы» из «СЛУЖБЫ 112.docx»: 209 служб, в том числе
|
||||||
|
#: районные ДДС. Источник и лицензия — в шапке файла и в docs/delivery/THIRD-PARTY.md.
|
||||||
|
CATALOG = Path(__file__).parent / "services_catalog.json"
|
||||||
|
|
||||||
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
|
#: Часть строк книги существует ради интеграции с чужими системами, а оператору
|
||||||
#: не показывается — книга помечает их прямо в первом признаке.
|
#: не показывается — книга помечает их прямо в первом признаке.
|
||||||
|
|
@ -89,6 +92,37 @@ def reference() -> Reference:
|
||||||
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
|
return Reference.model_validate_json(DATA.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogService(BaseModel):
|
||||||
|
"""Служба каталога боевого АРМ — то, что оператор добавляет в карточку вручную."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
organization: str | None = None
|
||||||
|
#: «ДДС», если это явно указано в названии в каталоге.
|
||||||
|
service_level: str | None = None
|
||||||
|
#: Скриншот и строка списка в «СЛУЖБЫ 112.docx».
|
||||||
|
source_reference: str
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog(BaseModel):
|
||||||
|
source: str
|
||||||
|
transcription: str
|
||||||
|
license: str
|
||||||
|
services: list[CatalogService]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def catalog() -> Catalog:
|
||||||
|
"""Каталог только расширяет выбор служб. Список оповещения по-прежнему
|
||||||
|
считается из признаков по классификатору, эталоны сценариев от него не зависят."""
|
||||||
|
return Catalog.model_validate_json(CATALOG.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def catalog_names() -> frozenset[str]:
|
||||||
|
return frozenset(service.name for service in catalog().services)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _by_code() -> dict[str, Incident]:
|
def _by_code() -> dict[str, Incident]:
|
||||||
return {incident.code: incident for incident in reference().incidents}
|
return {incident.code: incident for incident in reference().incidents}
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,13 @@ class KIO(BaseModel):
|
||||||
# ДДС. `notify` считается из признаков и правится только системой:
|
# ДДС. `notify` считается из признаков и правится только системой:
|
||||||
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
|
# в боевом АРМ оператор может добавить службу вручную, но не удалить.
|
||||||
notify: list[str] = Field(default_factory=list, description="Список оповещения")
|
notify: list[str] = Field(default_factory=list, description="Список оповещения")
|
||||||
|
#: Службы, добавленные оператором вручную из каталога. Автоматический список
|
||||||
|
#: не заменяют и в оценку по эталону не входят. Каталог проверяется на входе
|
||||||
|
#: (apply_patch), а не здесь: иначе правка названия в каталоге сломала бы
|
||||||
|
#: загрузку сохранённых снимков занятий и сданных карточек.
|
||||||
|
notify_extra: list[str] = Field(
|
||||||
|
default_factory=list, max_length=20, description="Дополнительно оповестить"
|
||||||
|
)
|
||||||
dds: DDSCode | None = Field(
|
dds: DDSCode | None = Field(
|
||||||
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
|
default=None, description="Устаревшее: одна служба. Заменено списком оповещения"
|
||||||
)
|
)
|
||||||
|
|
@ -147,7 +154,7 @@ EDITABLE_KIO_FIELDS: frozenset[str] = frozenset({
|
||||||
"okato", "address", "street", "building", "entrance", "floor",
|
"okato", "address", "street", "building", "entrance", "floor",
|
||||||
"intercom_code", "description", "incident_group", "signs",
|
"intercom_code", "description", "incident_group", "signs",
|
||||||
"incident_type", "victims_count", "is_emergency", "life_threat",
|
"incident_type", "victims_count", "is_emergency", "life_threat",
|
||||||
"evacuation_needed", "dds",
|
"evacuation_needed", "dds", "notify_extra",
|
||||||
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
"fire.fire_nature", "fire.object_kind", "fire.floors", "fire.gasified",
|
||||||
"fire.people_inside", "fire.smoke_spread",
|
"fire.people_inside", "fire.smoke_spread",
|
||||||
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
"police.offence_kind", "police.suspects", "police.suspect_fled",
|
||||||
|
|
@ -223,11 +230,40 @@ def derive_incident(card: KIO) -> KIO:
|
||||||
return KIO.model_validate(data)
|
return KIO.model_validate(data)
|
||||||
|
|
||||||
|
|
||||||
|
class PatchRejected(ValueError):
|
||||||
|
"""Дельта `kio.patch` не принята целиком: карточка не изменилась."""
|
||||||
|
|
||||||
|
|
||||||
|
def _check_catalog(names: Any) -> None:
|
||||||
|
"""Только службы каталога: свободный текст в адресатах карточки
|
||||||
|
на АРМ ДДС выглядел бы как настоящая служба."""
|
||||||
|
from app.domain import ekp
|
||||||
|
|
||||||
|
if not isinstance(names, list):
|
||||||
|
return # тип и лимит проверит модель
|
||||||
|
known = ekp.catalog_names()
|
||||||
|
unknown = [name for name in names if name not in known]
|
||||||
|
if unknown:
|
||||||
|
raise PatchRejected(f"нет в каталоге служб: {', '.join(map(str, unknown[:3]))}")
|
||||||
|
|
||||||
|
|
||||||
|
def _without_auto_services(card: KIO) -> KIO:
|
||||||
|
"""Добавки без повторов и без служб, которые уже есть в списке оповещения:
|
||||||
|
иначе на АРМ ДДС одна служба окажется в двух строках."""
|
||||||
|
extra = [name for name in dict.fromkeys(card.notify_extra) if name not in card.notify]
|
||||||
|
if extra == card.notify_extra:
|
||||||
|
return card
|
||||||
|
return card.model_copy(update={"notify_extra": extra})
|
||||||
|
|
||||||
|
|
||||||
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
||||||
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
|
"""Применить дельту `kio.patch`. Служебные поля игнорируются.
|
||||||
|
|
||||||
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
|
Вложенные поля приходят плоским путём: {"fire.floors": 5}.
|
||||||
|
Недопустимое значение — `PatchRejected` или `ValidationError`.
|
||||||
"""
|
"""
|
||||||
|
if "notify_extra" in fields:
|
||||||
|
_check_catalog(fields["notify_extra"])
|
||||||
data = card.model_dump()
|
data = card.model_dump()
|
||||||
for path, value in fields.items():
|
for path, value in fields.items():
|
||||||
if path in READ_ONLY_FIELDS:
|
if path in READ_ONLY_FIELDS:
|
||||||
|
|
@ -243,4 +279,4 @@ def apply_patch(card: KIO, fields: dict[str, Any]) -> KIO:
|
||||||
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
|
# Пересчитываем не только на смену признаков: пострадавшие поднимают скорую,
|
||||||
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
|
# газификация — МОСГАЗ, и список оповещения обязан это отразить сразу.
|
||||||
touched = MODIFIER_FIELDS & set(fields)
|
touched = MODIFIER_FIELDS & set(fields)
|
||||||
return derive_incident(updated) if touched else updated
|
return _without_auto_services(derive_incident(updated) if touched else updated)
|
||||||
|
|
|
||||||
1470
backend/app/domain/services_catalog.json
Normal file
1470
backend/app/domain/services_catalog.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,13 +12,14 @@ from pathlib import Path
|
||||||
API_ROOT = Path(__file__).parents[1] / "app" / "api"
|
API_ROOT = Path(__file__).parents[1] / "app" / "api"
|
||||||
|
|
||||||
# Public by design: credential entry/session bootstrap and the non-sensitive
|
# Public by design: credential entry/session bootstrap and the non-sensitive
|
||||||
# incident classification dictionary. dev-token has its own fail-closed flag
|
# incident classification dictionary and service catalog. dev-token has its own fail-closed flag
|
||||||
# and remains hidden in production.
|
# and remains hidden in production.
|
||||||
PUBLIC_HTTP_ROUTES = {
|
PUBLIC_HTTP_ROUTES = {
|
||||||
("auth.py", "post", "/login"),
|
("auth.py", "post", "/login"),
|
||||||
("auth.py", "post", "/dev-token"),
|
("auth.py", "post", "/dev-token"),
|
||||||
("http/ekp.py", "get", "/groups"),
|
("http/ekp.py", "get", "/groups"),
|
||||||
("http/ekp.py", "get", "/signs"),
|
("http/ekp.py", "get", "/signs"),
|
||||||
|
("http/ekp.py", "get", "/services"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Routes that centralize ownership + authentication checks in a shared helper.
|
# Routes that centralize ownership + authentication checks in a shared helper.
|
||||||
|
|
|
||||||
167
backend/tests/test_service_catalog.py
Normal file
167
backend/tests/test_service_catalog.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
"""Каталог служб из «СЛУЖБЫ 112» (lct-38).
|
||||||
|
|
||||||
|
Каталог только расширяет ручной выбор служб в карточке. Список оповещения
|
||||||
|
по-прежнему считается по классификатору, эталоны сценариев от каталога не зависят.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.api.ws import call as call_ws
|
||||||
|
from app.domain import ekp
|
||||||
|
from app.domain.events import ErrorEvent, KioPatchIn, KioPatchOut, PatchSource, SessionMode
|
||||||
|
from app.domain.kio import EDITABLE_KIO_FIELDS, KIO, PatchRejected, apply_patch
|
||||||
|
from app.main import app
|
||||||
|
from app.scenarios.loader import load_file
|
||||||
|
from app.scoring.card import evaluate_card
|
||||||
|
from app.session.hub import hub
|
||||||
|
from app.session.state import SessionState
|
||||||
|
from app.session.store import MemorySessionStore
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_keeps_all_209_services_with_attribution_and_source():
|
||||||
|
data = ekp.catalog()
|
||||||
|
|
||||||
|
assert len(data.services) == 209
|
||||||
|
assert len({item.name for item in data.services}) == 209
|
||||||
|
assert sum(item.service_level == "ДДС" for item in data.services) == 157
|
||||||
|
assert "ded466988f505082922aacac9fff32b777d1279a" in data.transcription
|
||||||
|
assert data.license.startswith("MIT")
|
||||||
|
assert all(item.source_reference.startswith("СЛУЖБЫ 112.docx#word/media/image")
|
||||||
|
for item in data.services)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", [
|
||||||
|
'Служба 101 (ГУ МЧС России по г.Москве, ГКУ "Пожарно спасательный центр" ОДС)',
|
||||||
|
'Служба 104 (АО "МОСГАЗ" Диспетчерское управление)',
|
||||||
|
"ЦЭМП",
|
||||||
|
'ЦОДД (ГКУ "Центр организации дорожного движения")',
|
||||||
|
'ЭВАЖД (ГБУ "Учреждение по эксплуатации высотных административных и жилых домов")',
|
||||||
|
])
|
||||||
|
def test_rows_checked_against_our_screenshots_are_verbatim(name):
|
||||||
|
assert name in ekp.catalog_names()
|
||||||
|
|
||||||
|
|
||||||
|
def test_classifier_services_and_notify_lists_are_untouched():
|
||||||
|
"""Индексы служб классификатора — основа списков оповещения 96 сценариев."""
|
||||||
|
raw = json.loads(ekp.DATA.read_text(encoding="utf-8"))
|
||||||
|
assert [item.name for item in ekp.services()] == [item["name"] for item in raw["services"]]
|
||||||
|
assert len(ekp.services()) == 61
|
||||||
|
|
||||||
|
|
||||||
|
def test_operator_adds_catalog_services_without_changing_the_notify_list():
|
||||||
|
assert "notify_extra" in EDITABLE_KIO_FIELDS
|
||||||
|
card = KIO(notify=["Служба 101"])
|
||||||
|
|
||||||
|
updated = apply_patch(card, {"notify_extra": ["ФСБ", "ЦЭМП", "ФСБ"], "notify": []})
|
||||||
|
|
||||||
|
assert updated.notify_extra == ["ФСБ", "ЦЭМП"]
|
||||||
|
assert updated.notify == ["Служба 101"], "автоматический список оператор не правит"
|
||||||
|
with pytest.raises(PatchRejected):
|
||||||
|
apply_patch(card, {"notify_extra": ["Служба, которой нет"]})
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
apply_patch(card, {"notify_extra": [item.name for item in ekp.catalog().services[:21]]})
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_already_in_notify_is_not_added_twice():
|
||||||
|
"""ЦЭМП, ФСБ, МОЭК, Метро, Мосгортранс есть и в каталоге, и в классификаторе."""
|
||||||
|
card = KIO(notify=["ЦЭМП"])
|
||||||
|
|
||||||
|
updated = apply_patch(card, {"notify_extra": ["ЦЭМП", "ФСБ"]})
|
||||||
|
|
||||||
|
assert updated.notify_extra == ["ФСБ"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_card_with_renamed_service_still_loads():
|
||||||
|
"""Каталог проверяется на входе kio.patch: правка названия в каталоге
|
||||||
|
не должна ломать восстановление снимков и сданных карточек."""
|
||||||
|
card = KIO.model_validate({"notify_extra": ["Прежнее название службы"]})
|
||||||
|
|
||||||
|
assert card.notify_extra == ["Прежнее название службы"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_services_do_not_change_the_score_against_the_reference():
|
||||||
|
library = Path(__file__).resolve().parents[2] / "scenarios"
|
||||||
|
scenario = load_file(library / "fire-apartment-l2.yaml", library)
|
||||||
|
card = KIO(address="улица Ленина, 14", description="горит балкон")
|
||||||
|
|
||||||
|
plain = evaluate_card(scenario, card)
|
||||||
|
extended = evaluate_card(scenario, card.model_copy(update={"notify_extra": ["ФСБ"]}))
|
||||||
|
|
||||||
|
assert [(m.key, m.passed) for m in plain.metrics] == [(m.key, m.passed) for m in extended.metrics]
|
||||||
|
assert plain.score == extended.score
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_is_served_like_the_classifier():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get("/api/ekp/services")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["services"]) == 209
|
||||||
|
assert {"id", "name", "organization", "service_level", "source_reference"} <= body["services"][0].keys()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lesson(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(hub, "store", MemorySessionStore())
|
||||||
|
monkeypatch.setattr(hub, "to_trainee", lambda _session_id, event: sent.append(event))
|
||||||
|
monkeypatch.setattr(hub, "to_observers", lambda *_args: None)
|
||||||
|
state = SessionState(session_id=uuid4(), scenario_id="test", scenario_title="Тест",
|
||||||
|
level="L1", mode=SessionMode.TRAINING)
|
||||||
|
hub.register(state)
|
||||||
|
|
||||||
|
def patch(fields):
|
||||||
|
sent.clear()
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
async with hub.operation(state.session_id):
|
||||||
|
await call_ws._handle(state.session_id, state, KioPatchIn(fields=fields))
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
return list(sent)
|
||||||
|
|
||||||
|
return state, patch
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_service_in_kio_patch_is_refused_without_failing_the_lesson(lesson):
|
||||||
|
state, patch = lesson
|
||||||
|
|
||||||
|
sent = patch({"notify_extra": ["Служба, которой нет"]})
|
||||||
|
assert isinstance(sent[0], ErrorEvent) and "не принято" in sent[0].message
|
||||||
|
assert state.kio.notify_extra == []
|
||||||
|
|
||||||
|
sent = patch({"notify_extra": ["ФСБ"]})
|
||||||
|
assert state.kio.notify_extra == ["ФСБ"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_kio_patch_returns_server_values_to_drop_pending(lesson):
|
||||||
|
"""Пакет отклоняется целиком — фронт должен получить серверные значения
|
||||||
|
всех его полей с `auto`, иначе pending висит до следующего kio.state."""
|
||||||
|
state, patch = lesson
|
||||||
|
patch({"address": "улица Ленина, 14", "notify_extra": ["ФСБ"]})
|
||||||
|
|
||||||
|
sent = patch({"address": "Тверская, 1", "fire.floors": 5, "notify_extra": ["ФСБ", "Служба, которой нет"]})
|
||||||
|
|
||||||
|
assert isinstance(sent[0], ErrorEvent)
|
||||||
|
assert isinstance(sent[1], KioPatchOut) and sent[1].source is PatchSource.AUTO
|
||||||
|
assert sent[1].fields == {"address": "улица Ленина, 14", "fire.floors": None, "notify_extra": ["ФСБ"]}
|
||||||
|
assert state.kio.address == "улица Ленина, 14"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleaned_notify_extra_is_sent_back_as_auto(lesson):
|
||||||
|
state, patch = lesson
|
||||||
|
state.kio = state.kio.model_copy(update={"notify": ["ЦЭМП"]})
|
||||||
|
|
||||||
|
sent = patch({"notify_extra": ["ЦЭМП", "ФСБ", "ФСБ"]})
|
||||||
|
|
||||||
|
auto = [event for event in sent if isinstance(event, KioPatchOut) and event.source is PatchSource.AUTO]
|
||||||
|
assert auto and auto[-1].fields["notify_extra"] == ["ФСБ"]
|
||||||
|
assert state.kio.notify_extra == ["ФСБ"]
|
||||||
|
|
@ -74,6 +74,18 @@ socket.receive({ type: "kio.patch", fields: {
|
||||||
}, source: "operator" });
|
}, source: "operator" });
|
||||||
socket = reconnect(socket);
|
socket = reconnect(socket);
|
||||||
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
|
assert.deepEqual(socket.sent, [], "matching checkpoint echo clears the KIO patch");
|
||||||
|
const refused = { type: "kio.patch", fields: { address: "ул. Новая, 3", notify_extra: ["Нет в каталоге"] } };
|
||||||
|
call.send(refused);
|
||||||
|
socket.receive({ type: "kio.patch", fields: { address: patch.fields.address, notify_extra: [] }, source: "auto" });
|
||||||
|
socket.sent = [];
|
||||||
|
call.send({ type: "kio.patch", fields: { floor: "2" } });
|
||||||
|
assert.deepEqual(socket.sent.at(-1).fields, { floor: "2" },
|
||||||
|
"server values after a refused patch acknowledge it: refused fields are not merged into the next edit");
|
||||||
|
socket.receive({ type: "kio.patch", fields: { floor: "2" }, source: "operator" });
|
||||||
|
socket = reconnect(socket);
|
||||||
|
assert.deepEqual(socket.sent, [], "nothing left to replay after refusal and the next echo");
|
||||||
|
const refusedCard = applyPatch(edit(empty, "notify_extra", ["Нет в каталоге"]), { notify_extra: [] }, "auto");
|
||||||
|
assert.deepEqual(refusedCard.pending, {}, "server value after refusal drops the pending edit");
|
||||||
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
|
const edited = edit(empty, "coords", { latitude: 55.7, longitude: 37.6 });
|
||||||
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
|
assert.deepEqual(applyPatch(edited, { coords: { longitude: 37.6, latitude: 55.7 } }, "operator").pending, {});
|
||||||
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
|
assert.deepEqual(applyState(edited, { coords: { longitude: 37.6, latitude: 55.7 } }).pending, {});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
|
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
|
||||||
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
|
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
|
||||||
|
|
||||||
import { useIncidentGroups, useSigns } from "@/shared/api/http";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { useIncidentGroups, useServiceCatalog, useSigns } from "@/shared/api/http";
|
||||||
import { type CardState, display } from "./merge";
|
import { type CardState, display } from "./merge";
|
||||||
import { type Field, type FieldGroup, visibleGroups } from "./fields";
|
import { type Field, type FieldGroup, visibleGroups } from "./fields";
|
||||||
|
|
||||||
|
|
@ -17,6 +19,7 @@ interface Props {
|
||||||
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
|
export function KioCard({ state, required = [], readOnly = false, referenceArm = false, onChange }: Props) {
|
||||||
const dds = display(state, "dds") as string | null;
|
const dds = display(state, "dds") as string | null;
|
||||||
const incidentGroup = display(state, "incident_group") as number | null;
|
const incidentGroup = display(state, "incident_group") as number | null;
|
||||||
|
const notify = (display(state, "notify") as string[] | null) ?? [];
|
||||||
const groups = visibleGroups(dds);
|
const groups = visibleGroups(dds);
|
||||||
const section = (group: FieldGroup) => (
|
const section = (group: FieldGroup) => (
|
||||||
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
|
<section className={`kio-arm-section kio-arm-${group.area}`} key={group.title}>
|
||||||
|
|
@ -36,6 +39,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
|
||||||
readOnly={readOnly || field.readOnly}
|
readOnly={readOnly || field.readOnly}
|
||||||
referenceArm={referenceArm}
|
referenceArm={referenceArm}
|
||||||
incidentGroup={incidentGroup}
|
incidentGroup={incidentGroup}
|
||||||
|
notify={notify}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
@ -58,7 +62,7 @@ export function KioCard({ state, required = [], readOnly = false, referenceArm =
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({
|
function Row({
|
||||||
field, value, pending, required, readOnly, referenceArm, incidentGroup, onChange,
|
field, value, pending, required, readOnly, referenceArm, incidentGroup, notify, onChange,
|
||||||
}: {
|
}: {
|
||||||
field: Field;
|
field: Field;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
|
|
@ -67,6 +71,7 @@ function Row({
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
referenceArm: boolean;
|
referenceArm: boolean;
|
||||||
incidentGroup: number | null;
|
incidentGroup: number | null;
|
||||||
|
notify: string[];
|
||||||
onChange?: (path: string, value: unknown) => void;
|
onChange?: (path: string, value: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
const empty = value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
||||||
|
|
@ -85,7 +90,7 @@ function Row({
|
||||||
{readOnly ? (
|
{readOnly ? (
|
||||||
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
|
<span className="kio-arm-readonly">{formatValue(value, field)}</span>
|
||||||
) : (
|
) : (
|
||||||
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} onChange={onChange} />
|
<Input field={field} value={value} referenceArm={referenceArm} incidentGroup={incidentGroup} notify={notify} onChange={onChange} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -93,12 +98,13 @@ function Row({
|
||||||
}
|
}
|
||||||
|
|
||||||
function Input({
|
function Input({
|
||||||
field, value, referenceArm, incidentGroup, onChange,
|
field, value, referenceArm, incidentGroup, notify, onChange,
|
||||||
}: {
|
}: {
|
||||||
field: Field;
|
field: Field;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
referenceArm: boolean;
|
referenceArm: boolean;
|
||||||
incidentGroup: number | null;
|
incidentGroup: number | null;
|
||||||
|
notify: string[];
|
||||||
onChange?: (path: string, value: unknown) => void;
|
onChange?: (path: string, value: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const emit = (next: unknown) => onChange?.(field.path, next);
|
const emit = (next: unknown) => onChange?.(field.path, next);
|
||||||
|
|
@ -141,6 +147,9 @@ function Input({
|
||||||
</select>
|
</select>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (field.kind === "services") {
|
||||||
|
return <ServicesPicker value={(value as string[]) ?? []} notify={notify} onChange={(next) => emit(next)} />;
|
||||||
|
}
|
||||||
if (field.kind === "signs") {
|
if (field.kind === "signs") {
|
||||||
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
|
return <SignsPicker value={(value as string[]) ?? []} group={incidentGroup} onChange={(next) => emit(next)} />;
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +179,46 @@ function Input({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Как `max_length` у `KIO.notify_extra`. */
|
||||||
|
const SERVICES_LIMIT = 20;
|
||||||
|
|
||||||
|
/** Ручное добавление служб из каталога — окно «Добавьте службы» боевого АРМ.
|
||||||
|
* Список оповещения по признакам остаётся как есть: здесь только добавки. */
|
||||||
|
function ServicesPicker({ value, notify, onChange }: {
|
||||||
|
value: string[]; notify: string[]; onChange: (next: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const catalog = useServiceCatalog();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
// Лимит и службы из списка оповещения — те же правила, что на сервере:
|
||||||
|
// лишняя служба отклонила бы весь пакет правок.
|
||||||
|
const full = value.length >= SERVICES_LIMIT;
|
||||||
|
const found = needle && !full
|
||||||
|
? (catalog.data?.services ?? [])
|
||||||
|
.filter((item) => !value.includes(item.name) && !notify.includes(item.name)
|
||||||
|
&& item.name.toLowerCase().includes(needle))
|
||||||
|
.slice(0, 8)
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<div className="kio-services">
|
||||||
|
{value.length > 0 && <div className="signs-selected" aria-label="Добавленные службы">
|
||||||
|
{value.map((name) => <span key={name}>
|
||||||
|
{name} <button type="button" aria-label={`Убрать ${name}`}
|
||||||
|
onClick={() => onChange(value.filter((item) => item !== name))}>×</button>
|
||||||
|
</span>)}
|
||||||
|
</div>}
|
||||||
|
<input type="search" aria-label="Поиск службы в каталоге"
|
||||||
|
placeholder={full ? `Не больше ${SERVICES_LIMIT} служб` : "Поиск службы…"} disabled={full}
|
||||||
|
value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||||
|
{found.length > 0 && <ul className="kio-services-found">
|
||||||
|
{found.map((item) => <li key={item.id}>
|
||||||
|
<button type="button" onClick={() => { onChange([...value, item.name]); setQuery(""); }}>{item.name}</button>
|
||||||
|
</li>)}
|
||||||
|
</ul>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
|
function IncidentGroupPicker({ value, onChange }: { value: number | null; onChange: (next: number | null) => void }) {
|
||||||
const page = useIncidentGroups();
|
const page = useIncidentGroups();
|
||||||
return <select aria-label="Категория происшествия" value={value ?? ""}
|
return <select aria-label="Категория происшествия" value={value ?? ""}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import type { DDSCode, IncidentType } from "@/shared/types/generated";
|
import type { DDSCode, IncidentType } from "@/shared/types/generated";
|
||||||
|
|
||||||
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list";
|
export type FieldKind = "text" | "textarea" | "number" | "bool" | "select" | "signs" | "group" | "list" | "services";
|
||||||
|
|
||||||
export interface Field {
|
export interface Field {
|
||||||
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
|
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
|
||||||
|
|
@ -107,6 +107,8 @@ export const GROUPS: FieldGroup[] = [
|
||||||
fields: [
|
fields: [
|
||||||
// Список оповещения оператор не выбирает: он производен от признаков.
|
// Список оповещения оператор не выбирает: он производен от признаков.
|
||||||
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
|
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
|
||||||
|
// Как «Службы: +» в боевом АРМ: добавить можно, убрать автоматическую — нет.
|
||||||
|
{ path: "notify_extra", label: "Дополнительно оповестить", kind: "services" },
|
||||||
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
|
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
|
||||||
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
|
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
|
||||||
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },
|
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ function field(card: CardState, path: string): string {
|
||||||
const value = readPath(card.server, path);
|
const value = readPath(card.server, path);
|
||||||
return value === null || value === undefined || value === "" ? "—" : String(value);
|
return value === null || value === undefined || value === "" ? "—" : String(value);
|
||||||
}
|
}
|
||||||
|
/** Список из карточки: `field()` не годится — `String([])` пустая строка, а не «—». */
|
||||||
|
function names(card: CardState, path: string): string[] {
|
||||||
|
const value = readPath(card.server, path);
|
||||||
|
return Array.isArray(value) ? value.map(String) : [];
|
||||||
|
}
|
||||||
function time(value: string): string {
|
function time(value: string): string {
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString("ru-RU");
|
||||||
|
|
@ -548,6 +553,7 @@ export function Dds() {
|
||||||
<div><b>{field(card, "description")}</b></div>
|
<div><b>{field(card, "description")}</b></div>
|
||||||
<div>Класс: <b>{field(card, "incident_type")}</b></div>
|
<div>Класс: <b>{field(card, "incident_type")}</b></div>
|
||||||
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
|
<div>Получатели карточки: <b>{station?.recipient_services?.join(", ") || field(card, "notify")}</b></div>
|
||||||
|
{names(card, "notify_extra").length > 0 && <div>Добавлены оператором: <b>{names(card, "notify_extra").join(", ")}</b></div>}
|
||||||
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
|
<div>Ваша ДДС: <b>{station?.managed_service || "служба не назначена"}</b></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="dds-case-stage">
|
<div className="dds-case-stage">
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,17 @@ export const useIncidentGroups = () => useQuery({
|
||||||
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
|
queryFn: () => request<IncidentGroupsPage>("/api/ekp/groups"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface ServiceCatalogPage {
|
||||||
|
source: string;
|
||||||
|
services: { id: string; name: string; organization: string | null; service_level: string | null; source_reference: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Каталог окна «Добавьте службы»: 209 служб, включая районные ДДС. */
|
||||||
|
export const useServiceCatalog = () => useQuery({
|
||||||
|
queryKey: ["ekp-services"], staleTime: Infinity,
|
||||||
|
queryFn: () => request<ServiceCatalogPage>("/api/ekp/services"),
|
||||||
|
});
|
||||||
|
|
||||||
export function useSigns(prefix: string[], group: number | null = null) {
|
export function useSigns(prefix: string[], group: number | null = null) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (group !== null) params.set("group", String(group));
|
if (group !== null) params.set("group", String(group));
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,17 @@ export const callChannel = (
|
||||||
...options,
|
...options,
|
||||||
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
queueKey: (event) => event.type === "kio.patch" ? "kio.patch" : null,
|
||||||
isAcknowledged: (incoming, pending) => {
|
isAcknowledged: (incoming, pending) => {
|
||||||
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch"
|
if (pending.type !== "kio.patch" || incoming.type !== "kio.patch") return false;
|
||||||
|| incoming.source !== "operator") return false;
|
// `auto` по всем полям команды с другим значением — сервер её переписал
|
||||||
|
// (так он отвечает и на отклонённый пакет), а merge.ts уже снял правку из
|
||||||
|
// pending. Повтор затёр бы серверную истину, а слияние с новыми правками
|
||||||
|
// отклоняло бы и их. Совпадающий `auto` ничего не доказывает: команда
|
||||||
|
// могла не дойти, повтор безвреден.
|
||||||
|
if (incoming.source === "auto") {
|
||||||
|
const keys = Object.keys(pending.fields);
|
||||||
|
return keys.every((key) => key in incoming.fields)
|
||||||
|
&& keys.some((key) => !sameJsonValue(pending.fields[key], incoming.fields[key]));
|
||||||
|
}
|
||||||
return Object.entries(pending.fields).every(([key, value]) =>
|
return Object.entries(pending.fields).every(([key, value]) =>
|
||||||
sameJsonValue(value, incoming.fields[key]),
|
sameJsonValue(value, incoming.fields[key]),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,8 @@ export interface KIO {
|
||||||
evacuation_needed?: boolean;
|
evacuation_needed?: boolean;
|
||||||
/** Список оповещения */
|
/** Список оповещения */
|
||||||
notify?: Array<string>;
|
notify?: Array<string>;
|
||||||
|
/** Дополнительно оповестить */
|
||||||
|
notify_extra?: Array<string>;
|
||||||
/** Устаревшее: одна служба. Заменено списком оповещения */
|
/** Устаревшее: одна служба. Заменено списком оповещения */
|
||||||
dds?: DDSCode | null;
|
dds?: DDSCode | null;
|
||||||
dispatch_order_at?: string | null;
|
dispatch_order_at?: string | null;
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,11 @@ button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
|
.kio-arm-incident .signs select { flex: 1 1 180px; width: auto; max-width: 100%; padding: 7px 8px; border: 1px solid #acb5bc; background: white; }
|
||||||
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
.kio-arm-ref .signs-selected { display: flex; flex: 1 1 100%; flex-wrap: wrap; gap: 5px; margin-top: 5px; }
|
||||||
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
|
.kio-arm-ref .signs-selected span { padding: 7px 10px; background: #1876b4; color: white; font-size: 12px; font-weight: 700; }
|
||||||
|
.kio-services { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||||
|
.kio-services .signs-selected { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.kio-services .signs-selected button { border: 0; background: transparent; color: inherit; cursor: pointer; padding: 0 0 0 4px; }
|
||||||
|
.kio-services-found { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
|
||||||
|
.kio-services-found button { width: 100%; text-align: left; border: 0; border-bottom: 1px solid var(--line); background: var(--panel); padding: 6px 8px; cursor: pointer; }
|
||||||
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
|
.kio-arm-choice { display: flex; flex-wrap: wrap; gap: 6px; padding: 5px 0; }
|
||||||
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
|
.kio-arm-choice button { min-width: 49px; padding: 6px 11px; border: 1px solid #aeb6bd; background: white; font-size: 13px; font-weight: 700; }
|
||||||
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }
|
.kio-arm-choice .kio-arm-selected { border-color: #1979bb; background: #1979bb; color: white; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue