feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)

ТЗ называет обучаемого оператором ДДС, а его работа в системе-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 чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-20 08:23:55 +03:00
commit d4f0d0a0f8
14 changed files with 953 additions and 9 deletions

View file

@ -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<boolean | null>(null);
const [marks, setMarks] = useState<string[]>([]);
const [station, setStation] = useState<StationSnapshot | null>(null);
const channel = useRef<ReturnType<typeof stationChannel> | 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<string, unknown>));
setFrom(event.from_operator);
@ -146,12 +151,62 @@ export function Dds() {
}}>Прибытие</button>
</p>
{station && station.services.length > 0 && (
<>
<h2>Статусы реагирования</h2>
<p>
Статус карточки:{" "}
<b className={ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
{CARD_STATUS_LABELS[station.card]}
</b>
</p>
<table className="grid">
<thead>
<tr><th>Служба</th><th>Статус</th><th>Отметить</th></tr>
</thead>
<tbody>
{station.services.map((service) => (
<StatusRow
key={service}
service={service}
current={station.statuses[service]}
available={station.available[service] ?? []}
onSet={(status, comment) => {
channel.current?.send({ type: "card.status", service, status, comment });
mark(`${service}: ${SERVICE_STATUS_LABELS[status]}`);
}}
/>
))}
</tbody>
</table>
</>
)}
<h2>Карточка от оператора 112</h2>
{/* Снимок: после передачи он не меняется. */}
<KioCard state={card} readOnly />
</>
)}
{station && station.log.length > 0 && (
<>
<h2>Журнал отметок</h2>
<table className="grid">
<tbody>
{station.log.map((entry, index) => (
<tr key={index}>
<th>{entry.service}</th>
<td>
{SERVICE_STATUS_LABELS[entry.status]}
{entry.comment && <> — {entry.comment}</>}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
{marks.length > 0 && (
<>
<h2>Отметки времени</h2>
@ -163,3 +218,65 @@ export function Dds() {
</main>
);
}
/** Одна служба списка оповещения.
*
* Доступные статусы приходят с сервера: автомат переходов живёт там, и
* дублировать его на фронте нельзя — разъедется. Поле комментария появляется
* у отказов, потому что без комментария сервер такую отметку не примет. */
function StatusRow({
service, current, available, onSet,
}: {
service: string;
current: ServiceStatus;
available: ServiceStatus[];
onSet: (status: ServiceStatus, comment: string) => void;
}) {
const [chosen, setChosen] = useState<ServiceStatus | "">("");
const [comment, setComment] = useState("");
const needsComment = chosen === "declined" || chosen === "refused";
if (available.length === 0) {
return (
<tr>
<th>{service}</th>
<td>{SERVICE_STATUS_LABELS[current]}</td>
<td>карточка закрыта для редактирования</td>
</tr>
);
}
return (
<tr>
<th>{service}</th>
<td>{SERVICE_STATUS_LABELS[current]}</td>
<td>
<select value={chosen} onChange={(event) => setChosen(event.target.value as ServiceStatus)}>
<option value="">—</option>
{available.map((status) => (
<option key={status} value={status}>{SERVICE_STATUS_LABELS[status]}</option>
))}
</select>{" "}
{needsComment && (
<input
type="text"
value={comment}
placeholder="причина и куда передана информация"
onChange={(event) => setComment(event.target.value)}
/>
)}{" "}
<button
type="button"
disabled={!chosen || (needsComment && !comment.trim())}
onClick={() => {
if (!chosen) return;
onSet(chosen, comment);
setChosen("");
setComment("");
}}
>
Поставить
</button>
</td>
</tr>
);
}

View file

@ -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<ServiceStatus, string> = {
added: "Добавлена",
received: "Получена службой",
accepted: "Принята",
declined: "Не принята",
responding: "Начало реагирования",
arrived: "Прибытие",
working: "Проведение работ",
completed: "Работы завершены",
refused: "Отказ от выполнения работ",
};
export const CARD_STATUS_LABELS: Record<CardStatus, string> = {
registered: "Зарегистрирована",
processed: "Отработана",
checked: "Проверена",
not_notified: "Не оповещено",
refused: "Отказ",
not_completed: "Не завершено",
completed: "Завершена",
};
/** Три статуса, которые в боевой системе красные и попадают в раздел отдела
* контроля реагирования. Единственное место, где цвет несёт смысл. */
export const ALARMING: CardStatus[] = ["not_notified", "refused", "not_completed"];