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:
parent
ed0b13f060
commit
7212251112
16 changed files with 522 additions and 19 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import type { DDSCode, IncidentType } from "@/shared/types/generated";
|
||||
|
||||
export type FieldKind = "text" | "number" | "bool" | "select";
|
||||
export type FieldKind = "text" | "number" | "bool" | "select" | "signs" | "list";
|
||||
|
||||
export interface Field {
|
||||
/** Путь в карточке: «floor», «fire.floors» — так же, как в `kio.patch`. */
|
||||
|
|
@ -74,6 +74,10 @@ export const GROUPS: FieldGroup[] = [
|
|||
{
|
||||
title: "Происшествие",
|
||||
fields: [
|
||||
// Признаки по ЕКП — то, что оператор действительно проставляет.
|
||||
// Код и список оповещения считает сервер (docs/spec/DATASET.md).
|
||||
{ path: "signs", label: "Признаки происшествия", kind: "signs" },
|
||||
{ path: "incident_code", label: "Код ЕКП", kind: "text", readOnly: true },
|
||||
{ path: "incident_type", label: "Тип происшествия", kind: "select", options: INCIDENT },
|
||||
{ path: "description", label: "Описание", kind: "text" },
|
||||
{ path: "victims_count", label: "Пострадавших", kind: "number" },
|
||||
|
|
@ -85,7 +89,9 @@ export const GROUPS: FieldGroup[] = [
|
|||
{
|
||||
title: "Дежурно-диспетчерская служба",
|
||||
fields: [
|
||||
{ path: "dds", label: "Служба", kind: "select", options: DDS },
|
||||
// Список оповещения оператор не выбирает: он производен от признаков.
|
||||
{ path: "notify", label: "Список оповещения", kind: "list", readOnly: true },
|
||||
{ path: "dds", label: "Служба (устаревшее)", kind: "select", options: DDS },
|
||||
{ path: "dispatch_order_at", label: "Приказ на выезд", kind: "text", readOnly: true },
|
||||
{ path: "arrival_at", label: "Прибытие", kind: "text", readOnly: true },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -105,6 +105,23 @@ export interface Profile {
|
|||
hints_total: number;
|
||||
}
|
||||
|
||||
/** Значения следующего признака происшествия при уже выбранных (ЕКП). */
|
||||
export interface SignsPage {
|
||||
level: number;
|
||||
values: string[];
|
||||
incident: { code: string; type: string; notify: string[] } | null;
|
||||
}
|
||||
|
||||
export function useSigns(prefix: string[]) {
|
||||
const query = prefix.map((sign) => `prefix=${encodeURIComponent(sign)}`).join("&");
|
||||
return useQuery({
|
||||
queryKey: ["ekp-signs", prefix],
|
||||
// Справочник не меняется между занятиями — перезапрашивать незачем.
|
||||
staleTime: Infinity,
|
||||
queryFn: () => request<SignsPage>(`/api/ekp/signs${query ? `?${query}` : ""}`),
|
||||
});
|
||||
}
|
||||
|
||||
export const useTrainees = () =>
|
||||
useQuery({ queryKey: ["trainees"], queryFn: () => request<TraineeInfo[]>("/api/trainees") });
|
||||
|
||||
|
|
|
|||
|
|
@ -218,12 +218,18 @@ export interface KIO {
|
|||
floor?: string | null;
|
||||
intercom_code?: string | null;
|
||||
coords?: Coords | null;
|
||||
signs?: Array<string>;
|
||||
/** Код ЕКП по признакам */
|
||||
incident_code?: string | null;
|
||||
incident_type?: IncidentType | null;
|
||||
description?: string | null;
|
||||
victims_count?: number | null;
|
||||
is_emergency?: boolean;
|
||||
life_threat?: boolean;
|
||||
evacuation_needed?: boolean;
|
||||
/** Список оповещения */
|
||||
notify?: Array<string>;
|
||||
/** Устаревшее: одна служба. Заменено списком оповещения */
|
||||
dds?: DDSCode | null;
|
||||
dispatch_order_at?: string | null;
|
||||
arrival_at?: string | null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue