feat: классификатор ЕКП — признаки вместо выбора службы (lct-32)

Оператор системы-112 службу не выбирает: он проставляет формализованные
признаки происшествия, комбинация признаков даёт код ЕКП, а коду соответствует
список оповещения, который система собирает сама (docs/spec/DATASET.md).
Прежняя модель с полем dds из пяти значений оценивала действие, которого
в боевой работе нет.

- scripts/import_ekp.py (make ekp): книга заказчика → app/domain/ekp.json,
  1283 кода, 61 служба, 23 группы. Разовый импорт, результат под гитом:
  читать xlsx в рантайме — лишняя зависимость и полсекунды на старте.
- domain/ekp.py: справочник с ленивой загрузкой, каскад значений признаков,
  список оповещения с модификаторами (нет доступа, угроза людям, пострадавшие,
  газификация и ещё десяток).
- КИО: поля signs, incident_code, notify. Последние два только на чтение
  и пересчитываются при каждой правке признаков; добавленная вручную служба
  не теряется, удалить службу нельзя — как в боевом АРМ.
- GET /api/ekp/signs отдаёт один уровень признаков, а не дерево на полмегабайта.
- Карточка на фронте: три каскадных селектора вместо выбора службы.
- Оценка: метрика incident_signs (E2, маршрутизация). Для размеченных сценариев
  dds_choice больше не считается — список оповещения производен от признаков,
  и штрафовать за него отдельно значит наказать дважды за одну ошибку.

Разбор книги оказался основной работой: имя службы лежит то в первой строке
заголовка, то во второй, то склеено с модификатором; «Классификатор МЧС» —
заголовок группы колонок, а не служба. Правила разбора в докстринге импорта.

113 тестов зелёных (14 новых в tests/test_ekp.py), make typecheck чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-19 20:11:50 +03:00
commit 7212251112
16 changed files with 522 additions and 19 deletions

View file

@ -1,6 +1,7 @@
// Карточка КИО. Один и тот же компонент рисуется у курсанта, на пульте,
// на внешнем мониторе и на АРМ ДДС — там в режиме `readOnly`.
import { useSigns } from "@/shared/api/http";
import { type CardState, display } from "./merge";
import { type Field, visibleGroups } from "./fields";
@ -80,6 +81,9 @@ function Input({
}) {
const emit = (next: unknown) => onChange?.(field.path, next);
if (field.kind === "list") {
return <span>{formatValue(value)}</span>;
}
if (field.kind === "bool") {
return (
<input type="checkbox" checked={value === true} onChange={(e) => emit(e.target.checked)} />
@ -95,6 +99,9 @@ function Input({
</select>
);
}
if (field.kind === "signs") {
return <SignsPicker value={(value as string[]) ?? []} onChange={(next) => emit(next)} />;
}
if (field.kind === "number") {
return (
<input
@ -117,9 +124,63 @@ function Input({
);
}
/** Три признака происшествия каскадом: выбор верхнего сужает нижний.
*
* Значения приходят с сервера, а не лежат на фронте: классификатор — 1283 строки,
* и он обновляется файлом от заказчика (docs/spec/DATASET.md). Служба из выбора
* не следует — её считает сервер, поэтому здесь её нет. */
function SignsPicker({
value, onChange,
}: {
value: string[];
onChange: (next: string[]) => void;
}) {
const levels = [0, 1, 2];
return (
<div className="signs">
{levels.map((level) => (
<SignSelect
key={level}
prefix={value.slice(0, level)}
chosen={value[level] ?? ""}
onPick={(sign) => {
// Смена верхнего признака обнуляет нижние: они относились
// к другой ветке классификатора.
const next = value.slice(0, level);
if (sign) next.push(sign);
onChange(next);
}}
/>
))}
</div>
);
}
function SignSelect({
prefix, chosen, onPick,
}: {
prefix: string[];
chosen: string;
onPick: (sign: string) => void;
}) {
const page = useSigns(prefix);
const values = page.data?.values ?? [];
// Уровня может не быть: часть происшествий классифицируется двумя признаками.
if (!page.isLoading && values.length === 0) return null;
return (
<select value={chosen} onChange={(e) => onPick(e.target.value)} disabled={page.isLoading}>
<option value="">—</option>
{values.map((sign) => (
<option key={sign} value={sign}>{sign}</option>
))}
</select>
);
}
function formatValue(value: unknown): string {
if (value === true) return "да";
if (value === false) return "нет";
if (Array.isArray(value)) return value.length ? value.join(", ") : "—";
if (value === null || value === undefined || value === "") return "—";
return String(value);
}