Merge branch 'feat/dds-ack-limit'
lct-11: срок решения на пульте ДДС из снимка сервера
This commit is contained in:
commit
02c86e457d
8 changed files with 134 additions and 21 deletions
|
|
@ -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":
|
||||||
# Курсант закрывает вызов не карточкой. Отдельное действие, а не
|
# Курсант закрывает вызов не карточкой. Отдельное действие, а не
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
44
backend/tests/test_station_ticker.py
Normal file
44
backend/tests/test_station_ticker.py
Normal 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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
33
frontend/scripts/test-dds-ack.mjs
Normal file
33
frontend/scripts/test-dds-ack.mjs
Normal 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.");
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
9
frontend/src/pages/dds/ack.d.mts
Normal file
9
frontend/src/pages/dds/ack.d.mts
Normal 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;
|
||||||
31
frontend/src/pages/dds/ack.mjs
Normal file
31
frontend/src/pages/dds/ack.mjs
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue