From 48c936ea6ef13c2359e70c45f74d8af7ed46bb69 Mon Sep 17 00:00:00 2001 From: Ivan Gerasimov Date: Thu, 17 Sep 2026 21:16:51 +0300 Subject: [PATCH] =?UTF-8?q?lct-10:=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=9A=D0=98=D0=9E=20=E2=80=94=20=D1=84=D0=BE?= =?UTF-8?q?=D1=80=D0=BC=D0=B0,=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BE=D0=BA,=20read-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фронт не хранит карточку как истину: локально живут только правки курсанта, ещё не подтверждённые сервером. Распознанное сервером значение перекрывает набранное, эхо своей правки её подтверждает. Иначе автозаполнение и ручной ввод дерутся за одно поле — худший класс багов для отладки в последнюю ночь. Описание полей отдельно от отрисовки: по нему же карточка рисуется read-only на мониторе, пульте и АРМ ДДС. Сверка формы с контрактом встроена в make typecheck: все 40 полей домена на форме, а поле, добавленное в контракт и забытое в форме, теперь поймается само. Это пропущенный факт по вине интерфейса, а не курсанта. Открытый вопрос в карточке: нужно ли автозаполнение карточки из речи вообще — полнота КИО оценивается кодом E5, и если сервер впишет адрес сам, курсант перестанет за неё отвечать. --- frontend/package.json | 3 +- frontend/scripts/check-kio-fields.mjs | 45 +++++++ frontend/src/features/call/useCall.ts | 32 ++++- frontend/src/features/kio-card/KioCard.tsx | 125 ++++++++++++++++++ frontend/src/features/kio-card/fields.ts | 147 +++++++++++++++++++++ frontend/src/features/kio-card/merge.ts | 69 ++++++++++ frontend/src/pages/trainee/Call.tsx | 24 ++++ frontend/src/styles.css | 12 ++ 8 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 frontend/scripts/check-kio-fields.mjs create mode 100644 frontend/src/features/kio-card/KioCard.tsx create mode 100644 frontend/src/features/kio-card/fields.ts create mode 100644 frontend/src/features/kio-card/merge.ts diff --git a/frontend/package.json b/frontend/package.json index 775150e..05e6e2e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/scripts/check-kio-fields.mjs b/frontend/scripts/check-kio-fields.mjs new file mode 100644 index 0000000..2e492bf --- /dev/null +++ b/frontend/scripts/check-kio-fields.mjs @@ -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} полей контракта на форме`); diff --git a/frontend/src/features/call/useCall.ts b/frontend/src/features/call/useCall.ts index ff0364d..826c504 100644 --- a/frontend/src/features/call/useCall.ts +++ b/frontend/src/features/call/useCall.ts @@ -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([]); const [callerSpeaking, setCallerSpeaking] = useState(false); const [micOn, setMicOn] = useState(false); + const [card, setCard] = useState(emptyCard); const [error, setError] = useState(null); const channel = useRef | null>(null); const capture = useRef(null); const audio = useRef<{ context: AudioContext; playback: Playback; ambience: Ambience } | null>(null); const gate = useRef(new EnergyGate()); + const outbox = useRef>({}); + const patchTimer = useRef | 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, + }; } diff --git a/frontend/src/features/kio-card/KioCard.tsx b/frontend/src/features/kio-card/KioCard.tsx new file mode 100644 index 0000000..e8287fc --- /dev/null +++ b/frontend/src/features/kio-card/KioCard.tsx @@ -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 ( +
+ {visibleGroups(dds).map((group) => ( + + + + + + + + {group.fields.map((field) => ( + + ))} + +
{group.title}
+ ))} +
+ ); +} + +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 ( + + + {field.label} + {required && ·} + + + {readOnly ? ( + {formatValue(value)} + ) : ( + + )} + + + ); +} + +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 ( + emit(e.target.checked)} /> + ); + } + if (field.kind === "select") { + return ( + + ); + } + if (field.kind === "number") { + return ( + { + const text = e.target.value; + if (text === "") return emit(null); + const parsed = Number(text); + // Нечисловое значение не уходит на сервер: карточка — норматив, + // а не свободный текст. + if (Number.isFinite(parsed) && parsed >= 0) emit(parsed); + }} + /> + ); + } + return ( + 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); +} diff --git a/frontend/src/features/kio-card/fields.ts b/frontend/src/features/kio-card/fields.ts new file mode 100644 index 0000000..2884409 --- /dev/null +++ b/frontend/src/features/kio-card/fields.ts @@ -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, path: string): unknown { + return path.split(".").reduce( + (value, part) => (value && typeof value === "object" ? (value as Record)[part] : undefined), + card, + ); +} diff --git a/frontend/src/features/kio-card/merge.ts b/frontend/src/features/kio-card/merge.ts new file mode 100644 index 0000000..b74d97c --- /dev/null +++ b/frontend/src/features/kio-card/merge.ts @@ -0,0 +1,69 @@ +// Кто выигрывает спор за поле карточки. +// +// Истина — на сервере: он распознаёт факты из речи и присылает `kio.patch` +// с `source: "auto"`. Локально хранится только то, что курсант печатает +// прямо сейчас, — до подтверждения сервером. Иначе автозаполнение и ручной +// ввод дерутся за одно поле, и это худший класс багов для отладки в последнюю +// ночь (docs/arch/FRONTEND.md). + +export interface CardState { + /** Состояние с сервера — единственная истина. */ + server: Record; + /** Правки курсанта, ещё не подтверждённые сервером: путь → значение. */ + pending: Record; +} + +export const empty: CardState = { server: {}, pending: {} }; + +export function setNested(card: Record, path: string, value: unknown): Record { + const [head, ...rest] = path.split("."); + if (rest.length === 0) return { ...card, [head]: value }; + const nested = (card[head] as Record) ?? {}; + 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, + 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): 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, path: string): unknown { + return path.split(".").reduce( + (value, part) => (value && typeof value === "object" ? (value as Record)[part] : undefined), + card, + ); +} + +/** Что показать в поле: своя неподтверждённая правка либо значение сервера. */ +export function display(state: CardState, path: string): unknown { + return path in state.pending ? state.pending[path] : readPath(state.server, path); +} diff --git a/frontend/src/pages/trainee/Call.tsx b/frontend/src/pages/trainee/Call.tsx index 4375e1f..b030cc2 100644 --- a/frontend/src/pages/trainee/Call.tsx +++ b/frontend/src/pages/trainee/Call.tsx @@ -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 &&

{call.error}

} +
+
+

Карточка информационного обмена

+ +

+ +

+
+

Разговор

@@ -60,6 +82,8 @@ export function Call() { {call.lines.length === 0 && }
—
+
+
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7debe5b..f06e78d 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; }