Merge branch 'feat/dds-ack-limit'

lct-11: срок решения на пульте ДДС из снимка сервера
This commit is contained in:
GGlamer 2026-09-26 23:01:15 +03:00 • committed by GitHub
commit 02c86e457d
8 changed files with 134 additions and 21 deletions

View file

@ -337,7 +337,9 @@ async def _handle(session_id: UUID, state, event) -> None:
# первого своего действия: отмечаться ему по нему же (lct-33). # первого своего действия: отмечаться ему по нему же (lct-33).
hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
hub.to_observers(session_id, KioState(kio=state.kio)) hub.to_observers(session_id, KioState(kio=state.kio))
hub.to_observers(session_id, TimerTick(timers=state.timers.snapshot())) tick = TimerTick(timers=state.timers.snapshot())
hub.to_observers(session_id, tick)
hub.to_station(session_id, tick)
case "call.resolve": case "call.resolve":
# Курсант закрывает вызов не карточкой. Отдельное действие, а не # Курсант закрывает вызов не карточкой. Отдельное действие, а не

View file

@ -328,7 +328,11 @@ class SessionHub:
# Keep the pending count and countdown live even while # Keep the pending count and countdown live even while
# the active dispatcher card is being handled. # the active dispatcher card is being handled.
self.to_station(session_id, StationState(snapshot=state.station_snapshot())) self.to_station(session_id, StationState(snapshot=state.station_snapshot()))
self.broadcast(session_id, TimerTick(timers=state.timers.snapshot())) tick = TimerTick(timers=state.timers.snapshot())
self.broadcast(session_id, tick)
# Срок первичного решения пульт берёт отсюда: после передачи
# из 112 без очереди ДДС лимит преподавателя больше нигде не виден.
self.to_station(session_id, tick)
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise

View file

@ -0,0 +1,44 @@
"""Такт таймеров доходит до АРМ ДДС: пульт строит срок решения по снимку сервера."""
import asyncio
from pathlib import Path
from uuid import uuid4
from app.domain.events import Exercise, SessionMode, TimerTick
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session import hub as hub_module
from app.session.hub import SessionHub
from app.session.state import SessionState
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
async def test_station_receives_dds_ack_with_configured_limit_after_handoff(monkeypatch):
"""Передача из 112 без очереди ДДС: `queue_cards` пуст, и лимит решения,
настроенный преподавателем, пульт видит только в `timer.tick`."""
monkeypatch.setattr(hub_module, "TICK_SECONDS", 0.01)
scenario = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=scenario.id, scenario_title=scenario.title,
level=scenario.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CARD, handoff_to_dds=True, scenario=scenario,
)
state.timers.limits[TimerCode.DDS_ACK] = 45_000
state.on_event("dds.dispatch")
state.dispatch()
local_hub = SessionHub()
local_hub.register(state)
with local_hub.station(state.session_id) as station:
local_hub.start_ticker(state.session_id)
try:
async with asyncio.timeout(1):
while not isinstance(event := await station.get(), TimerTick):
pass
finally:
local_hub.stop_ticker(state.session_id)
ack = next(timer for timer in event.timers if timer.code is TimerCode.DDS_ACK)
assert ack.limit_ms == 45_000
assert not ack.stopped

View file

@ -11,7 +11,8 @@
"check:kio": "node scripts/check-kio-fields.mjs", "check:kio": "node scripts/check-kio-fields.mjs",
"check:a11y": "node scripts/check-accessible-controls.mjs", "check:a11y": "node scripts/check-accessible-controls.mjs",
"test:ws-outbox": "node scripts/test-ws-outbox.mjs", "test:ws-outbox": "node scripts/test-ws-outbox.mjs",
"test:dds-history": "node scripts/test-dds-history.mjs" "test:dds-history": "node scripts/test-dds-history.mjs",
"test:dds-ack": "node scripts/test-dds-ack.mjs"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.103.1", "@tanstack/react-query": "^5.103.1",

View file

@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { ackDeadline } from "../src/pages/dds/ack.mjs";
const queueCard = (overrides) => ({ card_id: "c1", elapsed_ms: 12_000, limit_ms: 45_000,
timer_stopped: false, active: true, ...overrides });
const ackTimer = (overrides) => ({ code: "dds_ack", elapsed_ms: 12_000, limit_ms: 45_000,
state: "ok", stopped: false, ...overrides });
assert.equal(ackDeadline([], []), null, "до передачи карточки отсчёта нет");
assert.deepEqual(ackDeadline([], [queueCard({ active: false }), queueCard({ card_id: "c2", elapsed_ms: 3_000 })]),
{ elapsedMs: 3_000, limitMs: 45_000, violated: false },
"срок берётся у активной карточки очереди, а не у первой");
assert.deepEqual(ackDeadline([ackTimer({ elapsed_ms: 99_000, limit_ms: 30_000 })], [queueCard()]),
{ elapsedMs: 12_000, limitMs: 45_000, violated: false },
"снимок активной карточки важнее timer.tick, который мог остаться от прошлой карточки");
assert.deepEqual(ackDeadline([ackTimer()], []),
{ elapsedMs: 12_000, limitMs: 45_000, violated: false },
"без очереди (передача из 112) — таймер dds_ack из timer.tick");
assert.equal(ackDeadline([ackTimer({ elapsed_ms: 45_000 })], []).violated, false,
"ровно на лимите нарушения нет — как state_for на сервере");
assert.equal(ackDeadline([ackTimer({ elapsed_ms: 45_001 })], []).violated, true);
assert.equal(ackDeadline([], [queueCard({ elapsed_ms: 45_001 })]).violated, true);
assert.equal(ackDeadline([ackTimer({ stopped: true })], []), null, "сервер остановил таймер — отсчёт скрыт");
assert.equal(ackDeadline([ackTimer()], [queueCard({ timer_stopped: true })]), null);
assert.equal(ackDeadline([ackTimer({ code: "dds_work" })], []), null, "чужой таймер не подменяет dds_ack");
console.log("DDS ack deadline follows server snapshots: limit, violation and stop.");

