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

@ -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);
}