lct-10: карточка КИО — форма, слияние правок, read-only

Фронт не хранит карточку как истину: локально живут только правки
курсанта, ещё не подтверждённые сервером. Распознанное сервером значение
перекрывает набранное, эхо своей правки её подтверждает. Иначе
автозаполнение и ручной ввод дерутся за одно поле — худший класс багов
для отладки в последнюю ночь.

Описание полей отдельно от отрисовки: по нему же карточка рисуется
read-only на мониторе, пульте и АРМ ДДС.

Сверка формы с контрактом встроена в make typecheck: все 40 полей
домена на форме, а поле, добавленное в контракт и забытое в форме,
теперь поймается само. Это пропущенный факт по вине интерфейса,
а не курсанта.

Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи
вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес
сам, курсант перестанет за неё отвечать.
This commit is contained in:
Ivan Gerasimov 2026-09-17 21:16:51 +03:00
commit 48c936ea6e
8 changed files with 454 additions and 3 deletions

View file

@ -7,7 +7,8 @@
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit && node scripts/check-kio-fields.mjs",
"check:kio": "node scripts/check-kio-fields.mjs"
},
"dependencies": {
"@tanstack/react-query": "^5.103.1",

View file

@ -0,0 +1,45 @@
// Все поля карточки из контракта должны быть на форме.
//
// Домен живёт в backend/app/domain/kio.py и меняется: поле, добавленное в контракт
// и забытое в форме, — это пропущенный факт по вине интерфейса, а не курсанта.
// Проверка сверяет пути в features/kio-card/fields.ts с типами из generated.ts.
import { readFileSync } from "node:fs";
const types = readFileSync(new URL("../src/shared/types/generated.ts", import.meta.url), "utf8");
const spec = readFileSync(new URL("../src/features/kio-card/fields.ts", import.meta.url), "utf8");
// Поля, которых на форме нет намеренно.
const SKIP = new Map([
["card_id", "служебный идентификатор, человеку не нужен"],
["coords", "координаты приходят от ЭРА-ГЛОНАСС, отдельным экраном"],
]);
const NESTED = { fire: "FireDetails", police: "PoliceDetails", medical: "MedicalDetails", utility: "UtilityDetails" };
function fieldsOf(name) {
const body = types.match(new RegExp(`export interface ${name} \\{([\\s\\S]*?)\\n\\}`))?.[1];
if (!body) throw new Error(`в generated.ts нет интерфейса ${name}`);
return [...body.matchAll(/^\s{2}(\w+)\??:/gm)].map((m) => m[1]);
}
const expected = [];
for (const field of fieldsOf("KIO")) {
if (SKIP.has(field)) continue;
if (field in NESTED) {
for (const nested of fieldsOf(NESTED[field])) expected.push(`${field}.${nested}`);
} else {
expected.push(field);
}
}
const shown = new Set([...spec.matchAll(/path:\s*"([^"]+)"/g)].map((m) => m[1]));
const missing = expected.filter((path) => !shown.has(path));
const extra = [...shown].filter((path) => !expected.includes(path));
if (missing.length || extra.length) {
if (missing.length) console.error("нет на форме КИО:", missing.join(", "));
if (extra.length) console.error("на форме есть, а в контракте нет:", extra.join(", "));
process.exit(1);
}
console.log(`карточка КИО: все ${expected.length} полей контракта на форме`);

View file

@ -10,7 +10,11 @@ import { Ambience } from "@/shared/audio/ambience";
import { type Capture, startCapture } from "@/shared/audio/capture";
import { EnergyGate } from "@/shared/audio/levels";
import { CALLER_RATE, Playback } from "@/shared/audio/playback";
import type { CallIncoming, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
import { type CardState, applyPatch, edit, empty as emptyCard } from "@/features/kio-card/merge";
import type { CallIncoming, DDSCode, ServerToTrainee, TimerSnapshot } from "@/shared/types/generated";
/** Правки копятся и уходят одной дельтой: 300 мс тишины — и отправка. */
const PATCH_DEBOUNCE_MS = 300;
export interface Line {
speaker: "caller" | "operator";
@ -28,12 +32,15 @@ export function useCall(sessionId: string | null) {
const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [callerSpeaking, setCallerSpeaking] = useState(false);
const [micOn, setMicOn] = useState(false);
const [card, setCard] = useState<CardState>(emptyCard);
const [error, setError] = useState<string | null>(null);
const channel = useRef<ReturnType<typeof callChannel> | null>(null);
const capture = useRef<Capture | null>(null);
const audio = useRef<{ context: AudioContext; playback: Playback; ambience: Ambience } | null>(null);
const gate = useRef(new EnergyGate());
const outbox = useRef<Record<string, unknown>>({});
const patchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const append = useCallback((line: Line) => {
setLines((prev) => {
@ -82,6 +89,9 @@ export function useCall(sessionId: string | null) {
case "bg.stop":
audio.current?.ambience.stop();
break;
case "kio.patch":
setCard((prev) => applyPatch(prev, event.fields, event.source));
break;
case "timer.tick":
setTimers(event.timers);
break;
@ -137,5 +147,23 @@ export function useCall(sessionId: string | null) {
const hint = useCallback(() => channel.current?.send({ type: "hint.request" }), []);
return { status, phase, incoming, lines, timers, callerSpeaking, micOn, error, answer, hangup, hint };
const patchKio = useCallback((path: string, value: unknown) => {
setCard((prev) => edit(prev, path, value));
outbox.current[path] = value;
if (patchTimer.current) clearTimeout(patchTimer.current);
patchTimer.current = setTimeout(() => {
const fields = outbox.current;
outbox.current = {};
if (Object.keys(fields).length) channel.current?.send({ type: "kio.patch", fields });
}, PATCH_DEBOUNCE_MS);
}, []);
const dispatch = useCallback((service: DDSCode) => {
channel.current?.send({ type: "dds.dispatch", service });
}, []);
return {
status, phase, incoming, lines, timers, callerSpeaking, micOn, error, card,
answer, hangup, hint, patchKio, dispatch,
};
}

View file

@ -0,0 +1,125 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { type CardState, display } from "./merge";
import { type Field, visibleGroups } from "./fields";
interface Props {
state: CardState;
/** Обязательные поля приходят сценарием в `call.incoming`. */
required?: string[];
readOnly?: boolean;
onChange?: (path: string, value: unknown) => void;
}
export function KioCard({ state, required = [], readOnly = false, onChange }: Props) {
const dds = display(state, "dds") as string | null;
return (
<div className="kio">
{visibleGroups(dds).map((group) => (
<table className="grid" key={group.title}>
<thead>
<tr>
<th colSpan={2}>{group.title}</th>
</tr>
</thead>
<tbody>
{group.fields.map((field) => (
<Row
key={field.path}
field={field}
value={display(state, field.path)}
pending={field.path in state.pending}
required={required.includes(field.path)}
readOnly={readOnly || field.readOnly}
onChange={onChange}
/>
))}
</tbody>
</table>
))}
</div>
);
}
function Row({
field, value, pending, required, readOnly, onChange,
}: {
field: Field;
value: unknown;
pending: boolean;
required: boolean;
readOnly?: boolean;
onChange?: (path: string, value: unknown) => void;
}) {
const empty = value === null || value === undefined || value === "";
return (
<tr>
<th>
{field.label}
{required && <span title="обязательное поле" className="required"> ·</span>}
</th>
<td className={required && empty ? "warn" : pending ? "pending" : ""}>
{readOnly ? (
<span>{formatValue(value)}</span>
) : (
<Input field={field} value={value} onChange={onChange} />
)}
</td>
</tr>
);
}
function Input({
field, value, onChange,
}: {
field: Field;
value: unknown;
onChange?: (path: string, value: unknown) => void;
}) {
const emit = (next: unknown) => onChange?.(field.path, next);
if (field.kind === "bool") {
return (
<input type="checkbox" checked={value === true} onChange={(e) => emit(e.target.checked)} />
);
}
if (field.kind === "select") {
return (
<select value={(value as string) ?? ""} onChange={(e) => emit(e.target.value || null)}>
<option value="">—</option>
{field.options?.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
);
}
if (field.kind === "number") {
return (
<input
type="number"
min={0}
value={value === null || value === undefined ? "" : String(value)}
onChange={(e) => {
const text = e.target.value;
if (text === "") return emit(null);
const parsed = Number(text);
// Нечисловое значение не уходит на сервер: карточка — норматив,
// а не свободный текст.
if (Number.isFinite(parsed) && parsed >= 0) emit(parsed);
}}
/>
);
}
return (
<input type="text" value={(value as string) ?? ""} onChange={(e) => emit(e.target.value)} />
);
}
function formatValue(value: unknown): string {
if (value === true) return "да";
if (value === false) return "нет";
if (value === null || value === undefined || value === "") return "—";
return String(value);
}

View file

@ -0,0 +1,147 @@
// Поля карточки информационного обмена. Это не дизайн формы, это норматив:
// docs/spec/NORMATIVES.md#карточка-информационного-обмена-кио
//
// Описание полей отдельно от отрисовки: по нему же рисуется карточка на внешнем
// мониторе, на пульте и на АРМ ДДС — там она read-only.
import type { DDSCode, IncidentType } from "@/shared/types/generated";
export type FieldKind = "text" | "number" | "bool" | "select";
export interface Field {
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
path: string;
label: string;
kind: FieldKind;
options?: { value: string; label: string }[];
/** Заполняется системой: номер определяется автоматически, время — сервером. */
readOnly?: boolean;
}
export interface FieldGroup {
title: string;
fields: Field[];
/** Группа показывается только для этой службы. */
dds?: DDSCode;
}
const INCIDENT: { value: IncidentType; label: string }[] = [
{ value: "fire", label: "Пожар" },
{ value: "medical", label: "Медицинский вызов" },
{ value: "police", label: "Правонарушение" },
{ value: "gkh", label: "Авария ЖКХ" },
{ value: "era_glonass", label: "ДТП, автовызов" },
{ value: "other", label: "Прочее" },
];
const DDS: { value: DDSCode; label: string }[] = [
{ value: "01", label: "01 — МЧС" },
{ value: "02", label: "02 — полиция" },
{ value: "03", label: "03 — скорая помощь" },
{ value: "04", label: "04 — прочие службы" },
{ value: "gkh", label: "ЖКХ" },
];
export const GROUPS: FieldGroup[] = [
{
title: "Служебное",
fields: [
{ path: "registered_at", label: "Время регистрации", kind: "text", readOnly: true },
{ path: "response_status", label: "Статус реагирования", kind: "text", readOnly: true },
],
},
{
title: "Звонящий",
fields: [
{ path: "caller_number", label: "Номер телефона", kind: "text", readOnly: true },
{ path: "caller_name", label: "ФИО", kind: "text" },
{ path: "caller_contact", label: "Контактный телефон", kind: "text" },
{ path: "language", label: "Язык общения", kind: "text" },
],
},
{
title: "Место",
fields: [
{ path: "okato", label: "ОКАТО", kind: "text" },
{ path: "address", label: "Адрес", kind: "text" },
{ path: "street", label: "Улица", kind: "text" },
{ path: "building", label: "Дом", kind: "text" },
{ path: "entrance", label: "Подъезд", kind: "text" },
{ path: "floor", label: "Этаж", kind: "text" },
{ path: "intercom_code", label: "Код домофона", kind: "text" },
],
},
{
title: "Происшествие",
fields: [
{ path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT },
{ path: "description", label: "Описание", kind: "text" },
{ path: "victims_count", label: "Пострадавших", kind: "number" },
{ path: "is_emergency", label: "Признак ЧС", kind: "bool" },
{ path: "life_threat", label: "Угроза жизни", kind: "bool" },
{ path: "evacuation_needed", label: "Нужна эвакуация", kind: "bool" },
],
},
{
title: "Дежурно-диспетчерская служба",
fields: [
{ path: "dds", label: "Служба", kind: "select", options: DDS },
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },
],
},
{
title: "Уточнения: пожар",
dds: "01",
fields: [
{ path: "fire.fire_nature", label: "Характер пожара", kind: "text" },
{ path: "fire.object_kind", label: "Объект", kind: "text" },
{ path: "fire.floors", label: "Этажность", kind: "number" },
{ path: "fire.gasified", label: "Газификация", kind: "bool" },
{ path: "fire.people_inside", label: "Люди в помещении", kind: "bool" },
{ path: "fire.smoke_spread", label: "Куда идёт дым", kind: "text" },
],
},
{
title: "Уточнения: полиция",
dds: "02",
fields: [
{ path: "police.offence_kind", label: "Вид правонарушения", kind: "text" },
{ path: "police.suspects", label: "Подозреваемые, приметы", kind: "text" },
{ path: "police.suspect_fled", label: "Нарушитель скрылся", kind: "bool" },
{ path: "police.vehicle", label: "Транспортное средство", kind: "text" },
],
},
{
title: "Уточнения: медицина",
dds: "03",
fields: [
{ path: "medical.reason", label: "Повод к вызову", kind: "text" },
{ path: "medical.conscious", label: "В сознании", kind: "bool" },
{ path: "medical.breathing", label: "Дышит", kind: "bool" },
{ path: "medical.can_move", label: "Может передвигаться", kind: "bool" },
{ path: "medical.age", label: "Возраст", kind: "number" },
],
},
{
title: "Уточнения: ЖКХ и прочие",
dds: "gkh",
fields: [
{ path: "utility.failure_kind", label: "Тип аварии", kind: "text" },
{ path: "utility.scale", label: "Масштаб", kind: "text" },
{ path: "utility.threat_to_residents", label: "Угроза жильцам", kind: "bool" },
],
},
];
/** Группы, которые показываются при выбранной службе. */
export function visibleGroups(dds: string | null | undefined): FieldGroup[] {
return GROUPS.filter((group) => !group.dds || group.dds === dds);
}
export function readValue(card: Record<string, unknown>, path: string): unknown {
return path.split(".").reduce<unknown>(
(value, part) => (value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined),
card,
);
}

View file

@ -0,0 +1,69 @@
// Кто выигрывает спор за поле карточки.
//
// Истина — на сервере: он распознаёт факты из речи и присылает `kio.patch`
// с `source: "auto"`. Локально хранится только то, что курсант печатает
// прямо сейчас, — до подтверждения сервером. Иначе автозаполнение и ручной
// ввод дерутся за одно поле, и это худший класс багов для отладки в последнюю
// ночь (docs/arch/FRONTEND.md).
export interface CardState {
/** Состояние с сервера — единственная истина. */
server: Record<string, unknown>;
/** Правки курсанта, ещё не подтверждённые сервером: путь → значение. */
pending: Record<string, unknown>;
}
export const empty: CardState = { server: {}, pending: {} };
export function setNested(card: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const [head, ...rest] = path.split(".");
if (rest.length === 0) return { ...card, [head]: value };
const nested = (card[head] as Record<string, unknown>) ?? {};
return { ...card, [head]: setNested(nested, rest.join("."), value) };
}
/** Курсант ввёл значение: показываем сразу, отправку делает вызывающий. */
export function edit(state: CardState, path: string, value: unknown): CardState {
return { ...state, pending: { ...state.pending, [path]: value } };
}
/**
* Пришёл `kio.patch` от сервера.
*
* `auto` — сервер распознал факт из речи: он побеждает, локальная правка снимается.
* `operator` — эхо правки: снимаем ожидание, если сервер подтвердил то же значение.
*/
export function applyPatch(
state: CardState,
fields: Record<string, unknown>,
source: "auto" | "operator",
): CardState {
let server = state.server;
const pending = { ...state.pending };
for (const [path, value] of Object.entries(fields)) {
server = setNested(server, path, value);
if (source === "auto" || pending[path] === value) delete pending[path];
}
return { server, pending };
}
/** Пришло состояние карточки целиком (`kio.state`, `session.snapshot`). */
export function applyState(state: CardState, card: Record<string, unknown>): CardState {
const pending = { ...state.pending };
for (const path of Object.keys(pending)) {
if (readPath(card, path) === pending[path]) delete pending[path];
}
return { server: card, pending };
}
export function readPath(card: Record<string, unknown>, path: string): unknown {
return path.split(".").reduce<unknown>(
(value, part) => (value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined),
card,
);
}
/** Что показать в поле: своя неподтверждённая правка либо значение сервера. */
export function display(state: CardState, path: string): unknown {
return path in state.pending ? state.pending[path] : readPath(state.server, path);
}

View file

@ -1,6 +1,8 @@
// АРМ курсанта: приём вызова и разговор голосом.
// Карточка КИО — lct-10, таймеры — lct-11.
import { KioCard } from "@/features/kio-card/KioCard";
import { display } from "@/features/kio-card/merge";
import { useCall } from "@/features/call/useCall";
import { sessionIdFromUrl } from "@/shared/api/session";
@ -48,6 +50,26 @@ export function Call() {
{call.error && <p className="violated">{call.error}</p>}
<div className="columns">
<section>
<h2>Карточка информационного обмена</h2>
<KioCard
state={call.card}
required={call.incoming?.required_fields ?? []}
readOnly={!call.micOn}
onChange={call.patchKio}
/>
<p>
<button
type="button"
disabled={!call.micOn || !display(call.card, "dds")}
onClick={() => call.dispatch(display(call.card, "dds") as never)}
>
Передать в ДДС
</button>
</p>
</section>
<section>
<h2>Разговор</h2>
<table className="grid">
<tbody>
@ -60,6 +82,8 @@ export function Call() {
{call.lines.length === 0 && <tr><td colSpan={2}>—</td></tr>}
</tbody>
</table>
</section>
</div>
</main>
);
}

View file

@ -39,3 +39,15 @@ button { font: inherit; padding: 6px 16px; border: 1px solid var(--line); backgr
button:disabled { opacity: 0.5; cursor: default; }
.warn { color: var(--warn); }
.violated { color: var(--violated); }
/* Две колонки: карточка и разговор рядом, как на настоящем АРМ. */
.columns { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
.columns > section { flex: 1 1 420px; min-width: 0; }
.kio { display: flex; flex-direction: column; gap: 12px; }
.kio .grid { width: 100%; }
.kio thead th { background: var(--line); }
.kio input[type="text"], .kio input[type="number"], .kio select { width: 100%; font: inherit; border: 1px solid var(--line); padding: 2px 4px; background: var(--panel); }
/* Обязательное поле помечено точкой, незаполненное — жёлтым. */
.required { color: var(--warn); font-weight: 700; }
/* Правка, которую сервер ещё не подтвердил. */
.pending { background: #fffbe6; }