feat: реагирование смежных служб в карточке ДДС и вводная «бригада просит смежников»

This commit is contained in:
gglamer 2026-09-28 18:02:03 +00:00
commit 6d3ebb72c8
19 changed files with 1139 additions and 21 deletions

View file

@ -33,6 +33,8 @@ const DDS_METRICS: MetricOption[] = [
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
{ key: "dds_work_time", label: "Первая запись", hint: "Первый статус с текстом в пределах лимита после открытия карточки." },
{ key: "dds_report_reaction", label: "Реакция на доклад бригады", hint: "Статус по докладу бригады не позже лимита занятия." },
{ key: "dds_adjacent_check", label: "Сверка перед ответом бригаде", hint: "Ответ на запрос бригады о смежной службе сверен с журналом карточки." },
{ key: "dds_adjacent_call", label: "Звонок молчащей смежной службе", hint: "Связь со смежной службой и ответ бригаде не позже лимита вводной." },
];
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [

View file

@ -17,7 +17,10 @@ const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }
type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
interface PhoneLine { service: string; crew: string; speaker: "dispatcher" | "crew"; text: string; at: string }
interface PhonePending { service: string; crew: string; phase: ReportPhase }
interface PhonePending {
service: string; crew: string; phase: string;
incoming?: boolean; adjacent_service?: string | null;
}
interface CompletedCard {
card_id: string; scenario_id: string; score_auto: number; score_final?: number;
session_id?: string; ended_at?: string; title?: string | null; address?: string | null;
@ -236,6 +239,9 @@ export function Dds() {
const [dialing, setDialing] = useState(false);
const [brief, setBrief] = useState({ address: "", incident: "", request: "" });
const [checkText, setCheckText] = useState("");
const [crewReplyText, setCrewReplyText] = useState("");
const [adjacentTarget, setAdjacentTarget] = useState("");
const [adjacentText, setAdjacentText] = useState("");
const [error, setError] = useState("");
const [ended, setEnded] = useState(false);
const [paused, setPaused] = useState(false);
@ -286,6 +292,9 @@ export function Dds() {
setDialing(false);
setBrief({ address: "", incident: "", request: "" });
setCheckText("");
setCrewReplyText("");
setAdjacentTarget("");
setAdjacentText("");
setZone(null);
setScoreReady(false);
setView(openCardAfterSwitch.current ? "card" : "registry");
@ -334,7 +343,15 @@ export function Dds() {
const latest = (lastReport?.crew === selectedCrew ? lastReport : null) ??
reports.filter((item) => item.crew === selectedCrew).at(-1) ?? null;
const phoneCall = station?.phone_pending ?? null;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === selectedCrew);
// Лента разговора следует за активным вызовом: входящий от бригады может
// прийти и без выбранной в селекторе бригады.
const activeCallCrew = phoneCall?.crew ?? selectedCrew;
const phoneLines = (station?.phone_lines ?? []).filter((line) => line.crew === activeCallCrew);
// Смежные службы: получатели карточки, которыми курсант не управляет.
const adjacentServices = (station?.recipient_services ?? [])
.filter((service) => !(station?.services ?? []).includes(service));
const adjacentService = adjacentTarget || phoneCall?.adjacent_service || adjacentServices[0] || "";
const adjacentLines = (station?.phone_lines ?? []).filter((line) => line.crew.endsWith("— диспетчер"));
const isConnected = status === "open";
// Вверх: короткая пауза не должна выглядеть как «0 с», а без пауз строки нет.
const pausedSeconds = Math.ceil((report?.total_paused_ms ?? 0) / 1000);
@ -378,6 +395,10 @@ export function Dds() {
АРМ {role}: {isConnected ? "на связи" : status}
</span>
{error && <div className="dds-alert" role="alert">Действие не принято: {error}</div>}
{phoneCall?.incoming && pane !== "phone" && !ended && <div className="dds-alert" role="alert">
Входящий вызов бригады {phoneCall.crew}.{" "}
<button type="button" onClick={() => { setView("card"); setPane("phone"); }}>Ответить</button>
</div>}
{paused && !ended && <div className="dds-alert dds-paused">Пауза, ждите преподавателя</div>}
{ended && <div className="dds-alert dds-done">Занятие завершено. Оценка появится после расчёта.</div>}
{(ended || scoreReady) && <section className="dds-panel" aria-live="polite">
@ -630,7 +651,22 @@ export function Dds() {
onClick={() => { if (send({ type: "phone.dial" })) setDialing(true); }}>
☎ {dialing ? "Соединение…" : "Позвонить старшему группы"}</button>
{selectedCrew && !crewAccepted && <p className="dds-hint state-warn">Перед звонком поставьте статус «Принята» для службы этой бригады.</p>}
{phoneCall && <div className="dds-phone-active" aria-live="polite">
{phoneCall?.incoming && <div className="dds-phone-active" aria-live="polite">
<strong>● Входящий вызов · {phoneCall.crew}</strong>
<div className="dds-phone-form">
<p>Бригада спрашивает о смежной службе{phoneCall.adjacent_service ? ` «${phoneCall.adjacent_service}»` : ""}.
Сверьтесь с журналом карточки: если служба реагирует — передайте это бригаде,
если молчит — сообщите, что уточните, и позвоните её диспетчеру.</p>
<label htmlFor="dds-phone-reply">Ваш ответ бригаде</label>
<textarea id="dds-phone-reply" rows={2} value={crewReplyText}
onChange={(event) => setCrewReplyText(event.target.value)}
placeholder="Служба реагирует, бригада направлена — или: уточню по карточке и свяжусь со службой" />
<button type="button" disabled={!isConnected || ended || crewReplyText.trim().length < 8}
onClick={() => { if (send({ type: "phone.reply", text: crewReplyText })) setCrewReplyText(""); }}>
Ответить бригаде</button>
</div>
</div>}
{phoneCall && !phoneCall.incoming && <div className="dds-phone-active" aria-live="polite">
<strong>● Соединение установлено · {phoneCall.crew}</strong>
{phoneCall.phase === "dispatched" ? <div className="dds-phone-form">
<p>Передайте сведения из карточки и задачу бригаде. Доклад о выезде появится только после передачи.</p>
@ -661,6 +697,28 @@ export function Dds() {
{reports.length > 0 && <details className="dds-details"><summary>История докладов ({reports.length})</summary>
<ol className="dds-reports">{reports.map((report, index) => <li key={`${report.at}-${index}`}><b>{REPORT_LABELS[report.phase]}</b> · {time(report.at)}<br />{report.text}</li>)}</ol>
</details>}
{adjacentServices.length > 0 && <>
<h3 className="dds-protocol-title">Смежные службы</h3>
<p className="dds-hint">Реагирование смежных служб видно в журнале и на кнопках служб.
Если нужная служба в карточке молчит — позвоните её диспетчеру.</p>
<label htmlFor="dds-adjacent-service">Смежная служба</label>
<select id="dds-adjacent-service" value={adjacentService}
disabled={!isConnected || ended}
onChange={(event) => setAdjacentTarget(event.target.value)}>
{adjacentServices.map((service) => <option key={service} value={service}>{serviceTitle(service)}</option>)}
</select>
<label htmlFor="dds-adjacent-text">Запрос диспетчеру службы</label>
<textarea id="dds-adjacent-text" rows={2} value={adjacentText}
onChange={(event) => setAdjacentText(event.target.value)}
placeholder="Прошу перекрыть газовый ввод по карточке, работает бригада" />
<button type="button" className="dds-dial"
disabled={!isConnected || ended || Boolean(phoneCall) || !adjacentService || adjacentText.trim().length < 8}
onClick={() => { if (send({ type: "adjacent.dial", service: adjacentService, text: adjacentText })) setAdjacentText(""); }}>
☎ Позвонить диспетчеру службы</button>
{adjacentLines.length > 0 && <div className="dds-phone-transcript" aria-live="polite"><strong>Разговоры со смежными службами</strong>
{adjacentLines.map((line, index) => <p key={`${line.at}-${index}`}><small>{time(line.at)} · {line.speaker === "crew" ? line.crew : "Диспетчер ДДС"}</small><br />{line.text}</p>)}
</div>}
</>}
</section>}
{pane === "reply" && <section className="dds-panel dds-reply">
<div className="dds-panel-title"><h2>Рабочая заметка диспетчера</h2>
@ -709,7 +767,8 @@ export function Dds() {
})}
</div>
<div className="dds-service-tools" role="group" aria-label="Действия диспетчера">
<button type="button" onClick={() => setPane("phone")}><b>☎ Связь</b><small>Бригада</small></button>
<button type="button" onClick={() => setPane("phone")}>
<b>☎ Связь</b><small>{phoneCall?.incoming ? "Входящий!" : "Бригада"}</small></button>
<button type="button" onClick={() => setPane("reply")}><b>✎ Ответ</b><small>{reply.dirty ? "Не сохранён" : "Комментарий"}</small></button>
<button type="button" onClick={() => setPane("journal")}><b>Журнал</b><small>Статусы</small></button>
<button type="button" disabled={!cardId || !isConnected || ended || advancePending}

View file

@ -95,6 +95,7 @@ export function Instructor() {
const [ddsCardWorkTimeLimit, setDdsCardWorkTimeLimit] = useState(180);
const [ddsReportReactionEnabled, setDdsReportReactionEnabled] = useState(false);
const [ddsReportReactionLimit, setDdsReportReactionLimit] = useState(45);
const [ddsAdjacentLimit, setDdsAdjacentLimit] = useState(90);
const [allowedErrors, setAllowedErrors] = useState(0);
const [requireCorrectGrammar, setRequireCorrectGrammar] = useState(true);
const [lessonScoreWeights, setLessonScoreWeights] = useState<Record<string, number>>({});
@ -312,6 +313,7 @@ export function Instructor() {
dds_card_work_time_limit_seconds: ddsCardWorkTimeLimit,
dds_report_reaction_enabled: reportReactionEnabled,
dds_report_reaction_limit_seconds: ddsReportReactionLimit,
dds_adjacent_limit_seconds: ddsAdjacentLimit,
allowed_errors: allowedErrors,
require_correct_grammar: requireCorrectGrammar,
score_weights: reportReactionEnabled
@ -321,7 +323,7 @@ export function Instructor() {
});
if (sent) toStart.current = null;
}, [sessionId, control, scenarioId, extraScenarioIds, mode, trainee, traineeId, trainees.data,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
exercise, decisionTimeLimit, cardFillTimeLimit, ddsCardWorkTimeLimit, ddsReportReactionEnabled, ddsReportReactionLimit, ddsAdjacentLimit, allowedErrors, requireCorrectGrammar, wholeTicket, selectedTicketScenarios,
ddsArrivalIntervalSeconds, ddsMaxWaiting, lessonScoreWeights, scenarios.data,
randomScenario, visibleScenarios]);
@ -682,6 +684,13 @@ export function Instructor() {
onCommit={setDdsReportReactionLimit} />
<small className="ref">Учебный лимит при включённом критерии; исходное значение — 45 секунд.</small>
</label>}
<label className="lesson-criterion">
Лимит вводной «бригада просит смежников», секунд
<LessonNumberInput label="Лимит вводной о смежной службе, секунд"
value={ddsAdjacentLimit} min={5} max={600} fallback={90}
onCommit={setDdsAdjacentLimit} />
<small className="ref">Действует в сценариях с вводной: за это время нужно ответить бригаде, а при молчании смежной службы — позвонить ей.</small>
</label>
</>}
<label className="lesson-criterion">
Допустимо нарушенных метрик

View file

@ -14,6 +14,13 @@ export interface AIRecommendation {
text: string;
}
/** Звонок диспетчеру смежной службы из списка оповещения карточки. Один обмен: запрос курсанта и скриптовый ответ смежной службы; её статус после звонка появляется в журнале карточки. */
export interface AdjacentDial {
type: "adjacent.dial";
service: string;
text: string;
}
/** Аудио-фон происшествия. Файл лежит в сборке фронта, трафика фон не создаёт. */
export interface BgStart {
type: "bg.start";
@ -403,6 +410,7 @@ export interface LessonCriteria {
dds_card_work_time_limit_seconds?: number;
dds_report_reaction_enabled?: boolean;
dds_report_reaction_limit_seconds?: number;
dds_adjacent_limit_seconds?: number;
allowed_errors?: number;
require_correct_grammar?: boolean;
score_weights?: Record<string, number>;
@ -462,6 +470,8 @@ export interface PhoneCallPending {
service: string;
crew: string;
phase: string;
incoming?: boolean;
adjacent_service?: string | null;
}
/** Запрос диспетчера об обстановке после предыдущего доклада. */
@ -495,6 +505,12 @@ export interface PhoneLineRecord {
at: string;
}
/** Ответ диспетчера на входящий вызов бригады (вводная «бригада просит смежников»). Свободный текст проверяется кодом по основам слов. */
export interface PhoneReply {
type: "phone.reply";
text: string;
}
export interface PhoneReport {
type: "phone.report";
service: string;
@ -831,10 +847,11 @@ export type InstructorToServer = ScenarioStart | DirectorInject | ReferencePlay
export type ServerToStation = CardReceived | StationState | PhoneLine | PhoneReport | TimerTick | SessionEnded | ScoreReady | CommandAck | SessionPaused | ErrorEvent;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
export type StationToServer = CardAck | CardBounce | ServiceStatusSet | CrewSelect | PhoneDial | PhoneBrief | PhoneCheck | PhoneHangup | PhoneReply | AdjacentDial | StationFinish | CardReply | CardNext | CardOpen | ZoneDecision | CrewDispatched | CrewArrived;
export type EventName =
"bg.start"
"adjacent.dial"
| "bg.start"
| "bg.stop"
| "call.answer"
| "call.ended"
@ -873,6 +890,7 @@ export type EventName =
| "phone.dial"
| "phone.hangup"
| "phone.line"
| "phone.reply"
| "phone.report"
| "reference.play"
| "reference.started"