View file

@ -5,13 +5,13 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { KioCard } from "@/features/kio-card/KioCard"; import { KioCard } from "@/features/kio-card/KioCard";
import { Timers } from "@/features/timers/Timers"; import { Timers } from "@/features/timers/Timers";
import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge"; import { applyState, empty as emptyCard, readPath, type CardState } from "@/features/kio-card/merge";
import { ackDeadline } from "./ack.mjs";
import { filterSortHistory } from "./history.mjs"; import { filterSortHistory } from "./history.mjs";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import { stationChannel, type ChannelStatus } from "@/shared/api/ws"; import { stationChannel, type ChannelStatus } from "@/shared/api/ws";
import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer, TimerSnapshot } from "@/shared/types/generated"; import type { DdsQueueCard, ServiceStatus, SessionReport, StationSnapshot, StationToServer, TimerSnapshot } from "@/shared/types/generated";
import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses"; import { ALARMING, CARD_STATUS_LABELS, SERVICE_STATUS_LABELS } from "./statuses";
const ACK_LIMIT_S = 30;
const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }) => ({ default: SipPhone }))); const SipPhone = lazy(() => import("@/features/sip/SipPhone").then(({ SipPhone }) => ({ default: SipPhone })));
type ReportPhase = "dispatched" | "arrived" | "working" | "completed"; type ReportPhase = "dispatched" | "arrived" | "working" | "completed";
interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string } interface PhoneReport { service: string; crew: string; phase: ReportPhase; text: string; at: string }
@ -224,7 +224,6 @@ export function Dds() {
const [card, setCard] = useState<CardState | null>(null); const [card, setCard] = useState<CardState | null>(null);
const [from, setFrom] = useState(""); const [from, setFrom] = useState("");
const [receivedAt, setReceivedAt] = useState<number | null>(null); const [receivedAt, setReceivedAt] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [station, setStation] = useState<DdsSnapshot | null>(null); const [station, setStation] = useState<DdsSnapshot | null>(null);
const [timers, setTimers] = useState<TimerSnapshot[]>([]); const [timers, setTimers] = useState<TimerSnapshot[]>([]);
const [lastReport, setLastReport] = useState<PhoneReport | null>(null); const [lastReport, setLastReport] = useState<PhoneReport | null>(null);
@ -307,6 +306,7 @@ export function Dds() {
openCardAfterSwitch.current = false; openCardAfterSwitch.current = false;
setPane(null); setPane(null);
setActiveService(null); setActiveService(null);
setTimers([]);
} }
setCard(applyState(emptyCard, { ...event.card })); setCard(applyState(emptyCard, { ...event.card }));
setFrom(event.from_operator); setFrom(event.from_operator);
@ -334,15 +334,6 @@ export function Dds() {
return () => { controller.abort(); ch.close(); channel.current = null; }; return () => { controller.abort(); ch.close(); channel.current = null; };
}, [sessionId, role]); }, [sessionId, role]);
const primaryRecorded = Boolean(station?.services.length && station.services.every((service) =>
["accepted", "declined", "responding", "arrived", "working", "completed", "refused"]
.includes(station.statuses[service])));
useEffect(() => {
if (receivedAt === null || primaryRecorded) return;
const timer = setInterval(() => setElapsed((Date.now() - receivedAt) / 1000), 200);
return () => clearInterval(timer);
}, [receivedAt, primaryRecorded]);
useEffect(() => { useEffect(() => {
const timer = setInterval(() => setClockNow(new Date()), 1000); const timer = setInterval(() => setClockNow(new Date()), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
@ -366,6 +357,7 @@ export function Dds() {
if (!completedCards.some((item) => item.card_id === archived.card_id)) completedCards.push(archived); if (!completedCards.some((item) => item.card_id === archived.card_id)) completedCards.push(archived);
} }
const queueCards = station?.queue_cards ?? []; const queueCards = station?.queue_cards ?? [];
const ack = ackDeadline(timers, queueCards);
const query = search.trim().toLocaleLowerCase("ru-RU"); const query = search.trim().toLocaleLowerCase("ru-RU");
const filteredCompletedCards = statusFilter === "pending" ? [] : completedCards.filter((item) => { const filteredCompletedCards = statusFilter === "pending" ? [] : completedCards.filter((item) => {
const serviceMatches = serviceFilter === "all" || item.managed_service === serviceFilter; const serviceMatches = serviceFilter === "all" || item.managed_service === serviceFilter;
@ -497,9 +489,6 @@ export function Dds() {
</tr>)} </tr>)}
{visibleQueueCards.map((item) => { {visibleQueueCards.map((item) => {
const received = new Date(item.received_at); const received = new Date(item.received_at);
const liveElapsed = item.timer_stopped ? item.elapsed_ms / 1000 : Math.max(
item.elapsed_ms / 1000, (clockNow.getTime() - received.getTime()) / 1000,
);
const openCard = () => { const openCard = () => {
if (item.active) { if (item.active) {
send({ type: "card.open", card_id: item.card_id }); send({ type: "card.open", card_id: item.card_id });
@ -527,8 +516,8 @@ export function Dds() {
<td data-label="Адрес">{item.address || "—"}</td> <td data-label="Адрес">{item.address || "—"}</td>
<td data-label="Статус службы"><span className="dds-cell-value"> <td data-label="Статус службы"><span className="dds-cell-value">
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span> <span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
<small className={!item.timer_stopped && liveElapsed * 1000 > item.limit_ms ? "state-violated" : ""}> <small className={!item.timer_stopped && item.elapsed_ms > item.limit_ms ? "state-violated" : ""}>
{item.timer_stopped ? "решение за" : "до первичного решения"} {liveElapsed.toFixed(0)} с {item.timer_stopped ? "решение за" : "до первичного решения"} {Math.ceil(item.elapsed_ms / 1000)} с
</small> </small>
</span></td> </span></td>
</tr>})} </tr>})}
@ -612,8 +601,8 @@ export function Dds() {
{pane === "status" && <section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2> {pane === "status" && <section className="dds-panel"><div className="dds-panel-title"><h2>Реагирование служб</h2>
<span className={station && ALARMING.includes(station.card) ? "state-violated" : "state-ok"}> <span className={station && ALARMING.includes(station.card) ? "state-violated" : "state-ok"}>
{station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div> {station ? CARD_STATUS_LABELS[station.card] : "—"}</span></div>
{!primaryRecorded && receivedAt !== null && <div className={`dds-deadline ${elapsed > ACK_LIMIT_S ? "state-violated" : "state-ok"}`}> {ack && <div className={`dds-deadline ${ack.violated ? "state-violated" : "state-ok"}`}>
Первичное решение «Принята» / «Не принята»: {elapsed.toFixed(0)} из {ACK_LIMIT_S} секунд</div>} Первичное решение «Принята» / «Не принята»: {Math.ceil(ack.elapsedMs / 1000)} из {Math.ceil(ack.limitMs / 1000)} секунд</div>}
<Timers timers={timers.filter((timer) => timer.code === "dds_ack" || timer.code === "dds_work")} /> <Timers timers={timers.filter((timer) => timer.code === "dds_ack" || timer.code === "dds_work")} />
{station && station.services.length > 0 ? <> {station && station.services.length > 0 ? <>
<div className="dds-status-history"><strong>Служба {activeService ?? station.services[0]}</strong> <div className="dds-status-history"><strong>Служба {activeService ?? station.services[0]}</strong>

View file

@ -0,0 +1,9 @@
import type { DdsQueueCard, TimerSnapshot } from "@/shared/types/generated";
export interface AckDeadline {
elapsedMs: number;
limitMs: number;
violated: boolean;
}
export function ackDeadline(timers: TimerSnapshot[], queueCards: DdsQueueCard[]): AckDeadline | null;

View file

@ -0,0 +1,31 @@
/**
* @typedef {Object} AckDeadline
* @property {number} elapsedMs
* @property {number} limitMs
* @property {boolean} violated
*/
/**
* Срок первичного решения «Принята» / «Не принята» только по снимкам сервера.
*
* Лимит настраивает преподаватель, остановку фиксирует сервер на первом статусе
* из `PRIMARY`: своя константа или свой список статусов на пульте расходятся
* с тем, по чему ставится оценка. Активная карточка очереди важнее `timer.tick` —
* после переключения карточки тик до следующей секунды ещё несёт таймер прежней.
*
* @param {Array<{code: string, elapsed_ms: number, limit_ms: number, stopped?: boolean}>} timers
* @param {Array<{elapsed_ms: number, limit_ms: number, timer_stopped: boolean, active: boolean}>} queueCards
* @returns {AckDeadline | null} null — отсчёт не показывать
*/
export function ackDeadline(timers, queueCards) {
const active = queueCards.find((item) => item.active);
if (active) return active.timer_stopped ? null : deadline(active.elapsed_ms, active.limit_ms);
const timer = timers.find((item) => item.code === "dds_ack");
if (!timer || timer.stopped) return null;
return deadline(timer.elapsed_ms, timer.limit_ms);
}
/** Граница как у `state_for` на сервере: ровно на лимите нарушения ещё нет. */
function deadline(elapsedMs, limitMs) {
return { elapsedMs, limitMs, violated: elapsedMs > limitMs };
}