Merge branch 'feat/service-catalog'

lct-38: каталог 209 служб из «СЛУЖБЫ 112» для ручного добавления в карточку
This commit is contained in:
GGlamer 2026-09-27 23:04:30 +03:00 • committed by GitHub
commit 8ff51a8955
15 changed files with 1853 additions and 16 deletions

View file

@ -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]}

View file

@ -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)
old_extra = list(state.kio.notify_extra)
try:
state.patch_kio(event.fields) 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))

View file

@ -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}

View file

@ -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)

File diff suppressed because it is too large Load diff

View file

@ -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.

View 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 == ["ФСБ"]

View file

@ -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, {});

View file

@ -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 ?? ""}

View file

@ -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 },

View file

@ -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">

View file

@ -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));

View file

@ -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]),
); );

View file

@ -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;

View file

@ -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; }