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:
parent
842a333d16
commit
d4f0d0a0f8
14 changed files with 953 additions and 9 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
36
frontend/src/pages/dds/statuses.ts
Normal file
36
frontend/src/pages/dds/statuses.ts
Normal 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"];
|
||||
Loading…
Reference in a new issue