From d4f0d0a0f8ee711d64d98bb2d95c4ee28a3c9fd6 Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 20 Sep 2026 08:23:55 +0300
Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?=
=?UTF-8?q?=D1=8B=20=D1=80=D0=B5=D0=B0=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=90=D0=A0=D0=9C=20=D0=94?=
=?UTF-8?q?=D0=94=D0=A1=20=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20?=
=?UTF-8?q?=D0=B4=D0=B8=D1=81=D0=BF=D0=B5=D1=82=D1=87=D0=B5=D1=80=D0=B0=20?=
=?UTF-8?q?D1=E2=80=93D6=20(lct-33)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
---
1.md | 0
backend/app/api/ws/call.py | 4 +
backend/app/api/ws/station.py | 28 +++-
backend/app/domain/events.py | 26 +++-
backend/app/domain/statuses.py | 198 +++++++++++++++++++++++++
backend/app/domain/taxonomy.py | 50 +++++++
backend/app/scoring/dispatcher.py | 125 ++++++++++++++++
backend/app/session/finish.py | 16 +-
backend/app/session/state.py | 48 ++++++
backend/tests/test_station.py | 88 +++++++++++
backend/tests/test_statuses.py | 175 ++++++++++++++++++++++
frontend/src/pages/dds/Dds.tsx | 119 ++++++++++++++-
frontend/src/pages/dds/statuses.ts | 36 +++++
frontend/src/shared/types/generated.ts | 49 +++++-
14 files changed, 953 insertions(+), 9 deletions(-)
create mode 100644 1.md
create mode 100644 backend/app/domain/statuses.py
create mode 100644 backend/app/scoring/dispatcher.py
create mode 100644 backend/tests/test_statuses.py
create mode 100644 frontend/src/pages/dds/statuses.ts
diff --git a/1.md b/1.md
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/api/ws/call.py b/backend/app/api/ws/call.py
index 46cade5..aab37f9 100644
--- a/backend/app/api/ws/call.py
+++ b/backend/app/api/ws/call.py
@@ -13,6 +13,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
from app.domain.events import (
+ StationState,
CallEnded,
CallEndReason,
CallStarted,
@@ -123,6 +124,9 @@ async def _handle(session_id: UUID, state, event) -> None:
# Карточка замораживается снимком и уходит диспетчеру: оператор
# не должен иметь возможности дописать поле задним числом.
hub.to_station(session_id, state.card_received_event())
+ # Список оповещения диспетчер должен видеть сразу, а не после
+ # первого своего действия: отмечаться ему по нему же (lct-33).
+ hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot()))
diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py
index de556d0..5b6e12c 100644
--- a/backend/app/api/ws/station.py
+++ b/backend/app/api/ws/station.py
@@ -15,7 +15,8 @@ from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from pydantic import TypeAdapter, ValidationError
-from app.domain.events import ErrorEvent, ErrorKind, StationToServer
+from app.domain.events import ErrorEvent, ErrorKind, StationState, StationToServer
+from app.domain.statuses import PRIMARY, ServiceStatus, StatusError
from app.session.hub import hub
from app.session.state import now_utc
@@ -28,8 +29,31 @@ _adapter = TypeAdapter(StationToServer)
async def _handle(session_id: UUID, state, event) -> None:
match event.type:
case "card.ack":
+ # Подтверждение приёма — это статус «Принята» у главной службы.
+ # Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
+ # диспетчер один и выбирать службу не из чего.
state.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None))
+ services = state.notified_services()
+ if services:
+ try:
+ state.set_service_status(services[0], ServiceStatus.ACCEPTED)
+ except StatusError:
+ pass # статус уже стоит: повторное нажатие ничего не меняет
+ case "card.status":
+ try:
+ state.set_service_status(
+ event.service, event.status, event.comment, author="диспетчер"
+ )
+ except StatusError as exc:
+ hub.to_station(
+ session_id,
+ ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(exc)),
+ )
+ return
+ # Первичный статус останавливает норматив 30 секунд.
+ if event.status in PRIMARY:
+ state.on_event("card.ack")
case "card.bounce":
# Карточка вернулась: в разборе это E6 с конкретной причиной.
state.bounced_fields = list(event.missing_fields)
@@ -44,6 +68,7 @@ async def _handle(session_id: UUID, state, event) -> None:
state.on_event("crew.arrived")
state.kio = state.kio.model_copy(update={"arrival_at": event.at})
state.dds_log.append(("crew.arrived", now_utc(), None))
+ hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, state.snapshot())
@@ -72,6 +97,7 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
# диспетчер садится за АРМ, когда вызов уже идёт.
if state.dispatched_card is not None:
hub.to_station(session_id, state.card_received_event())
+ hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
while True:
payload = await ws.receive_json()
try:
diff --git a/backend/app/domain/events.py b/backend/app/domain/events.py
index 4afdb9d..6686a47 100644
--- a/backend/app/domain/events.py
+++ b/backend/app/domain/events.py
@@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType, Level
from app.domain.kio import KIO, Coords
+from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
@@ -453,6 +454,27 @@ class CardBounce(BaseModel):
comment: str = ""
+class ServiceStatusSet(BaseModel):
+ """Диспетчер ставит статус реагирования своей службе.
+
+ Последовательность жёсткая, комментарий к отказу обязателен — это не
+ валидация формы, а то, чему учит второй режим занятия
+ (docs/spec/DATASET.md#статусы-реагирования).
+ """
+
+ type: Literal["card.status"] = "card.status"
+ service: str
+ status: ServiceStatus
+ comment: str = ""
+
+
+class StationState(BaseModel):
+ """Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
+
+ type: Literal["station.state"] = "station.state"
+ snapshot: StationSnapshot
+
+
class ZoneDecision(BaseModel):
type: Literal["zone.decision"] = "zone.decision"
in_zone: bool
@@ -469,12 +491,12 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
- CardReceived | TimerTick | SessionEnded | ErrorEvent,
+ CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
- CardAck | CardBounce | ZoneDecision | CrewDispatched | CrewArrived,
+ CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]
diff --git a/backend/app/domain/statuses.py b/backend/app/domain/statuses.py
new file mode 100644
index 0000000..2242d77
--- /dev/null
+++ b/backend/app/domain/statuses.py
@@ -0,0 +1,198 @@
+"""Статусы реагирования служб и статусы карточки.
+
+Взято из памятки ГБУ «Система 112» для дежурно-диспетчерских служб
+(docs/spec/DATASET.md#статусы-реагирования). Это не наша модель работы, а её
+модель: формулировки, порядок и обязательность комментариев — от заказчика,
+и менять их по своему вкусу нельзя, иначе курсант выучит не то.
+
+Главное свойство: статусы выбираются **последовательно**. Сначала доступны
+только «Принята» и «Не принята»; всё остальное открывается после «Принята».
+"""
+
+from datetime import datetime
+from enum import StrEnum
+
+from pydantic import BaseModel
+
+
+class ServiceStatus(StrEnum):
+ """Статус реагирования одной службы из списка оповещения."""
+
+ ADDED = "added" # технический: карточка сохранена с оповещением
+ RECEIVED = "received" # технический: карточка открыта на АРМ
+ ACCEPTED = "accepted" # Принята
+ DECLINED = "declined" # Не принята
+ RESPONDING = "responding" # Начало реагирования
+ ARRIVED = "arrived" # Прибытие
+ WORKING = "working" # Проведение работ
+ COMPLETED = "completed" # Работы завершены
+ REFUSED = "refused" # Отказ от выполнения работ
+
+
+SERVICE_STATUS_LABELS: dict[ServiceStatus, str] = {
+ ServiceStatus.ADDED: "Добавлена",
+ ServiceStatus.RECEIVED: "Получена службой",
+ ServiceStatus.ACCEPTED: "Принята",
+ ServiceStatus.DECLINED: "Не принята",
+ ServiceStatus.RESPONDING: "Начало реагирования",
+ ServiceStatus.ARRIVED: "Прибытие",
+ ServiceStatus.WORKING: "Проведение работ",
+ ServiceStatus.COMPLETED: "Работы завершены",
+ ServiceStatus.REFUSED: "Отказ от выполнения работ",
+}
+
+#: Автомат переходов. «Не принята» ведёт только к «Принята» — служба может
+#: передумать, но не может отказаться дважды по-разному.
+NEXT: dict[ServiceStatus, tuple[ServiceStatus, ...]] = {
+ ServiceStatus.ADDED: (ServiceStatus.ACCEPTED, ServiceStatus.DECLINED),
+ ServiceStatus.RECEIVED: (ServiceStatus.ACCEPTED, ServiceStatus.DECLINED),
+ ServiceStatus.DECLINED: (ServiceStatus.ACCEPTED,),
+ ServiceStatus.ACCEPTED: (
+ ServiceStatus.RESPONDING,
+ ServiceStatus.ARRIVED,
+ ServiceStatus.WORKING,
+ ServiceStatus.COMPLETED,
+ ServiceStatus.REFUSED,
+ ),
+ ServiceStatus.RESPONDING: (
+ ServiceStatus.ARRIVED,
+ ServiceStatus.WORKING,
+ ServiceStatus.COMPLETED,
+ ServiceStatus.REFUSED,
+ ),
+ ServiceStatus.ARRIVED: (
+ ServiceStatus.WORKING,
+ ServiceStatus.COMPLETED,
+ ServiceStatus.REFUSED,
+ ),
+ ServiceStatus.WORKING: (ServiceStatus.COMPLETED, ServiceStatus.REFUSED),
+ # Закрывают карточку от правки: дальше только звонок в отдел контроля.
+ ServiceStatus.COMPLETED: (),
+ ServiceStatus.REFUSED: (),
+}
+
+#: Без комментария не сохраняются. Это не валидация формы, а предмет обучения:
+#: половина нарушений в памятке — отказ без указания, куда передана информация.
+COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset(
+ {ServiceStatus.DECLINED, ServiceStatus.REFUSED}
+)
+
+#: Первичные статусы: их ждут в норматив 30 секунд.
+PRIMARY: frozenset[ServiceStatus] = frozenset(
+ {ServiceStatus.ACCEPTED, ServiceStatus.DECLINED}
+)
+
+TERMINAL: frozenset[ServiceStatus] = frozenset(
+ {ServiceStatus.COMPLETED, ServiceStatus.REFUSED}
+)
+
+
+class CardStatus(StrEnum):
+ """Статус карточки целиком — считается из статусов служб, руками не ставится."""
+
+ REGISTERED = "registered" # Зарегистрирована
+ PROCESSED = "processed" # Отработана: оповещение завершено
+ CHECKED = "checked" # Проверена главным специалистом
+ NOT_NOTIFIED = "not_notified" # Не оповещено: служба промолчала в норматив
+ REFUSED = "refused" # Отказ
+ NOT_COMPLETED = "not_completed" # Не завершено: 48 часов без завершения работ
+ COMPLETED = "completed" # Завершена
+
+
+CARD_STATUS_LABELS: dict[CardStatus, str] = {
+ CardStatus.REGISTERED: "Зарегистрирована",
+ CardStatus.PROCESSED: "Отработана",
+ CardStatus.CHECKED: "Проверена",
+ CardStatus.NOT_NOTIFIED: "Не оповещено",
+ CardStatus.REFUSED: "Отказ",
+ CardStatus.NOT_COMPLETED: "Не завершено",
+ CardStatus.COMPLETED: "Завершена",
+}
+
+#: Красные в интерфейсе и в разборе: именно они попадают в раздел отдела
+#: контроля реагирования. Цвет несёт смысл только здесь (docs/arch/FRONTEND.md).
+ALARMING: frozenset[CardStatus] = frozenset(
+ {CardStatus.NOT_NOTIFIED, CardStatus.REFUSED, CardStatus.NOT_COMPLETED}
+)
+
+
+class StatusEntry(BaseModel):
+ """Одна отметка службы. Комментарий — часть отметки, а не примечание к ней."""
+
+ service: str
+ status: ServiceStatus
+ at: datetime
+ comment: str = ""
+ author: str = ""
+
+
+class StatusError(ValueError):
+ """Переход запрещён автоматом или нет обязательного комментария."""
+
+
+def current(entries: list[StatusEntry], service: str) -> ServiceStatus:
+ """Последний статус службы. Без отметок — «Добавлена»."""
+ for entry in reversed(entries):
+ if entry.service == service:
+ return entry.status
+ return ServiceStatus.ADDED
+
+
+def check(entries: list[StatusEntry], service: str, status: ServiceStatus, comment: str) -> None:
+ """Проверить отметку до записи. Бросает `StatusError` с текстом для курсанта."""
+ now = current(entries, service)
+ if status not in NEXT[now]:
+ allowed = ", ".join(SERVICE_STATUS_LABELS[item] for item in NEXT[now]) or "ничего"
+ raise StatusError(
+ f"после «{SERVICE_STATUS_LABELS[now]}» доступно: {allowed}"
+ + ("" if NEXT[now] else " — карточка закрыта для редактирования")
+ )
+ if status in COMMENT_REQUIRED and not comment.strip():
+ raise StatusError(
+ f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: причина и то, "
+ "куда передана информация"
+ )
+
+
+def card_status(
+ entries: list[StatusEntry], services: list[str], notify_deadline_passed: bool = False
+) -> CardStatus:
+ """Статус карточки по статусам служб.
+
+ Упрощение против боевой системы, и оно намеренное: статусы «Проверена»
+ и «Не завершено» требуют главного специалиста и 48 часов — ни того, ни
+ другого в занятии на полтора часа нет. Остальные четыре считаются честно.
+ """
+ if not services:
+ return CardStatus.COMPLETED
+
+ latest = {service: current(entries, service) for service in services}
+ silent = [
+ service for service, status in latest.items() if status not in PRIMARY | TERMINAL
+ ]
+ if silent and notify_deadline_passed:
+ return CardStatus.NOT_NOTIFIED
+ if any(
+ status in {ServiceStatus.DECLINED, ServiceStatus.REFUSED} for status in latest.values()
+ ):
+ return CardStatus.REFUSED
+ if all(status is ServiceStatus.COMPLETED for status in latest.values()):
+ return CardStatus.COMPLETED
+ if silent:
+ return CardStatus.REGISTERED
+ return CardStatus.PROCESSED
+
+
+class StationSnapshot(BaseModel):
+ """Что видит диспетчер: службы, их статусы и что доступно дальше.
+
+ Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда
+ собирается целиком, а необязательное поле уезжает в TypeScript как
+ `| undefined` и заставляет фронт проверять то, чего не бывает.
+ """
+
+ services: list[str]
+ statuses: dict[str, ServiceStatus]
+ available: dict[str, list[ServiceStatus]]
+ card: CardStatus
+ log: list[StatusEntry]
diff --git a/backend/app/domain/taxonomy.py b/backend/app/domain/taxonomy.py
index e545c06..a2be930 100644
--- a/backend/app/domain/taxonomy.py
+++ b/backend/app/domain/taxonomy.py
@@ -12,18 +12,32 @@ from pydantic import BaseModel
class ErrorCode(StrEnum):
+ """E — ошибки приёма вызова, D — ошибки диспетчера ДДС.
+
+ Две роли, две таксономии. D1–D6 взяты из памятки заказчика дословно:
+ она перечисляет типовые нарушения с реальными примерами, и преподаватель
+ узнаёт их формулировки (docs/spec/DATASET.md).
+ """
+
E1 = "E1"
E2 = "E2"
E3 = "E3"
E4 = "E4"
E5 = "E5"
E6 = "E6"
+ D1 = "D1"
+ D2 = "D2"
+ D3 = "D3"
+ D4 = "D4"
+ D5 = "D5"
+ D6 = "D6"
class FindingSource(StrEnum):
"""Кто выставил отметку. `judge` — единственный недетерминированный источник."""
SLOTS = "slots"
+ DISPATCHER = "dispatcher"
GROUND_TRUTH = "ground_truth"
TIMERS = "timers"
KIO = "kio"
@@ -76,6 +90,42 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
detail="Карточка ушла в ДДС непригодной, снятие с контроля до подтверждения",
source=FindingSource.CHAIN,
),
+ ErrorCode.D1: ErrorSpec(
+ code=ErrorCode.D1,
+ title="Статус реагирования не проставлен",
+ detail="Карточка осталась без первичного статуса и ушла в «Не оповещено»",
+ source=FindingSource.DISPATCHER,
+ ),
+ ErrorCode.D2: ErrorSpec(
+ code=ErrorCode.D2,
+ title="Статус не соответствует факту",
+ detail="«Принята» там, где работы проводиться не будут, и наоборот",
+ source=FindingSource.DISPATCHER,
+ ),
+ ErrorCode.D3: ErrorSpec(
+ code=ErrorCode.D3,
+ title="Отказ от профильного происшествия",
+ detail="Служба есть в списке оповещения, значит происшествие её — отказ неправомерен",
+ source=FindingSource.DISPATCHER,
+ ),
+ ErrorCode.D4: ErrorSpec(
+ code=ErrorCode.D4,
+ title="Нет комментария к отказу",
+ detail="«Не принята» и «Отказ от выполнения работ» без причины",
+ source=FindingSource.DISPATCHER,
+ ),
+ ErrorCode.D5: ErrorSpec(
+ code=ErrorCode.D5,
+ title="Неполный комментарий",
+ detail="Не указано, куда передана информация и что сделал диспетчер",
+ source=FindingSource.JUDGE,
+ ),
+ ErrorCode.D6: ErrorSpec(
+ code=ErrorCode.D6,
+ title="Нет статусов хода работ",
+ detail="Реагирование шло, но начало, прибытие и завершение не отмечены",
+ source=FindingSource.DISPATCHER,
+ ),
}
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
new file mode 100644
index 0000000..258c405
--- /dev/null
+++ b/backend/app/scoring/dispatcher.py
@@ -0,0 +1,125 @@
+"""Оценка работы диспетчера ДДС: статусы реагирования.
+
+Вторая роль — второй набор ошибок. D1–D6 взяты из памятки заказчика, где они
+перечислены поимённо и с реальными примерами (docs/spec/DATASET.md). Формулировки
+не переписаны: преподаватель, который эту памятку читал, должен узнать их в разборе.
+
+Детерминированно считаются D1, D2, D3, D4 и D6. D5 — полнота комментария — мягкий
+критерий, его место у судьи (lct-13): «не принята: не обслуживаем» без указания,
+куда передана информация, формально неотличимо от полного комментария.
+"""
+
+from app.domain.statuses import (
+ COMMENT_REQUIRED,
+ PRIMARY,
+ SERVICE_STATUS_LABELS,
+ ServiceStatus,
+ StatusEntry,
+ current,
+)
+from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
+
+#: Статусы, означающие, что реагирование действительно шло.
+PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
+
+
+def _finding(code: ErrorCode, summary: str, fact: str, norm: str | None = None) -> Finding:
+ return Finding(
+ code=code,
+ source=FindingSource.DISPATCHER,
+ summary=summary,
+ fact=fact,
+ norm=norm,
+ ref="памятка «Работа на АРМ-112», раздел «Статусы реагирования»",
+ competency=Competency.CARD,
+ )
+
+
+def evaluate_dispatcher(
+ *,
+ entries: list[StatusEntry],
+ services: list[str],
+ deadline_ms: int,
+ elapsed_ms: int | None,
+) -> list[Finding]:
+ """Отметки по работе диспетчера. Пустой список — работа без нарушений.
+
+ `services` — список оповещения карточки. Он же ответ на вопрос, профильно ли
+ происшествие: служба в списке — значит происшествие её, и отказ «не наша
+ компетенция» неправомерен.
+ """
+ findings: list[Finding] = []
+ if not services:
+ return findings
+
+ for service in services:
+ marks = [entry for entry in entries if entry.service == service]
+ latest = current(entries, service)
+
+ # D1 — первичного статуса нет вовсе.
+ if not any(mark.status in PRIMARY for mark in marks):
+ findings.append(
+ _finding(
+ ErrorCode.D1,
+ f"{service}: статус реагирования не проставлен",
+ fact=(
+ f"прошло {elapsed_ms // 1000} с"
+ if elapsed_ms is not None
+ else "карточка осталась без отметки"
+ ),
+ norm=f"первичный статус в течение {deadline_ms // 1000} с",
+ )
+ )
+ continue
+
+ for mark in marks:
+ # D4 — отказ без комментария.
+ if mark.status in COMMENT_REQUIRED and not mark.comment.strip():
+ findings.append(
+ _finding(
+ ErrorCode.D4,
+ f"{service}: «{SERVICE_STATUS_LABELS[mark.status]}» без комментария",
+ fact="причина отказа не указана",
+ norm="комментарий к отказу обязателен",
+ )
+ )
+
+ # D3 — отказ от профильного происшествия. Служба в списке оповещения
+ # по классификатору, значит происшествие входит в её компетенцию.
+ if latest is ServiceStatus.DECLINED:
+ findings.append(
+ _finding(
+ ErrorCode.D3,
+ f"{service}: отказ от происшествия, которое в её компетенции",
+ fact="служба есть в списке оповещения по ЕКП",
+ norm="отказываться от профильного происшествия нельзя",
+ )
+ )
+
+ # D2 — «Принята», но работ не было и отказа тоже: статус не отражает факт.
+ if latest is ServiceStatus.ACCEPTED:
+ findings.append(
+ _finding(
+ ErrorCode.D2,
+ f"{service}: «Принята», но о реагировании ничего не отмечено",
+ fact="после приёма статусов не было",
+ norm="статус должен соответствовать фактическому состоянию заявки",
+ )
+ )
+
+ # D6 — работы завершены, а ход работ не отмечен. В памятке это отдельный
+ # разбор: по такому происшествию идут повторные звонки, и другие службы
+ # не видят, что реагирование вообще началось.
+ if latest is ServiceStatus.COMPLETED and not any(
+ mark.status in PROGRESS for mark in marks
+ ):
+ findings.append(
+ _finding(
+ ErrorCode.D6,
+ f"{service}: работы завершены без отметок хода",
+ fact="начало реагирования и прибытие не отмечены",
+ norm="статусы хода работ проставляются по факту",
+ )
+ )
+
+ return findings
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index eb9904a..0feca6a 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -11,9 +11,10 @@ import logging
from uuid import UUID
from app.domain.events import ScoreReady
-from app.domain.timers import TimerCode
+from app.domain.timers import NORMATIVES, TimerCode
from app.scenarios import store
from app.scoring.competency import radar
+from app.scoring.dispatcher import evaluate_dispatcher
from app.scoring.gost import evaluate
from app.session.hub import hub
@@ -35,6 +36,17 @@ async def finish(session_id: UUID, state) -> None:
end_reason=state.end_reason,
bounced_fields=state.bounced_fields,
)
+ # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
+ # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
+ # участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
+ dispatcher_findings = evaluate_dispatcher(
+ entries=state.status_log,
+ services=state.notified_services(),
+ deadline_ms=NORMATIVES[TimerCode.DDS_ACK].limit_ms,
+ elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK),
+ )
+ result.findings.extend(dispatcher_findings)
+
# Сводка числами: по ней считается дельта между попытками в профиле.
# Вытаскивать её разбором текста метрик («94 с») — путь к тихим ошибкам.
required = scenario.ground_truth.required_facts
@@ -56,6 +68,8 @@ async def finish(session_id: UUID, state) -> None:
"findings": [finding.model_dump(mode="json") for finding in result.findings],
"competencies": [item.model_dump() for item in radar(result.metrics)],
"unavailable": result.unavailable,
+ # Статус карточки — готовая красная метка, понятная любому диспетчеру.
+ "card_status": state.station_snapshot().card.value,
}
log.info("сессия %s: оценка %.1f, отметок %d", session_id, result.score, len(result.findings))
diff --git a/backend/app/session/state.py b/backend/app/session/state.py
index 6b2bde3..6f8829b 100644
--- a/backend/app/session/state.py
+++ b/backend/app/session/state.py
@@ -22,6 +22,16 @@ from app.dialog.caller import TemplateCaller
from app.dialog.persona import PersonaState
from app.dialog.slots import SlotMachine
from app.scenarios.schema import Scenario
+from app.domain.statuses import (
+ NEXT,
+ ServiceStatus,
+ StationSnapshot,
+ StatusEntry,
+ card_status,
+ check,
+ current,
+)
+from app.domain.timers import NORMATIVES, TimerCode
from app.domain.kio import KIO, apply_patch
from app.session.timers import SessionTimers
@@ -81,6 +91,8 @@ class SessionState:
#: Поля, из-за которых диспетчер вернул карточку, — основание E6.
bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
+ #: Отметки служб по списку оповещения — предмет второго режима занятия.
+ status_log: list[StatusEntry] = field(default_factory=list)
def on_event(self, event_type: str) -> None:
"""Единственная точка, где событие двигает таймеры."""
@@ -109,6 +121,42 @@ class SessionState:
self.dispatched_at = now_utc()
return self.dispatched_card
+ def notified_services(self) -> list[str]:
+ """Список оповещения переданной карточки. До передачи — пусто:
+ отмечаться диспетчеру пока не по чему."""
+ card = self.dispatched_card or self.kio
+ return list(card.notify)
+
+ def set_service_status(
+ self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
+ ) -> StatusEntry:
+ """Отметка службы. Проверка перехода — до записи: недопустимый статус
+ не должен попасть в журнал даже отвергнутым."""
+ check(self.status_log, service, status, comment)
+ entry = StatusEntry(
+ service=service, status=status, at=now_utc(), comment=comment, author=author
+ )
+ self.status_log.append(entry)
+ return entry
+
+ def station_snapshot(self) -> StationSnapshot:
+ """Что видит диспетчер: службы, их статусы и доступные переходы."""
+ services = self.notified_services()
+ statuses = {service: current(self.status_log, service) for service in services}
+ elapsed = self.timers.measured_ms(TimerCode.DDS_ACK)
+ limit = NORMATIVES[TimerCode.DDS_ACK].limit_ms
+ overdue = self.dispatched_at is not None and (
+ elapsed is None
+ and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit
+ )
+ return StationSnapshot(
+ services=services,
+ statuses=statuses,
+ available={service: list(NEXT[status]) for service, status in statuses.items()},
+ card=card_status(self.status_log, services, notify_deadline_passed=overdue),
+ log=list(self.status_log),
+ )
+
def card_received_event(self):
"""Снимок карточки для станции ДДС."""
from app.domain.events import CardReceived
diff --git a/backend/tests/test_station.py b/backend/tests/test_station.py
index 8d8c12d..a0d869c 100644
--- a/backend/tests/test_station.py
+++ b/backend/tests/test_station.py
@@ -124,3 +124,91 @@ def test_bounced_card_becomes_e6_with_the_reason(client):
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
finally:
control.__exit__(None, None, None)
+
+
+# ── статусы реагирования (lct-33) ──
+
+
+def dispatched(client):
+ """Занятие, доведённое до передачи карточки в ДДС, и подключённая станция."""
+ session_id, control, _ = lesson(client)
+ station_ctx = client.websocket_connect(f"/ws/station/{session_id}?role=dds_01")
+ station = station_ctx.__enter__()
+ call_ctx = client.websocket_connect(f"/ws/call/{session_id}")
+ trainee = call_ctx.__enter__()
+ trainee.send_json({"type": "call.answer"})
+ trainee.send_json(
+ {"type": "kio.patch", "fields": {"signs": ["жилой дом", "балкон", "открытое пламя"]}}
+ )
+ wait_for(lambda: hub.get(session_id).kio.incident_code)
+ trainee.send_json({"type": "dds.dispatch", "service": "01"})
+ read_until(station, "card.received")
+ return session_id, station, (control, station_ctx, call_ctx)
+
+
+def close_all(contexts):
+ for context in contexts:
+ context.__exit__(None, None, None)
+
+
+def test_station_sees_the_notify_list_from_the_classifier(client):
+ """Диспетчеру есть что отмечать только потому, что список оповещения
+ посчитан по ЕКП, а не выбран оператором (lct-32)."""
+ session_id, station, contexts = dispatched(client)
+ try:
+ state = read_until(station, "station.state")
+ assert "Служба 101" in state["snapshot"]["services"]
+ assert len(state["snapshot"]["services"]) > 1
+ assert state["snapshot"]["statuses"]["Служба 101"] == "added"
+ finally:
+ close_all(contexts)
+
+
+def test_dispatcher_sets_a_status_and_the_card_follows(client):
+ session_id, station, contexts = dispatched(client)
+ try:
+ read_until(station, "station.state")
+ station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
+ state = read_until(station, "station.state")
+ assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
+ assert "responding" in state["snapshot"]["available"]["Служба 101"]
+ assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
+ finally:
+ close_all(contexts)
+
+
+def test_out_of_order_status_is_rejected_with_a_reason(client):
+ """Автомат живёт на сервере: фронт может ошибиться, состояние — нет."""
+ session_id, station, contexts = dispatched(client)
+ try:
+ read_until(station, "station.state")
+ station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived"})
+ error = read_until(station, "error")
+ assert "Принята" in error["message"]
+ assert hub.get(session_id).status_log == []
+ finally:
+ close_all(contexts)
+
+
+def test_refusal_without_a_comment_is_rejected(client):
+ session_id, station, contexts = dispatched(client)
+ try:
+ read_until(station, "station.state")
+ station.send_json({"type": "card.status", "service": "Служба 101", "status": "declined"})
+ error = read_until(station, "error")
+ assert "комментар" in error["message"]
+ finally:
+ close_all(contexts)
+
+
+def test_ack_button_still_works_and_counts_as_accepted(client):
+ """Кнопка подтверждения из живой цепочки (lct-20) осталась и означает
+ «Принята» у главной службы — иначе два экрана учили бы разному."""
+ session_id, station, contexts = dispatched(client)
+ try:
+ read_until(station, "station.state")
+ station.send_json({"type": "card.ack"})
+ state = read_until(station, "station.state")
+ assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
+ finally:
+ close_all(contexts)
diff --git a/backend/tests/test_statuses.py b/backend/tests/test_statuses.py
new file mode 100644
index 0000000..fa26aca
--- /dev/null
+++ b/backend/tests/test_statuses.py
@@ -0,0 +1,175 @@
+"""Статусы реагирования на АРМ ДДС.
+
+Пункты приёмки lct-33: автомат переходов, обязательный комментарий,
+необратимость завершения, статус карточки и ошибки диспетчера D1–D6.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from app.domain.statuses import (
+ CardStatus,
+ ServiceStatus,
+ StatusEntry,
+ StatusError,
+ card_status,
+ check,
+ current,
+)
+from app.domain.taxonomy import ErrorCode
+from app.scoring.dispatcher import evaluate_dispatcher
+
+SERVICES = ["Служба 101", "МВД"]
+NOW = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
+
+
+def mark(service: str, status: ServiceStatus, comment: str = "", minute: int = 0) -> StatusEntry:
+ return StatusEntry(
+ service=service, status=status, at=NOW + timedelta(minutes=minute), comment=comment
+ )
+
+
+# ── автомат переходов ──
+
+
+def test_only_primary_statuses_are_available_at_first():
+ check([], "Служба 101", ServiceStatus.ACCEPTED, "")
+ check([], "Служба 101", ServiceStatus.DECLINED, "не обслуживаем, передано в УК")
+ with pytest.raises(StatusError):
+ check([], "Служба 101", ServiceStatus.ARRIVED, "")
+
+
+def test_accepted_opens_the_rest():
+ log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
+ check(log, "Служба 101", ServiceStatus.RESPONDING, "")
+ check(log, "Служба 101", ServiceStatus.COMPLETED, "")
+
+
+def test_declined_leads_only_back_to_accepted():
+ """Служба может передумать, но не может отказаться дважды по-разному."""
+ log = [mark("Служба 101", ServiceStatus.DECLINED, "не наш адрес")]
+ check(log, "Служба 101", ServiceStatus.ACCEPTED, "")
+ with pytest.raises(StatusError):
+ check(log, "Служба 101", ServiceStatus.RESPONDING, "")
+
+
+def test_completed_closes_the_card_for_editing():
+ log = [
+ mark("Служба 101", ServiceStatus.ACCEPTED),
+ mark("Служба 101", ServiceStatus.COMPLETED, minute=5),
+ ]
+ with pytest.raises(StatusError) as exc:
+ check(log, "Служба 101", ServiceStatus.WORKING, "")
+ assert "закрыта" in str(exc.value)
+
+
+def test_refusal_without_a_comment_is_rejected():
+ """Не валидация формы, а предмет обучения: половина нарушений в памятке —
+ отказ без указания, куда передана информация."""
+ with pytest.raises(StatusError) as exc:
+ check([], "МВД", ServiceStatus.DECLINED, " ")
+ assert "комментар" in str(exc.value)
+
+
+def test_statuses_are_tracked_per_service():
+ log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
+ assert current(log, "Служба 101") is ServiceStatus.ACCEPTED
+ assert current(log, "МВД") is ServiceStatus.ADDED
+
+
+# ── статус карточки ──
+
+
+def test_silence_past_the_deadline_is_not_notified():
+ assert card_status([], SERVICES, notify_deadline_passed=True) is CardStatus.NOT_NOTIFIED
+
+
+def test_silence_within_the_deadline_is_still_registered():
+ assert card_status([], SERVICES, notify_deadline_passed=False) is CardStatus.REGISTERED
+
+
+def test_any_refusal_marks_the_whole_card():
+ log = [
+ mark("Служба 101", ServiceStatus.ACCEPTED),
+ mark("МВД", ServiceStatus.DECLINED, "не наша территория, передано в ОМВД"),
+ ]
+ assert card_status(log, SERVICES) is CardStatus.REFUSED
+
+
+def test_all_completed_closes_the_card():
+ log = []
+ for service in SERVICES:
+ log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
+ assert card_status(log, SERVICES) is CardStatus.COMPLETED
+
+
+def test_alarming_statuses_are_the_three_from_the_memo():
+ from app.domain.statuses import ALARMING
+
+ assert ALARMING == {CardStatus.NOT_NOTIFIED, CardStatus.REFUSED, CardStatus.NOT_COMPLETED}
+
+
+# ── ошибки диспетчера ──
+
+
+def _codes(entries, services=SERVICES, elapsed=45_000):
+ findings = evaluate_dispatcher(
+ entries=entries, services=services, deadline_ms=30_000, elapsed_ms=elapsed
+ )
+ return [finding.code for finding in findings]
+
+
+def test_no_status_is_d1():
+ assert ErrorCode.D1 in _codes([])
+
+
+def test_refusal_without_comment_is_d4():
+ """Автомат такую отметку не пропустит, но карточка может прийти из внешней
+ системы уже с пустым комментарием — разбор обязан это показать."""
+ log = [mark(service, ServiceStatus.DECLINED) for service in SERVICES]
+ assert ErrorCode.D4 in _codes(log)
+
+
+def test_declining_a_profile_incident_is_d3():
+ log = [
+ mark("Служба 101", ServiceStatus.ACCEPTED),
+ mark("МВД", ServiceStatus.DECLINED, "не в нашей компетенции"),
+ ]
+ codes = _codes(log)
+ assert ErrorCode.D3 in codes, "служба в списке оповещения — происшествие профильное"
+
+
+def test_accepted_and_forgotten_is_d2():
+ log = [mark(service, ServiceStatus.ACCEPTED) for service in SERVICES]
+ assert ErrorCode.D2 in _codes(log)
+
+
+def test_completed_without_progress_is_d6():
+ log = []
+ for service in SERVICES:
+ log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
+ assert ErrorCode.D6 in _codes(log)
+
+
+def test_clean_work_has_no_findings():
+ log = []
+ for service in SERVICES:
+ log += [
+ mark(service, ServiceStatus.ACCEPTED),
+ mark(service, ServiceStatus.RESPONDING, minute=1),
+ mark(service, ServiceStatus.ARRIVED, minute=4),
+ mark(service, ServiceStatus.WORKING, minute=5),
+ mark(service, ServiceStatus.COMPLETED, minute=20),
+ ]
+ assert _codes(log) == []
+
+
+def test_every_finding_carries_its_reason():
+ """Правило проекта: ни одной отметки без обоснования."""
+ findings = evaluate_dispatcher(
+ entries=[], services=SERVICES, deadline_ms=30_000, elapsed_ms=45_000
+ )
+ assert findings
+ for finding in findings:
+ assert finding.fact and finding.ref
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index b4250b2..c83ea1b 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -11,9 +11,11 @@ import { GROUPS } from "@/features/kio-card/fields";
import { KioCard } from "@/features/kio-card/KioCard";
import { applyState, empty as emptyCard, type CardState } from "@/features/kio-card/merge";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
+import type { ServiceStatus, StationSnapshot } from "@/shared/types/generated";
+import { SERVICE_STATUS_LABELS, CARD_STATUS_LABELS, ALARMING } from "./statuses";
import { sessionIdFromUrl } from "@/shared/api/session";
-const ACK_LIMIT_S = 4; // норматив подтверждения получения карточки
+const ACK_LIMIT_S = 30; // норматив подтверждения, ПП РФ № 1931 и памятка АРМ-112
const FIELDS = GROUPS.flatMap((group) =>
group.fields.filter((field) => !field.readOnly).map((field) => ({ path: field.path, label: field.label })),
@@ -34,6 +36,7 @@ export function Dds() {
const [comment, setComment] = useState("");
const [zone, setZone] = useState(null);
const [marks, setMarks] = useState([]);
+ const [station, setStation] = useState(null);
const channel = useRef | null>(null);
useEffect(() => {
@@ -41,6 +44,8 @@ export function Dds() {
const ch = stationChannel(sessionId, role, {
onStatus: setStatus,
onEvent: (event) => {
+ if (event.type === "station.state") setStation(event.snapshot);
+ if (event.type === "error") mark(`отклонено: ${event.message}`);
if (event.type === "card.received") {
setCard(applyState(emptyCard, event.card as unknown as Record));
setFrom(event.from_operator);
@@ -146,12 +151,62 @@ export function Dds() {
}}>Прибытие
+ {station && station.services.length > 0 && (
+ <>
+ Статусы реагирования
+
+ Статус карточки:{" "}
+
+ {CARD_STATUS_LABELS[station.card]}
+
+
+
+
+ | Служба | Статус | Отметить |
+
+
+ {station.services.map((service) => (
+ {
+ channel.current?.send({ type: "card.status", service, status, comment });
+ mark(`${service}: ${SERVICE_STATUS_LABELS[status]}`);
+ }}
+ />
+ ))}
+
+
+ >
+ )}
+
Карточка от оператора 112
{/* Снимок: после передачи он не меняется. */}
>
)}
+ {station && station.log.length > 0 && (
+ <>
+ Журнал отметок
+
+
+ {station.log.map((entry, index) => (
+
+ | {entry.service} |
+
+ {SERVICE_STATUS_LABELS[entry.status]}
+ {entry.comment && <> — {entry.comment}>}
+ |
+
+ ))}
+
+
+ >
+ )}
+
{marks.length > 0 && (
<>
Отметки времени
@@ -163,3 +218,65 @@ export function Dds() {
);
}
+
+/** Одна служба списка оповещения.
+ *
+ * Доступные статусы приходят с сервера: автомат переходов живёт там, и
+ * дублировать его на фронте нельзя — разъедется. Поле комментария появляется
+ * у отказов, потому что без комментария сервер такую отметку не примет. */
+function StatusRow({
+ service, current, available, onSet,
+}: {
+ service: string;
+ current: ServiceStatus;
+ available: ServiceStatus[];
+ onSet: (status: ServiceStatus, comment: string) => void;
+}) {
+ const [chosen, setChosen] = useState("");
+ const [comment, setComment] = useState("");
+ const needsComment = chosen === "declined" || chosen === "refused";
+
+ if (available.length === 0) {
+ return (
+
+ | {service} |
+ {SERVICE_STATUS_LABELS[current]} |
+ карточка закрыта для редактирования |
+
+ );
+ }
+ return (
+
+ | {service} |
+ {SERVICE_STATUS_LABELS[current]} |
+
+ {" "}
+ {needsComment && (
+ setComment(event.target.value)}
+ />
+ )}{" "}
+
+ |
+
+ );
+}
diff --git a/frontend/src/pages/dds/statuses.ts b/frontend/src/pages/dds/statuses.ts
new file mode 100644
index 0000000..8dcc36b
--- /dev/null
+++ b/frontend/src/pages/dds/statuses.ts
@@ -0,0 +1,36 @@
+// Подписи статусов реагирования и статусов карточки.
+//
+// Формулировки — из памятки ГБУ «Система 112» дословно (docs/spec/DATASET.md).
+// Переводить их на «свой» язык нельзя: диспетчер узнаёт эти слова на своём
+// боевом АРМ, и тренажёр должен говорить ими же.
+//
+// Сами значения приходят из домена через generated.ts — здесь только подписи,
+// иначе список статусов разъедется с сервером.
+
+import type { CardStatus, ServiceStatus } from "@/shared/types/generated";
+
+export const SERVICE_STATUS_LABELS: Record = {
+ added: "Добавлена",
+ received: "Получена службой",
+ accepted: "Принята",
+ declined: "Не принята",
+ responding: "Начало реагирования",
+ arrived: "Прибытие",
+ working: "Проведение работ",
+ completed: "Работы завершены",
+ refused: "Отказ от выполнения работ",
+};
+
+export const CARD_STATUS_LABELS: Record = {
+ registered: "Зарегистрирована",
+ processed: "Отработана",
+ checked: "Проверена",
+ not_notified: "Не оповещено",
+ refused: "Отказ",
+ not_completed: "Не завершено",
+ completed: "Завершена",
+};
+
+/** Три статуса, которые в боевой системе красные и попадают в раздел отдела
+ * контроля реагирования. Единственное место, где цвет несёт смысл. */
+export const ALARMING: CardStatus[] = ["not_notified", "refused", "not_completed"];
diff --git a/frontend/src/shared/types/generated.ts b/frontend/src/shared/types/generated.ts
index 65ddbde..705041d 100644
--- a/frontend/src/shared/types/generated.ts
+++ b/frontend/src/shared/types/generated.ts
@@ -78,6 +78,9 @@ export interface CardReceived {
at: string;
}
+/** Статус карточки целиком — считается из статусов служб, руками не ставится. */
+export type CardStatus = "registered" | "processed" | "checked" | "not_notified" | "refused" | "not_completed" | "completed";
+
/** Шесть осей радара. Проекция уже посчитанных метрик, без пересчёта весов. */
export type Competency = "intake" | "interview" | "card" | "routing" | "norms" | "communication";
@@ -130,7 +133,8 @@ export interface EraData {
impact_force: string;
}
-export type ErrorCode = "E1" | "E2" | "E3" | "E4" | "E5" | "E6";
+/** E — ошибки приёма вызова, D — ошибки диспетчера ДДС. Две роли, две таксономии. D1–D6 взяты из памятки заказчика дословно: она перечисляет типовые нарушения с реальными примерами, и преподаватель узнаёт их формулировки (docs/spec/DATASET.md). */
+export type ErrorCode = "E1" | "E2" | "E3" | "E4" | "E5" | "E6" | "D1" | "D2" | "D3" | "D4" | "D5" | "D6";
export interface ErrorEvent {
type: "error";
@@ -155,7 +159,7 @@ export interface Finding {
}
/** Кто выставил отметку. `judge` — единственный недетерминированный источник. */
-export type FindingSource = "slots" | "ground_truth" | "timers" | "kio" | "chain" | "judge" | "instructor";
+export type FindingSource = "slots" | "dispatcher" | "ground_truth" | "timers" | "kio" | "chain" | "judge" | "instructor";
/** Уточняющие поля 01 — МЧС. */
export interface FireDetails {
@@ -358,6 +362,17 @@ export interface SelfAssessmentSubmit {
comment?: string;
}
+/** Статус реагирования одной службы из списка оповещения. */
+export type ServiceStatus = "added" | "received" | "accepted" | "declined" | "responding" | "arrived" | "working" | "completed" | "refused";
+
+/** Диспетчер ставит статус реагирования своей службе. Последовательность жёсткая, комментарий к отказу обязателен — это не валидация формы, а то, чему учит второй режим занятия (docs/spec/DATASET.md#статусы-реагирования). */
+export interface ServiceStatusSet {
+ type: "card.status";
+ service: string;
+ status: ServiceStatus;
+ comment?: string;
+}
+
export interface SessionEnded {
type: "session.ended";
reason: CallEndReason;
@@ -412,6 +427,30 @@ export interface SessionStop {
export type Speaker = "caller" | "operator";
+/** Что видит диспетчер: службы, их статусы и что доступно дальше. Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда собирается целиком, а необязательное поле уезжает в TypeScript как `| undefined` и заставляет фронт проверять то, чего не бывает. */
+export interface StationSnapshot {
+ services: Array;
+ statuses: Record;
+ available: Record>;
+ card: CardStatus;
+ log: Array;
+}
+
+/** Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше. */
+export interface StationState {
+ type: "station.state";
+ snapshot: StationSnapshot;
+}
+
+/** Одна отметка службы. Комментарий — часть отметки, а не примечание к ней. */
+export interface StatusEntry {
+ service: string;
+ status: ServiceStatus;
+ at: string;
+ comment?: string;
+ author?: string;
+}
+
export interface SttFinal {
type: "stt.final";
text: string;
@@ -500,9 +539,9 @@ export type ServerToObserver = SessionSnapshot | TranscriptAppend | KioState | T
export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay | InstructorNoteAdd | ScoreOverride | ScenarioPublish | SessionStop;
-export type ServerToStation = CardReceived | TimerTick | SessionEnded | ErrorEvent;
+export type ServerToStation = CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent;
-export type StationToServer = CardAck | CardBounce | ZoneDecision | CrewDispatched | CrewArrived;
+export type StationToServer = CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"bg.start"
@@ -517,6 +556,7 @@ export type EventName =
| "card.ack"
| "card.bounce"
| "card.received"
+ | "card.status"
| "crew.arrived"
| "crew.dispatched"
| "dds.dispatch"
@@ -540,6 +580,7 @@ export type EventName =
| "session.ended"
| "session.snapshot"
| "session.stop"
+ | "station.state"
| "stt.final"
| "stt.partial"
| "timer.tick"