refactor: пульт ДДС владеет живыми карточками, сессия не копирует их поля

Связка 112→ДДС больше не подменяет таймеры оператора таймерами первой
карточки ДДС, поэтому время заполнения карточки 112 снова попадает в оценку.
This commit is contained in:
gglamer 2026-09-26 21:38:09 +00:00
commit 5a3761a830
24 changed files with 2093 additions and 762 deletions

View file

@ -228,11 +228,13 @@ async def active(
if state.started_at else 0) if state.started_at else 0)
station = state.station_snapshot() if state.exercise is Exercise.DDS else None station = state.station_snapshot() if state.exercise is Exercise.DDS else None
queue = station.queue_cards if station else [] queue = station.queue_cards if station else []
card = state.desk.active
status_log = card.status_log if card is not None else []
managed_services = state.managed_services() managed_services = state.managed_services()
latest_statuses = { latest_statuses = {
service: SERVICE_STATUS_LABELS[current(state.status_log, service)] service: SERVICE_STATUS_LABELS[current(status_log, service)]
for service in managed_services for service in managed_services
if (state.status_log or state.exercise is Exercise.DDS) if (status_log or state.exercise is Exercise.DDS)
} }
result.append(ActiveSessionOut( result.append(ActiveSessionOut(
session_id=state.session_id, session_id=state.session_id,
@ -243,7 +245,7 @@ async def active(
exercise=state.exercise, exercise=state.exercise,
started_at=state.started_at, started_at=state.started_at,
elapsed_seconds=elapsed, elapsed_seconds=elapsed,
dds_card_total=len(state.dds_scenarios), dds_card_total=len(state.desk.scenarios),
dds_open_cards=len(queue), dds_open_cards=len(queue),
dds_overdue_cards=sum( dds_overdue_cards=sum(
not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue not card.timer_stopped and card.elapsed_ms > card.limit_ms for card in queue
@ -253,7 +255,7 @@ async def active(
and timer.started_at is not None and timer.started_at is not None
and not timer.stopped and not timer.stopped
and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK] and timer.current_ms(time.monotonic()) > card.timers.limits[TimerCode.DDS_WORK]
for card in state.dds_live_cards for card in state.desk.cards.values()
), ),
dds_statuses=latest_statuses, dds_statuses=latest_statuses,
dds_snapshot=station, dds_snapshot=station,

View file

@ -244,8 +244,8 @@ async def _handle(session_id: UUID, state, event) -> None:
prepare_handoff_queue( prepare_handoff_queue(
state, state,
state.pending_dds_scenarios, state.pending_dds_scenarios,
arrival_interval_seconds=state.dds_arrival_interval_seconds, arrival_interval_seconds=state.desk.arrival_interval_seconds,
max_waiting=state.dds_max_waiting, max_waiting=state.desk.max_waiting,
) )
state.pending_dds_scenarios = [] state.pending_dds_scenarios = []
else: else:

View file

@ -272,14 +272,13 @@ async def _start(session_id: UUID, event, who=None) -> None:
use_pregenerated=scenario.tree.pregenerated, use_pregenerated=scenario.tree.pregenerated,
) )
elif event.exercise is Exercise.DDS: elif event.exercise is Exercise.DDS:
state.dds_scenarios = [item.model_copy(deep=True) for item in scenarios]
prepare_queue( prepare_queue(
state, state,
state.dds_scenarios, [item.model_copy(deep=True) for item in scenarios],
arrival_interval_seconds=event.dds_arrival_interval_seconds, arrival_interval_seconds=event.dds_arrival_interval_seconds,
max_waiting=event.dds_max_waiting, max_waiting=event.dds_max_waiting,
) )
state.started_at = state.dispatched_at state.started_at = state.desk.active.dispatched_at if state.desk.active else None
else: else:
state.started_at = now_utc() state.started_at = now_utc()
if event.exercise is Exercise.CARD: if event.exercise is Exercise.CARD:
@ -293,8 +292,8 @@ async def _start(session_id: UUID, event, who=None) -> None:
state.on_event("card.start") state.on_event("card.start")
if event.handoff_to_dds: if event.handoff_to_dds:
state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]] state.pending_dds_scenarios = [item.model_copy(deep=True) for item in scenarios[1:]]
state.dds_arrival_interval_seconds = event.dds_arrival_interval_seconds state.desk.arrival_interval_seconds = event.dds_arrival_interval_seconds
state.dds_max_waiting = event.dds_max_waiting state.desk.max_waiting = event.dds_max_waiting
hub.register(state) hub.register(state)
if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None: if event.exercise is not Exercise.CALL and hub.journal and state.started_at is not None:
await hub.journal.session_started(session_id, state.started_at) await hub.journal.session_started(session_id, state.started_at)

View file

@ -43,8 +43,8 @@ from app.domain.statuses import (
) )
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scoring.address import address_matches from app.scoring.address import address_matches
from app.session.dds import deliver_due_cards from app.session.dds import DdsLiveCard
from app.session.finish import finish, score_current_dds from app.session.finish import finish, score_dds_card
from app.session.hub import LEASE_FENCED_MESSAGE, hub from app.session.hub import LEASE_FENCED_MESSAGE, hub
from app.session.state import now_utc from app.session.state import now_utc
@ -54,11 +54,15 @@ router = APIRouter()
_adapter = TypeAdapter(StationToServer) _adapter = TypeAdapter(StationToServer)
def _start_dds_work_timer(state) -> None: def _start_dds_work_timer(card: DdsLiveCard) -> None:
"""Start the three-minute work clock once, when the card is opened.""" """Start the three-minute work clock once, when the card is opened."""
timer = state.timers.timers.get(TimerCode.DDS_WORK) timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is None or timer.started_at is None: if timer is None or timer.started_at is None:
state.on_event("dds.open") card.on_event("dds.open")
#: Команды, которым не нужна активная карточка пульта.
CARDLESS_EVENTS = {"card.open", "station.finish", "card.bounce"}
REPORT_PHASES = ("dispatched", "arrived", "working", "completed") REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REQUIRED_STATUS = { REQUIRED_STATUS = {
@ -78,14 +82,14 @@ def _error(session_id: UUID, message: str) -> None:
hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message)) hub.to_station(session_id, ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=message))
def _line(session_id: UUID, state, speaker: str, text: str) -> None: def _line(session_id: UUID, card: DdsLiveCard, speaker: str, text: str) -> None:
call = state.phone_pending call = card.phone_pending
if call is None: if call is None:
return return
line = PhoneLineRecord(service=call.service, crew=call.crew, line = PhoneLineRecord(service=call.service, crew=call.crew,
speaker=speaker, text=text, at=now_utc()) speaker=speaker, text=text, at=now_utc())
state.phone_lines.append(line) card.phone_lines.append(line)
state.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}")) card.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
hub.to_station(session_id, PhoneLine(**line.model_dump())) hub.to_station(session_id, PhoneLine(**line.model_dump()))
@ -94,11 +98,11 @@ def _address_matches(expected: str | None, supplied: str) -> bool:
return address_matches(expected, supplied) return address_matches(expected, supplied)
def _incident_matches(state, supplied: str) -> bool: def _incident_matches(card: DdsLiveCard, supplied: str) -> bool:
text = supplied.casefold() text = supplied.casefold()
# Описание часто начинается с адреса: его нельзя считать совпадением # Описание часто начинается с адреса: его нельзя считать совпадением
# характера происшествия. Берём только название сценария и признаки ЕКП. # характера происшествия. Берём только название сценария и признаки ЕКП.
source = " ".join((state.scenario_title, " ".join(state.kio.signs))) source = " ".join((card.scenario.title, " ".join(card.kio.signs)))
anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())} anchors = {word[:4] for word in re.findall(r"[а-яё]{5,}", source.casefold())}
return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors) return len(supplied.strip()) >= 8 and any(anchor in text for anchor in anchors)
@ -120,24 +124,23 @@ def _report_text(phase: str, crew: str, address: str) -> str:
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется." return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
def _finish_phone_call(session_id: UUID, state) -> None: def _finish_phone_call(session_id: UUID, card: DdsLiveCard) -> None:
call = state.phone_pending call = card.phone_pending
assert call is not None assert call is not None
text = _report_text(call.phase, call.crew, state.dispatched_card.address or "из карточки") text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки")
_line(session_id, state, "crew", text) _line(session_id, card, "crew", text)
report = PhoneReportRecord(service=call.service, crew=call.crew, report = PhoneReportRecord(service=call.service, crew=call.crew,
phase=call.phase, text=text, at=now_utc()) phase=call.phase, text=text, at=now_utc())
state.phone_reports.append(report) card.phone_reports.append(report)
state.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}")) card.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
state.phone_pending = None card.phone_pending = None
hub.to_station(session_id, PhoneReport(**report.model_dump())) hub.to_station(session_id, PhoneReport(**report.model_dump()))
async def _finish_dds(session_id: UUID, state) -> None: async def _finish_dds(session_id: UUID, state) -> None:
state.ended_at = now_utc() state.ended_at = now_utc()
state.end_reason = CallEndReason.COMPLETE state.end_reason = CallEndReason.COMPLETE
state.capture_active_dds() for card in state.desk.cards.values():
for card in state.dds_live_cards:
timer = card.timers.timers.get(TimerCode.DDS_WORK) timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is not None and timer.started_at is not None: if timer is not None and timer.started_at is not None:
card.timers.on_event("dds.finish") card.timers.on_event("dds.finish")
@ -154,6 +157,10 @@ async def _handle(session_id: UUID, state, event) -> None:
if state.ended: if state.ended:
_error(session_id, "Занятие уже завершено") _error(session_id, "Занятие уже завершено")
return return
card = state.desk.active
if card is None and event.type not in CARDLESS_EVENTS:
_error(session_id, "Нет активной карточки")
return
match event.type: match event.type:
case "card.ack": case "card.ack":
# Подтверждение приёма — это статус «Принята» у главной службы. # Подтверждение приёма — это статус «Принята» у главной службы.
@ -162,26 +169,26 @@ async def _handle(session_id: UUID, state, event) -> None:
if not event.comment.strip(): if not event.comment.strip():
_error(session_id, "Для подтверждения приёма добавьте комментарий с основанием") _error(session_id, "Для подтверждения приёма добавьте комментарий с основанием")
return return
if any(action == "card.ack" for action, _at, _detail in state.dds_log): if any(action == "card.ack" for action, _at, _detail in card.dds_log):
return return
state.on_event("card.ack") card.on_event("card.ack")
state.dds_log.append(("card.ack", now_utc(), None)) card.dds_log.append(("card.ack", now_utc(), None))
services = state.managed_services() services = state.card_services(card)
if services: if services:
_start_dds_work_timer(state) _start_dds_work_timer(card)
try: try:
state.set_service_status( card.set_service_status(
services[0], ServiceStatus.ACCEPTED, event.comment, author="диспетчер" services[0], ServiceStatus.ACCEPTED, event.comment, author="диспетчер"
) )
except StatusError: except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status": case "card.status":
if event.service not in state.managed_services(): if event.service not in state.card_services(card):
_error(session_id, "Можно менять статусы только своей ДДС") _error(session_id, "Можно менять статусы только своей ДДС")
return return
_start_dds_work_timer(state) _start_dds_work_timer(card)
try: try:
state.set_service_status( card.set_service_status(
event.service, event.status, event.comment, author="диспетчер" event.service, event.status, event.comment, author="диспетчер"
) )
except StatusError as exc: except StatusError as exc:
@ -192,75 +199,75 @@ async def _handle(session_id: UUID, state, event) -> None:
return return
# Первичный статус останавливает норматив 30 секунд. # Первичный статус останавливает норматив 30 секунд.
if event.status in PRIMARY: if event.status in PRIMARY:
state.on_event("card.ack") card.on_event("card.ack")
if event.status in { if event.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED, ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}: }:
state.on_event("dds.complete") card.on_event("dds.complete")
case "crew.select": case "crew.select":
if event.crew not in state.crew_options(): if event.crew not in state.crew_options(card):
_error(session_id, "Выберите бригаду из списка доступных") _error(session_id, "Выберите бригаду из списка доступных")
return return
service = state.crew_service(event.crew) service = state.crew_service(card, event.crew)
assigned = state.crew_assignments.get(service) assigned = card.crew_assignments.get(service)
if assigned and assigned != event.crew and any( if assigned and assigned != event.crew and any(
report.service == service for report in state.phone_reports report.service == service for report in card.phone_reports
): ):
_error(session_id, "После первого доклада бригаду этой службы менять нельзя") _error(session_id, "После первого доклада бригаду этой службы менять нельзя")
return return
if state.phone_pending is not None: if card.phone_pending is not None:
_error(session_id, "Завершите текущий разговор перед сменой бригады") _error(session_id, "Завершите текущий разговор перед сменой бригады")
return return
if state.crew_selected == event.crew and assigned == event.crew: if card.crew_selected == event.crew and assigned == event.crew:
return return
state.crew_selected = event.crew card.crew_selected = event.crew
state.crew_assignments[service] = event.crew card.crew_assignments[service] = event.crew
state.dds_log.append(("crew.select", now_utc(), event.crew)) card.dds_log.append(("crew.select", now_utc(), event.crew))
case "phone.dial": case "phone.dial":
if state.phone_pending is not None: if card.phone_pending is not None:
_error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок") _error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок")
return return
crew = state.crew_selected crew = card.crew_selected
service = state.crew_service(crew) if crew else None service = state.crew_service(card, crew) if crew else None
if service is None: if service is None:
_error(session_id, "Сначала выберите бригаду") _error(session_id, "Сначала выберите бригаду")
return return
previous = [report for report in state.phone_reports if report.service == service] previous = [report for report in card.phone_reports if report.service == service]
if len(previous) >= len(REPORT_PHASES): if len(previous) >= len(REPORT_PHASES):
_error(session_id, "Все доклады этой бригады уже получены") _error(session_id, "Все доклады этой бригады уже получены")
return return
phase = REPORT_PHASES[len(previous)] phase = REPORT_PHASES[len(previous)]
required = REQUIRED_STATUS[phase] required = REQUIRED_STATUS[phase]
if current(state.status_log, service) not in STATUS_AT_OR_AFTER[phase]: if current(card.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
_error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы") _error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы")
return return
state.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase) card.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase)
state.dds_log.append(("phone.dial", now_utc(), crew)) card.dds_log.append(("phone.dial", now_utc(), crew))
greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия " greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия "
"и что требуется от бригады." "и что требуется от бригады."
if phase == "dispatched" else if phase == "dispatched" else
f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.") f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.")
_line(session_id, state, "crew", greeting) _line(session_id, card, "crew", greeting)
case "phone.brief": case "phone.brief":
call = state.phone_pending call = card.phone_pending
if call is None or call.phase != "dispatched": if call is None or call.phase != "dispatched":
_error(session_id, "Сначала соединитесь со старшим группы для передачи вызова") _error(session_id, "Сначала соединитесь со старшим группы для передачи вызова")
return return
if not _address_matches(state.dispatched_card.address, event.address): if not _address_matches(card.dispatched_card.address, event.address):
_error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой") _error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой")
return return
if not _incident_matches(state, event.incident): if not _incident_matches(card, event.incident):
_error(session_id, "Уточните характер происшествия по данным карточки") _error(session_id, "Уточните характер происшествия по данным карточки")
return return
if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт", if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт",
"реагир", "помощ", "подтверд", "долож")): "реагир", "помощ", "подтверд", "долож")):
_error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады") _error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады")
return return
_line(session_id, state, "dispatcher", f"Адрес: {event.address.strip()}. " _line(session_id, card, "dispatcher", f"Адрес: {event.address.strip()}. "
f"Происшествие: {event.incident.strip()}. {event.request.strip()}") f"Происшествие: {event.incident.strip()}. {event.request.strip()}")
_finish_phone_call(session_id, state) _finish_phone_call(session_id, card)
case "phone.check": case "phone.check":
call = state.phone_pending call = card.phone_pending
if call is None or call.phase == "dispatched": if call is None or call.phase == "dispatched":
_error(session_id, "Сначала передайте вызов, затем запросите обстановку") _error(session_id, "Сначала передайте вызов, затем запросите обстановку")
return return
@ -269,17 +276,17 @@ async def _handle(session_id: UUID, state, event) -> None:
"ход", "сообщ", "долож", "уточн")): "ход", "сообщ", "долож", "уточн")):
_error(session_id, "Спросите обстановку, прибытие или ход работ по карточке") _error(session_id, "Спросите обстановку, прибытие или ход работ по карточке")
return return
_line(session_id, state, "dispatcher", event.text.strip()) _line(session_id, card, "dispatcher", event.text.strip())
_finish_phone_call(session_id, state) _finish_phone_call(session_id, card)
case "phone.hangup": case "phone.hangup":
if state.phone_pending is None: if card.phone_pending is None:
_error(session_id, "Нет активного разговора") _error(session_id, "Нет активного разговора")
return return
state.dds_log.append(("phone.hangup", now_utc(), state.phone_pending.crew)) card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew))
state.phone_pending = None card.phone_pending = None
case "card.reply": case "card.reply":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or ( if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or (
event.card_id != state.dispatched_card.card_id event.card_id != card.card_id
): ):
_error(session_id, "Ответ относится не к текущей карточке") _error(session_id, "Ответ относится не к текущей карточке")
return return
@ -287,15 +294,15 @@ async def _handle(session_id: UUID, state, event) -> None:
# committed this replace-style value. Reconnect retries are safe: # committed this replace-style value. Reconnect retries are safe:
# don't create another journal row # don't create another journal row
# when the current card already contains exactly this text. # when the current card already contains exactly this text.
if state.reply_text == event.text: if card.reply_text == event.text:
return return
state.reply_text = event.text card.reply_text = event.text
state.reply_log.append((now_utc(), event.text)) card.reply_log.append((now_utc(), event.text))
case "card.open": case "card.open":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.activate_dds_card(event.card_id): if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.desk.open(event.card_id):
_error(session_id, "Карточка отсутствует в текущей очереди") _error(session_id, "Карточка отсутствует в текущей очереди")
return return
_start_dds_work_timer(state) _start_dds_work_timer(state.desk.active)
hub.to_station(session_id, state.card_received_event()) hub.to_station(session_id, state.card_received_event())
# CardReceived carries the contents, while StationState carries # CardReceived carries the contents, while StationState carries
# the status journal and current queue. Send both on every switch # the status journal and current queue. Send both on every switch
@ -303,41 +310,25 @@ async def _handle(session_id: UUID, state, event) -> None:
# card's status snapshot until the next periodic tick. # card's status snapshot until the next periodic tick.
hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
case "card.next": case "card.next":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.dispatched_card or ( if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or (
event.card_id != state.dispatched_card.card_id event.card_id != card.card_id
): ):
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает") _error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
return return
if any(item.card_id == event.card_id for item in state.dds_completed): if state.desk.is_completed(event.card_id):
_error(session_id, "Эта карточка уже завершена") _error(session_id, "Эта карточка уже завершена")
return return
state.dds_completed.append(score_current_dds(state))
completed_index = state.dds_card_index
state.dds_live_cards = [
item for item in state.dds_live_cards if item.card_id != event.card_id
]
remaining = sorted(state.dds_live_cards, key=lambda item: item.original_index)
if remaining:
following = next(
(item for item in remaining if item.original_index > completed_index),
remaining[0],
)
state.activate_dds_card(following.card_id, capture=False)
hub.to_station(session_id, state.card_received_event())
else:
# Keep the lesson alive if selected cards have not arrived yet. # Keep the lesson alive if selected cards have not arrived yet.
# The next delivery may become the active card immediately or # The next delivery may become the active card immediately or
# after its configured interval; no completed card is reused. # after its configured interval; no completed card is reused.
state.dds_active_card_id = None if state.desk.complete(card.card_id, score_dds_card(state, card)) is not None:
state.dispatched_card = None hub.to_station(session_id, state.card_received_event())
state.dispatched_at = None active_before_delivery = state.desk.active_id
state.dds_card_index = completed_index state.desk.deliver_due()
active_before_delivery = state.dds_active_card_id if state.desk.active_id and state.desk.active_id != active_before_delivery:
deliver_due_cards(state)
if state.dds_active_card_id and state.dds_active_card_id != active_before_delivery:
hub.to_station(session_id, state.card_received_event()) hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
if not state.dds_live_cards and len(state.dds_completed) >= len(state.dds_scenarios): if not state.desk.cards and len(state.desk.completed) >= len(state.desk.scenarios):
await _finish_dds(session_id, state) await _finish_dds(session_id, state)
case "station.finish": case "station.finish":
if state.exercise is not Exercise.DDS and not state.handoff_to_dds: if state.exercise is not Exercise.DDS and not state.handoff_to_dds:
@ -352,7 +343,7 @@ async def _handle(session_id: UUID, state, event) -> None:
return return
case "zone.decision": case "zone.decision":
previous = next( previous = next(
(detail for action, _at, detail in reversed(state.dds_log) (detail for action, _at, detail in reversed(card.dds_log)
if action == "zone.decision"), if action == "zone.decision"),
None, None,
) )
@ -361,16 +352,15 @@ async def _handle(session_id: UUID, state, event) -> None:
if previous != decision: if previous != decision:
_error(session_id, "Решение по зоне уже записано для этой карточки") _error(session_id, "Решение по зоне уже записано для этой карточки")
return return
state.on_event("zone.decision") card.on_event("zone.decision")
state.dds_log.append(("zone.decision", now_utc(), decision)) card.dds_log.append(("zone.decision", now_utc(), decision))
case "crew.dispatched": case "crew.dispatched":
state.kio = state.kio.model_copy(update={"dispatch_order_at": event.at}) card.kio = card.kio.model_copy(update={"dispatch_order_at": event.at})
state.dds_log.append(("crew.dispatched", now_utc(), None)) card.dds_log.append(("crew.dispatched", now_utc(), None))
case "crew.arrived": case "crew.arrived":
state.on_event("crew.arrived") card.on_event("crew.arrived")
state.kio = state.kio.model_copy(update={"arrival_at": event.at}) card.kio = card.kio.model_copy(update={"arrival_at": event.at})
state.dds_log.append(("crew.arrived", now_utc(), None)) card.dds_log.append(("crew.arrived", now_utc(), None))
state.capture_active_dds()
await hub.checkpoint(session_id) await hub.checkpoint(session_id)
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, state.snapshot()) hub.to_observers(session_id, state.snapshot())
@ -434,7 +424,7 @@ async def station(ws: WebSocket, session_id: UUID, role: str = "dds") -> None:
try: try:
# Карточка, переданная до подключения станции, не теряется: # Карточка, переданная до подключения станции, не теряется:
# диспетчер садится за АРМ, когда вызов уже идёт. # диспетчер садится за АРМ, когда вызов уже идёт.
if state.dispatched_card is not None: if state.desk.active is not None:
hub.to_station(session_id, state.card_received_event()) hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
while True: while True:

View file

@ -286,6 +286,8 @@ def dispatcher_metrics(
deadline_ms: int, deadline_ms: int,
expected_decision: str = "accept", expected_decision: str = "accept",
expected_decision_reason: str | None = None, expected_decision_reason: str | None = None,
*,
services: list[str] | None = None,
) -> list[Metric]: ) -> list[Metric]:
"""Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму. """Числовая часть оценки ДДС; каждый проверяемый шаг имеет факт и норму.
@ -293,7 +295,7 @@ def dispatcher_metrics(
источник информации, но не обязательный шлюз статуса. источник информации, но не обязательный шлюз статуса.
""" """
metrics: list[Metric] = [] metrics: list[Metric] = []
for service in state.managed_services(): for service in (services if services is not None else state.managed_services()):
marks = [mark for mark in state.status_log if mark.service == service] marks = [mark for mark in state.status_log if mark.service == service]
statuses = {mark.status for mark in marks} statuses = {mark.status for mark in marks}

View file

@ -76,8 +76,8 @@ def build(session_id: UUID, state, scenario: Scenario) -> SessionReport:
return SessionReport( return SessionReport(
session_id=session_id, session_id=session_id,
scenario_id=(state.dds_scenarios[0].id scenario_id=(state.desk.scenarios[0].id
if state.exercise is Exercise.DDS and state.dds_scenarios if state.exercise is Exercise.DDS and state.desk.scenarios
else scenario.id), else scenario.id),
mode=state.mode, mode=state.mode,
exercise=state.exercise, exercise=state.exercise,

View file

@ -75,8 +75,13 @@ def _dump_live_card(item: DdsLiveCard, now: float) -> dict:
def dump_state(state: SessionState) -> dict: def dump_state(state: SessionState) -> dict:
"""Сериализовать только данные, необходимые для точного продолжения.""" """Сериализовать только данные, необходимые для точного продолжения.
state.capture_active_dds()
Чистое чтение. Верхние ключи работы диспетчера повторяют активную
карточку пульта — так их писал формат до `DdsDesk`.
"""
desk = state.desk
active = desk.active
now = time.monotonic() now = time.monotonic()
payload = { payload = {
"version": CHECKPOINT_VERSION, "version": CHECKPOINT_VERSION,
@ -117,30 +122,28 @@ def dump_state(state: SessionState) -> dict:
"dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None, "dispatched_at": state.dispatched_at.isoformat() if state.dispatched_at else None,
"bounced_fields": state.bounced_fields, "bounced_fields": state.bounced_fields,
"dds_log": [ "dds_log": [
[action, at.isoformat(), detail] for action, at, detail in state.dds_log [action, at.isoformat(), detail] for action, at, detail in active.dds_log
], ] if active else [],
"status_log": [item.model_dump(mode="json") for item in state.status_log], "status_log": [item.model_dump(mode="json") for item in active.status_log] if active else [],
"crew_selected": state.crew_selected, "crew_selected": active.crew_selected if active else None,
"crew_assignments": state.crew_assignments, "crew_assignments": active.crew_assignments if active else {},
"phone_reports": [item.model_dump(mode="json") for item in state.phone_reports], "phone_reports": [item.model_dump(mode="json") for item in active.phone_reports] if active else [],
"phone_lines": [item.model_dump(mode="json") for item in state.phone_lines], "phone_lines": [item.model_dump(mode="json") for item in active.phone_lines] if active else [],
"phone_pending": ( "phone_pending": (
state.phone_pending.model_dump(mode="json") if state.phone_pending else None active.phone_pending.model_dump(mode="json") if active and active.phone_pending else None
), ),
"dds_scenarios": [item.model_dump(mode="json") for item in state.dds_scenarios], "dds_scenarios": [item.model_dump(mode="json") for item in desk.scenarios],
"pending_dds_scenarios": [item.model_dump(mode="json") for item in state.pending_dds_scenarios], "pending_dds_scenarios": [item.model_dump(mode="json") for item in state.pending_dds_scenarios],
"operator_kio": state.operator_kio.model_dump(mode="json") if state.operator_kio else None, "operator_kio": state.operator_kio.model_dump(mode="json") if state.operator_kio else None,
"operator_scenario": state.operator_scenario.model_dump(mode="json") if state.operator_scenario else None, "operator_scenario": state.operator_scenario.model_dump(mode="json") if state.operator_scenario else None,
"dds_live_cards": [_dump_live_card(item, now) for item in state.dds_live_cards], "dds_live_cards": [_dump_live_card(item, now) for item in desk.ordered()],
"dds_active_card_id": ( "dds_active_card_id": str(desk.active_id) if desk.active_id else None,
str(state.dds_active_card_id) if state.dds_active_card_id else None "dds_card_index": desk.card_index,
), "dds_arrival_interval_seconds": desk.arrival_interval_seconds,
"dds_card_index": state.dds_card_index, "dds_max_waiting": desk.max_waiting,
"dds_arrival_interval_seconds": state.dds_arrival_interval_seconds, "dds_next_scenario_index": desk.next_index,
"dds_max_waiting": state.dds_max_waiting,
"dds_next_scenario_index": state.dds_next_scenario_index,
"dds_next_arrival_at": ( "dds_next_arrival_at": (
state.dds_next_arrival_at.isoformat() if state.dds_next_arrival_at else None desk.next_arrival_at.isoformat() if desk.next_arrival_at else None
), ),
"dds_completed": [ "dds_completed": [
{ {
@ -160,10 +163,10 @@ def dump_state(state: SessionState) -> dict:
"managed_service": item.managed_service, "managed_service": item.managed_service,
"recipient_services": item.recipient_services, "recipient_services": item.recipient_services,
} }
for item in state.dds_completed for item in desk.completed
], ],
"reply_text": state.reply_text, "reply_text": active.reply_text if active else "",
"reply_log": [[at.isoformat(), text] for at, text in state.reply_log], "reply_log": [[at.isoformat(), text] for at, text in active.reply_log] if active else [],
"text_revealed_facts": state.text_revealed_facts, "text_revealed_facts": state.text_revealed_facts,
"resolved_outcome": state.resolved_outcome, "resolved_outcome": state.resolved_outcome,
"resolve_comment": state.resolve_comment, "resolve_comment": state.resolve_comment,
@ -232,6 +235,75 @@ def _restore_live_card(item: dict, saved_at: datetime) -> DdsLiveCard:
) )
def _restore_completed(item: dict) -> DdsCardRecord:
return DdsCardRecord(
card_id=UUID(item["card_id"]),
scenario_id=item["scenario_id"],
reply_text=item.get("reply_text", ""),
metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])],
findings=[Finding.model_validate(finding) for finding in item.get("findings", [])],
actions=list(item.get("actions") or []),
duration_ms=int(item.get("duration_ms", 0)),
title=item.get("title"),
address=item.get("address"),
description=item.get("description"),
incident_type=item.get("incident_type"),
victims_count=item.get("victims_count"),
received_at=_dt(item.get("received_at")),
managed_service=item.get("managed_service"),
recipient_services=list(item.get("recipient_services") or []),
)
def _restore_desk(state: SessionState, payload: dict, saved_at: datetime) -> None:
desk = state.desk
live = payload.get("dds_live_cards", [])
desk.scenarios = [Scenario.model_validate(item) for item in payload.get("dds_scenarios", [])]
desk.completed = [_restore_completed(item) for item in payload.get("dds_completed", [])]
desk.limits = dict(state.timers.limits)
desk.card_index = int(payload.get("dds_card_index", 0))
desk.arrival_interval_seconds = int(payload.get("dds_arrival_interval_seconds", 0))
desk.max_waiting = int(payload.get("dds_max_waiting", 3))
desk.next_index = int(payload.get(
"dds_next_scenario_index",
max((item["original_index"] for item in live), default=-1) + 1,
))
desk.next_arrival_at = _dt(payload.get("dds_next_arrival_at"))
for item in live:
desk.add(_restore_live_card(item, saved_at))
if desk.cards:
# Legacy snapshots had no explicit active ID; newer snapshots may
# intentionally be between cards while waiting for the next arrival.
raw_id = payload.get("dds_active_card_id")
active_id = UUID(raw_id) if raw_id else None
if active_id is None and "dds_active_card_id" not in payload:
active_id = desk.ordered()[0].card_id
if active_id is not None:
desk.open(active_id)
elif (state.exercise is Exercise.CALL and state.dispatched_card is not None
and state.scenario is not None):
# До пульта живой диспетчер упражнения 112 писал прямо в сессию:
# его работа лежит в верхних ключах снимка.
desk.add(_restore_live_card({
**payload,
"original_index": 0,
"scenario": payload["scenario"],
"dispatched_card": payload["dispatched_card"],
"dispatched_at": payload.get("dispatched_at") or saved_at.isoformat(),
}, saved_at))
desk.open(state.dispatched_card.card_id)
if state.exercise is Exercise.CALL:
# Таймеры звонка и диспетчера — одна цепочка 112 → ДДС (см. dispatch).
for card in desk.cards.values():
card.timers = state.timers
if (desk.scenarios
and desk.next_index < len(desk.scenarios)
and desk.next_arrival_at is None):
# Old checkpoints had no delivery schedule; resume any remaining
# selected scenarios immediately rather than strand the session.
desk.next_arrival_at = now_utc()
def load_state(payload: dict, saved_at: datetime) -> SessionState: def load_state(payload: dict, saved_at: datetime) -> SessionState:
"""Восстановить состояние; неизвестная версия отклоняется явно.""" """Восстановить состояние; неизвестная версия отклоняется явно."""
if payload.get("version") != CHECKPOINT_VERSION: if payload.get("version") != CHECKPOINT_VERSION:
@ -276,84 +348,18 @@ def load_state(payload: dict, saved_at: datetime) -> SessionState:
), ),
dispatched_at=_dt(payload.get("dispatched_at")), dispatched_at=_dt(payload.get("dispatched_at")),
bounced_fields=list(payload.get("bounced_fields") or []), bounced_fields=list(payload.get("bounced_fields") or []),
dds_log=[(action, datetime.fromisoformat(at), detail)
for action, at, detail in payload.get("dds_log", [])],
status_log=[StatusEntry.model_validate(item) for item in payload.get("status_log", [])],
crew_selected=payload.get("crew_selected"),
crew_assignments=dict(payload.get("crew_assignments") or {}),
phone_reports=[PhoneReportRecord.model_validate(item)
for item in payload.get("phone_reports", [])],
phone_lines=[PhoneLineRecord.model_validate(item)
for item in payload.get("phone_lines", [])],
phone_pending=(
PhoneCallPending.model_validate(payload["phone_pending"])
if payload.get("phone_pending") else None
),
dds_scenarios=[Scenario.model_validate(item)
for item in payload.get("dds_scenarios", [])],
pending_dds_scenarios=[Scenario.model_validate(item) pending_dds_scenarios=[Scenario.model_validate(item)
for item in payload.get("pending_dds_scenarios", [])], for item in payload.get("pending_dds_scenarios", [])],
operator_kio=(KIO.model_validate(payload["operator_kio"]) operator_kio=(KIO.model_validate(payload["operator_kio"])
if payload.get("operator_kio") else None), if payload.get("operator_kio") else None),
operator_scenario=(Scenario.model_validate(payload["operator_scenario"]) operator_scenario=(Scenario.model_validate(payload["operator_scenario"])
if payload.get("operator_scenario") else None), if payload.get("operator_scenario") else None),
dds_live_cards=[_restore_live_card(item, saved_at)
for item in payload.get("dds_live_cards", [])],
dds_active_card_id=(
UUID(payload["dds_active_card_id"])
if payload.get("dds_active_card_id") else None
),
dds_card_index=int(payload.get("dds_card_index", 0)),
dds_arrival_interval_seconds=int(payload.get("dds_arrival_interval_seconds", 0)),
dds_max_waiting=int(payload.get("dds_max_waiting", 3)),
dds_next_scenario_index=int(payload.get(
"dds_next_scenario_index",
max((item["original_index"] for item in payload.get("dds_live_cards", [])), default=-1) + 1,
)),
dds_next_arrival_at=_dt(payload.get("dds_next_arrival_at")),
dds_completed=[
DdsCardRecord(
card_id=UUID(item["card_id"]),
scenario_id=item["scenario_id"],
reply_text=item.get("reply_text", ""),
metrics=[Metric.model_validate(metric) for metric in item.get("metrics", [])],
findings=[Finding.model_validate(finding)
for finding in item.get("findings", [])],
actions=list(item.get("actions") or []),
duration_ms=int(item.get("duration_ms", 0)),
title=item.get("title"),
address=item.get("address"),
description=item.get("description"),
incident_type=item.get("incident_type"),
victims_count=item.get("victims_count"),
received_at=_dt(item.get("received_at")),
managed_service=item.get("managed_service"),
recipient_services=list(item.get("recipient_services") or []),
)
for item in payload.get("dds_completed", [])
],
reply_text=payload.get("reply_text", ""),
reply_log=[(datetime.fromisoformat(at), text)
for at, text in payload.get("reply_log", [])],
text_revealed_facts=dict(payload.get("text_revealed_facts") or {}), text_revealed_facts=dict(payload.get("text_revealed_facts") or {}),
resolved_outcome=payload.get("resolved_outcome"), resolved_outcome=payload.get("resolved_outcome"),
resolve_comment=payload.get("resolve_comment", ""), resolve_comment=payload.get("resolve_comment", ""),
processed_station_commands=list(payload.get("processed_station_commands") or [])[-512:], processed_station_commands=list(payload.get("processed_station_commands") or [])[-512:],
) )
if state.dds_live_cards: _restore_desk(state, payload, saved_at)
# Legacy snapshots had no explicit active ID; newer snapshots may
# intentionally be between cards while waiting for the next arrival.
active_id = state.dds_active_card_id
if active_id is None and "dds_active_card_id" not in payload:
active_id = state.dds_live_cards[0].card_id
if active_id is not None:
state.activate_dds_card(active_id, capture=False)
if (state.dds_scenarios
and state.dds_next_scenario_index < len(state.dds_scenarios)
and state.dds_next_arrival_at is None):
# Old checkpoints had no delivery schedule; resume any remaining
# selected scenarios immediately rather than strand the session.
state.dds_next_arrival_at = now_utc()
# Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново; # Голосовые runtime-объекты не сериализуются. Их безопасно собрать заново;
# карточка и оценка при этом остаются прежними. # карточка и оценка при этом остаются прежними.
if state.exercise is Exercise.CALL and state.scenario is not None: if state.exercise is Exercise.CALL and state.scenario is not None:

View file

@ -1,23 +1,109 @@
"""Одновременная выдача учебных карточек ДДС в рамках одного занятия.""" """Пульт ДДС: одновременно выданные учебные карточки одного занятия.
Живое состояние карточки существует в одном экземпляре — в `DdsLiveCard`.
Пульт хранит карточки и id активной; переключение меняет id и ничего не
копирует, поэтому обработчики станции пишут прямо в `desk.active`.
"""
import re import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import uuid4 from typing import Any
from uuid import UUID, uuid4
from app.domain import ekp from app.domain import ekp
from app.domain.kio import KIO, ResponseStatus from app.domain.events import Metric
from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.domain.statuses import (
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StatusEntry,
check,
)
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario from app.scenarios.schema import Scenario
from app.session.state import DdsLiveCard, SessionState, now_utc from app.session.timers import SessionTimers, now_utc
from app.session.timers import SessionTimers
def prepare_card(state: SessionState, scenario: Scenario) -> None: @dataclass
"""Сменить активную карточку, сохранив общий ID занятия и прошлые результаты.""" class DdsCardRecord:
state.scenario = scenario.model_copy(deep=True) card_id: UUID
state.scenario_id = scenario.id scenario_id: str
state.scenario_title = scenario.title reply_text: str
state.level = scenario.level.value metrics: list[Metric]
state.required_fields = list(scenario.required_fields) findings: list[Finding]
actions: list[dict[str, Any]]
duration_ms: int
title: str | None = None
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime | None = None
managed_service: str | None = None
recipient_services: list[str] = field(default_factory=list)
@property
def score_auto(self) -> float:
total = sum(metric.weight for metric in self.metrics)
passed = sum(metric.weight * (metric.credit if metric.credit is not None
else float(metric.passed)) for metric in self.metrics)
return round(100 * passed / total, 1) if total else 0.0
@dataclass
class DdsLiveCard:
"""Изолированное живое состояние одной одновременно выданной карточки."""
original_index: int
scenario: Scenario
kio: KIO
dispatched_card: KIO
dispatched_at: datetime
timers: SessionTimers
bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
status_log: list[StatusEntry] = field(default_factory=list)
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
reply_text: str = ""
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
@property
def card_id(self) -> UUID:
return self.dispatched_card.card_id
def on_event(self, event_type: str) -> None:
self.timers.on_event(event_type)
def set_service_status(
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
) -> StatusEntry:
"""Отметка службы. Проверка перехода — до записи: недопустимый статус
не должен попасть в журнал даже отвергнутым."""
check(self.status_log, service, status, comment)
entry = StatusEntry(
service=service, status=status, at=now_utc(), comment=comment, author=author
)
self.status_log.append(entry)
return entry
def build_card(
scenario: Scenario, index: int, limits: dict[TimerCode, int]
) -> DdsLiveCard:
"""Готовая карточка сценария, переданная в ДДС в момент вызова.
Каждая карточка получает свою копию сценария, свою историю и свой таймер
с лимитом преподавателя: 30 секунд идут с поступления, а не с открытия.
"""
scenario = scenario.model_copy(deep=True)
truth = scenario.ground_truth truth = scenario.ground_truth
address_fact = next( address_fact = next(
(fact.value for fact in scenario.facts if "address" in fact.id), "" (fact.value for fact in scenario.facts if "address" in fact.id), ""
@ -58,7 +144,7 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
if scenario.student_card is not None: if scenario.student_card is not None:
# A moderated learner-authored KIO is the card itself; do not flatten # A moderated learner-authored KIO is the card itself; do not flatten
# it to title/description and silently discard its structured fields. # it to title/description and silently discard its structured fields.
state.kio = scenario.student_card.model_copy( kio = scenario.student_card.model_copy(
deep=True, deep=True,
update={ update={
"card_id": uuid4(), "card_id": uuid4(),
@ -69,7 +155,7 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
}, },
) )
else: else:
state.kio = KIO( kio = KIO(
registered_at=now_utc(), registered_at=now_utc(),
caller_number=caller_phone, caller_number=caller_phone,
caller_name=caller_name, caller_name=caller_name,
@ -91,130 +177,157 @@ def prepare_card(state: SessionState, scenario: Scenario) -> None:
description="; ".join(fact.value for fact in scenario.facts[:3]) description="; ".join(fact.value for fact in scenario.facts[:3])
or scenario.first_line, or scenario.first_line,
) )
state.dispatched_card = None
if service: if service:
state.dispatch(service) kio = apply_patch(kio, {"dds": service})
else: kio.response_status = ResponseStatus.TRANSFERRED
state.kio.response_status = ResponseStatus.TRANSFERRED timers = SessionTimers(limits=dict(limits))
state.dispatched_card = state.kio.model_copy(deep=True) timers.on_event("dds.dispatch")
state.dispatched_at = now_utc() return DdsLiveCard(
# A live DДС card keeps references to the current working collections.
# Never clear them in place when preparing the next card: that would also
# erase the previous card's history. Replace the working collections so
# every queued card retains an isolated snapshot.
state.status_log = []
state.dds_log = []
state.bounced_fields = []
state.crew_selected = None
state.crew_assignments = {}
state.phone_reports = []
state.phone_lines = []
state.phone_pending = None
state.reply_text = ""
state.reply_log = []
# Настроенный преподавателем лимит копируется в независимый таймер карточки.
state.timers = SessionTimers(limits=dict(state.timers.limits))
state.on_event("dds.dispatch")
def _append_live_card(
state: SessionState, scenario: Scenario, index: int
) -> DdsLiveCard:
state.dds_card_index = index
prepare_card(state, scenario)
card = DdsLiveCard(
original_index=index, original_index=index,
scenario=state.scenario, scenario=scenario,
kio=state.kio, kio=kio,
dispatched_card=state.dispatched_card, dispatched_card=kio.model_copy(deep=True),
dispatched_at=state.dispatched_at, dispatched_at=now_utc(),
timers=state.timers, timers=timers,
bounced_fields=state.bounced_fields,
dds_log=state.dds_log,
status_log=state.status_log,
crew_selected=state.crew_selected,
crew_assignments=state.crew_assignments,
phone_reports=state.phone_reports,
phone_lines=state.phone_lines,
phone_pending=state.phone_pending,
reply_text=state.reply_text,
reply_log=state.reply_log,
) )
state.dds_live_cards.append(card)
@dataclass
class DdsDesk:
"""Живые карточки занятия, активная из них и очередь поступления."""
scenarios: list[Scenario] = field(default_factory=list)
cards: dict[UUID, DdsLiveCard] = field(default_factory=dict)
active_id: UUID | None = None
#: Номер карточки на экране. После завершения последней из поступивших
#: остаётся прежним, пока не придёт следующая.
card_index: int = 0
completed: list[DdsCardRecord] = field(default_factory=list)
limits: dict[TimerCode, int] = field(default_factory=lambda: dict(SessionTimers().limits))
arrival_interval_seconds: int = 0
max_waiting: int = 3
next_index: int = 0
next_arrival_at: datetime | None = None
@property
def active(self) -> DdsLiveCard | None:
return self.cards.get(self.active_id) if self.active_id is not None else None
def ordered(self) -> list[DdsLiveCard]:
return sorted(self.cards.values(), key=lambda card: card.original_index)
def open(self, card_id: UUID) -> bool:
"""Сделать карточку активной; остальные продолжают идти."""
card = self.cards.get(card_id)
if card is None:
return False
self.active_id = card_id
self.card_index = card.original_index
return True
def add(self, card: DdsLiveCard) -> DdsLiveCard:
self.cards[card.card_id] = card
return card return card
def is_completed(self, card_id: UUID) -> bool:
return any(item.card_id == card_id for item in self.completed)
def deliver_due_cards(state: SessionState, now: datetime | None = None) -> int: def complete(self, card_id: UUID, record: DdsCardRecord) -> DdsLiveCard | None:
"""Закрыть карточку и открыть следующую по порядку поступления.
Если поступивших больше нет, пульт остаётся между карточками: номер
завершённой сохраняется, пока очередь не выдаст следующую.
"""
card = self.cards.pop(card_id)
self.completed.append(record)
remaining = self.ordered()
if not remaining:
self.active_id = None
self.card_index = card.original_index
return None
following = next(
(item for item in remaining if item.original_index > card.original_index),
remaining[0],
)
self.open(following.card_id)
return following
def start(
self,
scenarios: list[Scenario],
*,
limits: dict[TimerCode, int],
arrival_interval_seconds: int = 0,
max_waiting: int = 3,
now: datetime | None = None,
) -> int:
"""Начать поступление очереди с первой карточки; остальные идут по темпу."""
self.cards.clear()
self.active_id = None
self.scenarios = scenarios
self.limits = dict(limits)
self.arrival_interval_seconds = arrival_interval_seconds
self.max_waiting = max_waiting
self.next_index = 0
self.next_arrival_at = now or now_utc()
return self.deliver_due(self.next_arrival_at)
def deliver_due(self, now: datetime | None = None) -> int:
"""Выдать всё, чему настал срок и для чего есть место в ожидании. """Выдать всё, чему настал срок и для чего есть место в ожидании.
Активная карточка не считается ожидающей. Каждый вызов prepare_card Активная карточка не считается ожидающей. Каждая карточка запускает
запускает собственный 30-секундный таймер ровно при поступлении. собственный 30-секундный таймер ровно при поступлении.
""" """
now = now or now_utc() now = now or now_utc()
if state.dds_next_arrival_at is None or state.dds_next_arrival_at > now: if self.next_arrival_at is None or self.next_arrival_at > now:
return 0 return 0
delivered = 0 delivered = 0
while state.dds_next_scenario_index < len(state.dds_scenarios): while self.next_index < len(self.scenarios):
active_id = ( waiting_count = len(self.cards) - int(self.active is not None)
state.dds_active_card_id if waiting_count >= self.max_waiting:
if any(
item.card_id == state.dds_active_card_id
for item in state.dds_live_cards
)
else None
)
active_exists = active_id is not None
waiting_count = len(state.dds_live_cards) - int(active_exists)
if waiting_count >= state.dds_max_waiting:
break break
index = state.dds_next_scenario_index index = self.next_index
card = _append_live_card(state, state.dds_scenarios[index], index) card = self.add(build_card(self.scenarios[index], index, self.limits))
state.dds_next_scenario_index += 1 self.next_index += 1
delivered += 1 delivered += 1
if not active_exists: if self.active is None:
state.activate_dds_card(card.card_id, capture=False) self.open(card.card_id)
else:
# prepare_card builds the new snapshot through SessionState's
# working fields; restore the trainee's active card immediately.
state.activate_dds_card(active_id, capture=False)
if state.dds_next_scenario_index >= len(state.dds_scenarios): if self.next_index >= len(self.scenarios):
state.dds_next_arrival_at = None self.next_arrival_at = None
break break
if state.dds_arrival_interval_seconds: if self.arrival_interval_seconds:
# Pace from actual delivery time. If the cap was full, the next # Pace from actual delivery time. If the cap was full, the next
# card becomes due one full interval after this release. # card becomes due one full interval after this release.
state.dds_next_arrival_at = card.dispatched_at + timedelta( self.next_arrival_at = card.dispatched_at + timedelta(
seconds=state.dds_arrival_interval_seconds seconds=self.arrival_interval_seconds
) )
break break
# Zero interval means fill active + available waiting slots now. # Zero interval means fill active + available waiting slots now.
state.dds_next_arrival_at = now self.next_arrival_at = now
return delivered return delivered
def prepare_queue( def prepare_queue(
state: SessionState, state,
scenarios: list[Scenario], scenarios: list[Scenario],
arrival_interval_seconds: int = 0, arrival_interval_seconds: int = 0,
max_waiting: int = 3, max_waiting: int = 3,
) -> None: ) -> None:
"""Начать поступление очереди с первой карточки; остальные идут по темпу.""" """Упражнение ДДС: очередь готовых карточек с лимитами занятия."""
state.dds_live_cards.clear() state.desk.start(
state.dds_scenarios = scenarios scenarios,
state.dds_arrival_interval_seconds = arrival_interval_seconds limits=state.timers.limits,
state.dds_max_waiting = max_waiting arrival_interval_seconds=arrival_interval_seconds,
state.dds_next_scenario_index = 0 max_waiting=max_waiting,
state.dds_next_arrival_at = now_utc() )
deliver_due_cards(state)
def prepare_handoff_queue( def prepare_handoff_queue(
state: SessionState, state,
additional_scenarios: list[Scenario], additional_scenarios: list[Scenario],
arrival_interval_seconds: int = 0, arrival_interval_seconds: int = 0,
max_waiting: int = 3, max_waiting: int = 3,
@ -237,12 +350,14 @@ def prepare_handoff_queue(
dispatched_at=state.dispatched_at or now, dispatched_at=state.dispatched_at or now,
timers=dds_timers, timers=dds_timers,
) )
state.dds_live_cards = [first] desk = state.desk
state.dds_scenarios = [state.operator_scenario, *additional_scenarios] desk.cards = {first.card_id: first}
state.dds_arrival_interval_seconds = arrival_interval_seconds desk.scenarios = [state.operator_scenario, *additional_scenarios]
state.dds_max_waiting = max_waiting desk.limits = dict(state.timers.limits)
state.dds_next_scenario_index = 1 desk.arrival_interval_seconds = arrival_interval_seconds
state.dds_next_arrival_at = ( desk.max_waiting = max_waiting
desk.next_index = 1
desk.next_arrival_at = (
( (
now + timedelta(seconds=arrival_interval_seconds) now + timedelta(seconds=arrival_interval_seconds)
if additional_scenarios and arrival_interval_seconds if additional_scenarios and arrival_interval_seconds
@ -251,5 +366,5 @@ def prepare_handoff_queue(
if additional_scenarios if additional_scenarios
else None else None
) )
state.activate_dds_card(first.card_id, capture=False) desk.open(first.card_id)
deliver_due_cards(state) desk.deliver_due(now)

View file

@ -28,42 +28,47 @@ from app.scoring.taxonomy import METRIC_WEIGHTS
from app.scoring.timing import time_credit from app.scoring.timing import time_credit
from app.scoring.weights import apply_weights from app.scoring.weights import apply_weights
from app.session.hub import hub from app.session.hub import hub
from app.session.state import DdsCardRecord, now_utc from app.session.state import DdsCardRecord, DdsLiveCard, now_utc
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def score_current_dds(state) -> DdsCardRecord: def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
"""Оценить активную карточку отдельно, до выдачи следующей.""" """Оценить одну карточку пульта; активная карточка при этом не меняется.
number = state.dds_card_index + 1
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK] Из сессии берутся только служба обучающегося и критерии занятия.
dds_decision = state.scenario.dds_decision """
number = card.original_index + 1
services = state.card_services(card)
decision_limit_ms = card.timers.limits[TimerCode.DDS_ACK]
dds_decision = card.scenario.dds_decision
findings = evaluate_dispatcher( findings = evaluate_dispatcher(
entries=state.status_log, entries=card.status_log,
services=state.managed_services(), services=services,
crew_assignments=state.crew_assignments, crew_assignments=card.crew_assignments,
deadline_ms=decision_limit_ms, deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), elapsed_ms=card.timers.measured_ms(TimerCode.DDS_ACK),
reply_text=state.reply_text, reply_text=card.reply_text,
expected_decision=dds_decision.expected, expected_decision=dds_decision.expected,
expected_decision_reason=dds_decision.reason, expected_decision_reason=dds_decision.reason,
) )
metrics = dispatcher_metrics( metrics = dispatcher_metrics(
state, decision_limit_ms, dds_decision.expected, dds_decision.reason card, decision_limit_ms, dds_decision.expected, dds_decision.reason,
services=services,
) )
weighted = GostResult(metrics=metrics, findings=findings) weighted = GostResult(metrics=metrics, findings=findings)
work_limit_ms = state.timers.limits[TimerCode.DDS_WORK] work_limit_ms = card.timers.limits[TimerCode.DDS_WORK]
work_timer = state.timers.timers.get(TimerCode.DDS_WORK) work_timer = card.timers.timers.get(TimerCode.DDS_WORK)
work_elapsed_ms = state.timers.measured_ms(TimerCode.DDS_WORK) work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None: if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
work_elapsed_ms = work_timer.current_ms(time.monotonic()) work_elapsed_ms = work_timer.current_ms(time.monotonic())
terminal = bool(state.managed_services()) and all( terminal = bool(services) and all(
current(state.status_log, service) in { current(card.status_log, service) in {
ServiceStatus.COMPLETED, ServiceStatus.COMPLETED,
ServiceStatus.DECLINED, ServiceStatus.DECLINED,
ServiceStatus.REFUSED, ServiceStatus.REFUSED,
} }
for service in state.managed_services() for service in services
) )
work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms work_passed = terminal and work_elapsed_ms is not None and work_elapsed_ms <= work_limit_ms
work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None work_delta_ms = (work_elapsed_ms - work_limit_ms) if work_elapsed_ms is not None else None
@ -98,78 +103,71 @@ def score_current_dds(state) -> DdsCardRecord:
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026", ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
competency=Competency.NORMS, competency=Competency.NORMS,
)) ))
apply_weights(weighted, {**state.scenario.score_weights, **state.criteria.score_weights}) apply_weights(weighted, {**card.scenario.score_weights, **state.criteria.score_weights})
actions = [ actions = [
{"type": "card.status", "service": mark.service, "status": mark.status.value, {"type": "card.status", "service": mark.service, "status": mark.status.value,
"comment": mark.comment, "at": mark.at.isoformat()} "comment": mark.comment, "at": mark.at.isoformat()}
for mark in state.status_log for mark in card.status_log
] + [ ] + [
{"type": "phone.report", "service": report.service, "crew": report.crew, {"type": "phone.report", "service": report.service, "crew": report.crew,
"phase": report.phase, "text": report.text, "at": report.at.isoformat()} "phase": report.phase, "text": report.text, "at": report.at.isoformat()}
for report in state.phone_reports for report in card.phone_reports
] + [ ] + [
{"type": "card.reply", "text": text, "at": at.isoformat()} {"type": "card.reply", "text": text, "at": at.isoformat()}
for at, text in state.reply_log for at, text in card.reply_log
] + [ ] + [
{"type": kind, "text": text or "", "at": at.isoformat()} {"type": kind, "text": text or "", "at": at.isoformat()}
for kind, at, text in state.dds_log for kind, at, text in card.dds_log
] ]
actions.sort(key=lambda item: item["at"]) actions.sort(key=lambda item: item["at"])
return DdsCardRecord( return DdsCardRecord(
card_id=state.dispatched_card.card_id, card_id=card.card_id,
scenario_id=state.scenario_id, scenario_id=card.scenario.id,
reply_text=state.reply_text, reply_text=card.reply_text,
metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"}) metrics=[metric.model_copy(update={"title": f"Карточка {number}: {metric.title}"})
for metric in weighted.metrics], for metric in weighted.metrics],
findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"}) findings=[finding.model_copy(update={"summary": f"Карточка {number}: {finding.summary}"})
for finding in weighted.findings], for finding in weighted.findings],
actions=actions, actions=actions,
duration_ms=(max(0, int((now_utc() - state.dispatched_at).total_seconds() * 1000)) duration_ms=max(0, int((now_utc() - card.dispatched_at).total_seconds() * 1000)),
if state.dispatched_at else 0), title=card.scenario.title,
title=state.scenario.title, address=card.dispatched_card.address,
address=state.dispatched_card.address, description=card.dispatched_card.description,
description=state.dispatched_card.description, incident_type=(card.dispatched_card.incident_type.value
incident_type=(state.dispatched_card.incident_type.value if card.dispatched_card.incident_type else None),
if state.dispatched_card.incident_type else None), victims_count=card.dispatched_card.victims_count,
victims_count=state.dispatched_card.victims_count, received_at=card.dispatched_at,
received_at=state.dispatched_at, managed_service=services[0] if services else None,
managed_service=(state.managed_services()[0] if state.managed_services() else None), recipient_services=list(card.dispatched_card.notify),
recipient_services=list(state.dispatched_card.notify),
) )
def scoring_scenario(state): def scoring_scenario(state):
"""Сценарий, по которому оценивается и разбирается занятие. """Сценарий, по которому оценивается и разбирается занятие.
После передачи 112→ДДС `state.scenario` — активная карточка очереди, После передачи 112→ДДС КИО курсанта оценивается по эталону упражнения
а КИО курсанта оценивается по эталону упражнения 112. Балл, разбор 112. Балл, разбор и чек-лист самооценки обязаны брать один и тот же
и чек-лист самооценки обязаны брать один и тот же сценарий. сценарий.
""" """
if state.handoff_to_dds and state.operator_scenario: if state.handoff_to_dds and state.operator_scenario:
return state.operator_scenario return state.operator_scenario
if state.scenario is None and state.desk.scenarios:
return state.desk.scenarios[0]
return state.scenario or store.get(state.scenario_id) return state.scenario or store.get(state.scenario_id)
def score_live_dds_cards(state) -> list[DdsCardRecord]: def score_live_dds_cards(state) -> list[DdsCardRecord]:
"""Оценить все неоценённые карточки очереди и вернуть пульт курсанту. """Все карточки занятия: завершённые и оценка ещё открытых.
Оценка переключает рабочие поля сессии на каждую карточку по очереди. Открытые карточки оцениваются по месту, пульт не переключается: отчёт
Без возврата отчёт взял бы card_status у карточки, оценённой последней, берёт card_status у карточки, активной у курсанта при завершении.
а пульт после завершения показывал бы чужую карточку.
""" """
cards = list(state.dds_completed) desk = state.desk
state.capture_active_dds() cards = list(desk.completed)
active_id = state.dds_active_card_id for live in desk.ordered():
active_card = state.dispatched_card if not desk.is_completed(live.card_id):
for live in sorted(state.dds_live_cards, key=lambda item: item.original_index): cards.append(score_dds_card(state, live))
if any(item.card_id == live.card_id for item in cards): desk.completed = cards
continue
state.activate_dds_card(live.card_id, capture=False)
cards.append(score_current_dds(state))
state.dds_completed = cards
if active_id is None or not state.activate_dds_card(active_id, capture=False):
state.dds_active_card_id = active_id
state.dispatched_card = active_card
return cards return cards
@ -273,30 +271,32 @@ async def finish(session_id: UUID, state) -> None:
# Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут # Работа диспетчера — вторая роль и вторая таксономия. Отметки D1–D6 идут
# рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии # рядом с E1–E6, а не вместо: в живой цепочке 112 → ДДС в одном занятии
# участвуют оба (docs/spec/DATASET.md#статусы-реагирования). # участвуют оба (docs/spec/DATASET.md#статусы-реагирования).
if state.dispatched_card is not None and state.exercise is Exercise.CALL: live_card = state.desk.active
decision_limit_ms = state.timers.limits[TimerCode.DDS_ACK] if live_card is not None and state.exercise is Exercise.CALL:
decision_limit_ms = live_card.timers.limits[TimerCode.DDS_ACK]
services = state.card_services(live_card)
dispatcher_findings = evaluate_dispatcher( dispatcher_findings = evaluate_dispatcher(
entries=state.status_log, entries=live_card.status_log,
services=state.managed_services(), services=services,
crew_assignments=state.crew_assignments, crew_assignments=live_card.crew_assignments,
deadline_ms=decision_limit_ms, deadline_ms=decision_limit_ms,
elapsed_ms=state.timers.measured_ms(TimerCode.DDS_ACK), elapsed_ms=live_card.timers.measured_ms(TimerCode.DDS_ACK),
reply_text=state.reply_text, reply_text=live_card.reply_text,
expected_decision=scenario.dds_decision.expected, expected_decision=scenario.dds_decision.expected,
expected_decision_reason=scenario.dds_decision.reason, expected_decision_reason=scenario.dds_decision.reason,
) )
result.findings.extend(dispatcher_findings) result.findings.extend(dispatcher_findings)
result.metrics.extend(dispatcher_metrics( result.metrics.extend(dispatcher_metrics(
state, decision_limit_ms, scenario.dds_decision.expected, live_card, decision_limit_ms, scenario.dds_decision.expected,
scenario.dds_decision.reason, scenario.dds_decision.reason, services=services,
)) ))
# DDS cards were weighted individually in score_current_dds using each # DDS cards were weighted individually in score_dds_card using each
# card's scenario defaults plus the lesson override. Reapplying the first # card's scenario defaults plus the lesson override. Reapplying the first
# scenario's weights here would corrupt the other ticket cards. # scenario's weights here would corrupt the other ticket cards.
if state.exercise is not Exercise.DDS: if state.exercise is not Exercise.DDS:
apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights}) apply_weights(result, {**scenario.score_weights, **state.criteria.score_weights})
if state.handoff_to_dds and state.dds_scenarios: if state.handoff_to_dds and state.desk.scenarios:
# В связке КИО оценивается относительно эталона и весов упражнения # В связке КИО оценивается относительно эталона и весов упражнения
# 112, а каждая карточка очереди уже взвешена собственным сценарием. # 112, а каждая карточка очереди уже взвешена собственным сценарием.
cards = score_live_dds_cards(state) cards = score_live_dds_cards(state)

View file

@ -20,7 +20,6 @@ from app.domain.events import (
CardReceived, CardReceived,
ErrorEvent, ErrorEvent,
ErrorKind, ErrorKind,
Exercise,
StationState, StationState,
TimerTick, TimerTick,
) )
@ -131,7 +130,7 @@ class SessionHub:
return any( return any(
not state.ended and not state.lease_fenced and ( not state.ended and not state.lease_fenced and (
state.scenario_id == scenario_id state.scenario_id == scenario_id
or any(item.id == scenario_id for item in state.dds_scenarios) or any(item.id == scenario_id for item in state.desk.scenarios)
) )
for state in self._sessions.values() for state in self._sessions.values()
) )
@ -316,19 +315,17 @@ class SessionHub:
state = self.get(session_id) state = self.get(session_id)
if state is None or state.ended: if state is None or state.ended:
return return
if state.exercise is Exercise.DDS or (state.handoff_to_dds and state.dds_scenarios): if state.dds_phase:
from app.session.dds import deliver_due_cards active_before = state.desk.active_id
delivered = state.desk.deliver_due()
active_before = state.dds_active_card_id if state.desk.active_id != active_before and state.desk.active_id:
delivered = deliver_due_cards(state)
if state.dds_active_card_id != active_before and state.dds_active_card_id:
self.to_station(session_id, state.card_received_event()) self.to_station(session_id, state.card_received_event())
if delivered: if delivered:
await self.checkpoint(session_id) await self.checkpoint(session_id)
# 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()))
tick = TimerTick(timers=state.timers.snapshot()) tick = TimerTick(timers=state.shown_timers().snapshot())
self.broadcast(session_id, tick) self.broadcast(session_id, tick)
# Срок первичного решения пульт берёт отсюда: после передачи # Срок первичного решения пульт берёт отсюда: после передачи
# из 112 без очереди ДДС лимит преподавателя больше нигде не виден. # из 112 без очереди ДДС лимит преподавателя больше нигде не виден.

View file

@ -7,7 +7,7 @@
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import datetime
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@ -18,7 +18,6 @@ from app.domain.events import (
CallEndReason, CallEndReason,
Exercise, Exercise,
LessonCriteria, LessonCriteria,
Metric,
Mood, Mood,
SessionMode, SessionMode,
SessionSnapshot, SessionSnapshot,
@ -31,77 +30,17 @@ from app.domain.statuses import (
CardStatus, CardStatus,
DdsCardSummary, DdsCardSummary,
DdsQueueCard, DdsQueueCard,
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus, ServiceStatus,
StationSnapshot, StationSnapshot,
StatusEntry,
card_status, card_status,
check,
current, current,
) )
from app.domain.taxonomy import Finding
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios.schema import Scenario from app.scenarios.schema import Scenario
from app.session.timers import SessionTimers from app.session.dds import DdsCardRecord, DdsDesk, DdsLiveCard
from app.session.timers import SessionTimers, now_utc
__all__ = ["DdsCardRecord", "DdsLiveCard", "SessionState", "now_utc"]
def now_utc() -> datetime:
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
return datetime.now(UTC)
@dataclass
class DdsCardRecord:
card_id: UUID
scenario_id: str
reply_text: str
metrics: list[Metric]
findings: list[Finding]
actions: list[dict[str, Any]]
duration_ms: int
title: str | None = None
address: str | None = None
description: str | None = None
incident_type: str | None = None
victims_count: int | None = None
received_at: datetime | None = None
managed_service: str | None = None
recipient_services: list[str] = field(default_factory=list)
@property
def score_auto(self) -> float:
total = sum(metric.weight for metric in self.metrics)
passed = sum(metric.weight * (metric.credit if metric.credit is not None
else float(metric.passed)) for metric in self.metrics)
return round(100 * passed / total, 1) if total else 0.0
@dataclass
class DdsLiveCard:
"""Изолированное живое состояние одной одновременно выданной карточки."""
original_index: int
scenario: Scenario
kio: KIO
dispatched_card: KIO
dispatched_at: datetime
timers: SessionTimers
bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list)
status_log: list[StatusEntry] = field(default_factory=list)
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
reply_text: str = ""
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
@property
def card_id(self) -> UUID:
return self.dispatched_card.card_id
@dataclass @dataclass
@ -167,34 +106,18 @@ class SessionState:
started_at: datetime | None = None started_at: datetime | None = None
ended_at: datetime | None = None ended_at: datetime | None = None
end_reason: CallEndReason | None = None end_reason: CallEndReason | None = None
#: Карточка оператора 112, замороженная при передаче в ДДС.
dispatched_card: KIO | None = None dispatched_card: KIO | None = None
dispatched_at: datetime | None = None dispatched_at: datetime | None = None
#: Поля, из-за которых диспетчер вернул карточку, — основание E6. #: Поля, из-за которых диспетчер вернул карточку, — основание E6.
bounced_fields: list[str] = field(default_factory=list) bounced_fields: list[str] = field(default_factory=list)
dds_log: list[tuple[str, datetime, str | None]] = field(default_factory=list) #: Готовые карточки связки 112→ДДС ждут, пока курсант не сдаст свою.
#: Отметки служб по списку оповещения — предмет второго режима занятия.
status_log: list[StatusEntry] = field(default_factory=list)
crew_selected: str | None = None
crew_assignments: dict[str, str] = field(default_factory=dict)
phone_reports: list[PhoneReportRecord] = field(default_factory=list)
phone_lines: list[PhoneLineRecord] = field(default_factory=list)
phone_pending: PhoneCallPending | None = None
dds_scenarios: list[Scenario] = field(default_factory=list)
pending_dds_scenarios: list[Scenario] = field(default_factory=list) pending_dds_scenarios: list[Scenario] = field(default_factory=list)
#: Исходная часть упражнения 112→ДДС сохраняется отдельно от активной #: Исходная часть упражнения 112→ДДС сохраняется отдельно от карточек пульта.
#: карточки ДДС, которая может переключаться по очереди.
operator_kio: KIO | None = None operator_kio: KIO | None = None
operator_scenario: Scenario | None = None operator_scenario: Scenario | None = None
dds_live_cards: list[DdsLiveCard] = field(default_factory=list) #: Работа диспетчера живёт только здесь — в карточках пульта.
dds_active_card_id: UUID | None = None desk: DdsDesk = field(default_factory=DdsDesk)
dds_card_index: int = 0
dds_completed: list[DdsCardRecord] = field(default_factory=list)
dds_arrival_interval_seconds: int = 0
dds_max_waiting: int = 3
dds_next_scenario_index: int = 0
dds_next_arrival_at: datetime | None = None
reply_text: str = ""
reply_log: list[tuple[datetime, str]] = field(default_factory=list)
#: Чем курсант закрыл вызов, если не карточкой (lct-36). #: Чем курсант закрыл вызов, если не карточкой (lct-36).
resolved_outcome: str | None = None resolved_outcome: str | None = None
resolve_comment: str = "" resolve_comment: str = ""
@ -230,8 +153,48 @@ class SessionState:
self.kio.response_status = ResponseStatus.TRANSFERRED self.kio.response_status = ResponseStatus.TRANSFERRED
self.dispatched_card = self.kio.model_copy(deep=True) self.dispatched_card = self.kio.model_copy(deep=True)
self.dispatched_at = now_utc() self.dispatched_at = now_utc()
if self.exercise is Exercise.CALL:
self._receive_call_card()
return self.dispatched_card return self.dispatched_card
def _receive_call_card(self) -> None:
"""Живой диспетчер упражнения 112 работает с переданной карточкой на пульте.
Таймеры общие с оператором: норматив приёма ДДС в этом упражнении
часть одной цепочки 112 → ДДС и оценивается вместе с ней. Повторная
передача обновляет содержимое карточки, не стирая работу диспетчера.
"""
if self.scenario is None or self.dispatched_card is None:
return
card = self.desk.cards.get(self.dispatched_card.card_id)
if card is None:
card = self.desk.add(DdsLiveCard(
original_index=0,
scenario=self.scenario,
kio=self.kio,
dispatched_card=self.dispatched_card,
dispatched_at=self.dispatched_at,
timers=self.timers,
))
else:
card.kio = self.kio
card.dispatched_card = self.dispatched_card
card.dispatched_at = self.dispatched_at
self.desk.open(card.card_id)
@property
def dds_phase(self) -> bool:
"""Экран курсанта — пульт с очередью: упражнение ДДС или вторая часть связки."""
return self.exercise is Exercise.DDS or (self.handoff_to_dds and bool(self.desk.scenarios))
def shown_card(self) -> DdsLiveCard | None:
"""Карточка, которую монитор показывает вместо КИО оператора."""
return self.desk.active if self.dds_phase else None
def shown_timers(self) -> SessionTimers:
card = self.shown_card()
return card.timers if card is not None else self.timers
def notified_services(self) -> list[str]: def notified_services(self) -> list[str]:
"""Список оповещения переданной карточки. До передачи — пусто: """Список оповещения переданной карточки. До передачи — пусто:
отмечаться диспетчеру пока не по чему.""" отмечаться диспетчеру пока не по чему."""
@ -269,105 +232,52 @@ class SessionState:
return [recipients[0]] return [recipients[0]]
def managed_services(self) -> list[str]: def managed_services(self) -> list[str]:
"""Службы обучающегося: по активной карточке пульта, до неё — по КИО 112."""
card = self.desk.active
if card is not None:
return self.card_services(card)
return self._managed_services_for(self.notified_services()) return self._managed_services_for(self.notified_services())
def capture_active_dds(self) -> None: def card_services(self, card: DdsLiveCard) -> list[str]:
"""Сохранить изменяемые поля активной карточки перед переключением.""" """Службы карточки пульта, от имени которых работает обучающийся."""
if not self.dispatched_card: return self._managed_services_for(list(card.dispatched_card.notify))
return
card = next(
(item for item in self.dds_live_cards
if item.card_id == self.dispatched_card.card_id),
None,
)
if card is None:
return
card.scenario = self.scenario
card.kio = self.kio
card.dispatched_card = self.dispatched_card
card.dispatched_at = self.dispatched_at or card.dispatched_at
card.timers = self.timers
card.bounced_fields = self.bounced_fields
card.dds_log = self.dds_log
card.status_log = self.status_log
card.crew_selected = self.crew_selected
card.crew_assignments = self.crew_assignments
card.phone_reports = self.phone_reports
card.phone_lines = self.phone_lines
card.phone_pending = self.phone_pending
card.reply_text = self.reply_text
card.reply_log = self.reply_log
self.dds_active_card_id = card.card_id
def activate_dds_card(self, card_id: UUID, *, capture: bool = True) -> bool: @staticmethod
"""Переключить рабочее место на карточку, не останавливая остальные.""" def _crew_options_for(services: list[str]) -> list[str]:
if capture: return [f"{service} — бригада №{number}" for service in services for number in (1, 2)]
self.capture_active_dds()
card = next((item for item in self.dds_live_cards if item.card_id == card_id), None)
if card is None:
return False
scenario = card.scenario
self.scenario = scenario
self.scenario_id = scenario.id
self.scenario_title = scenario.title
self.level = scenario.level.value
self.required_fields = list(scenario.required_fields)
self.kio = card.kio
self.dispatched_card = card.dispatched_card
self.dispatched_at = card.dispatched_at
self.timers = card.timers
self.bounced_fields = card.bounced_fields
self.dds_log = card.dds_log
self.status_log = card.status_log
self.crew_selected = card.crew_selected
self.crew_assignments = card.crew_assignments
self.phone_reports = card.phone_reports
self.phone_lines = card.phone_lines
self.phone_pending = card.phone_pending
self.reply_text = card.reply_text
self.reply_log = card.reply_log
self.dds_card_index = card.original_index
self.dds_active_card_id = card.card_id
return True
def crew_options(self) -> list[str]: def crew_options(self, card: DdsLiveCard) -> list[str]:
return [f"{service} — бригада №{number}" return self._crew_options_for(self.card_services(card))
for service in self.managed_services() for number in (1, 2)]
def crew_service(self, crew: str) -> str | None: def crew_service(self, card: DdsLiveCard, crew: str) -> str | None:
return next((service for service in self.managed_services() return next((service for service in self.card_services(card)
if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None) if crew in (f"{service} — бригада №1", f"{service} — бригада №2")), None)
def set_service_status(
self, service: str, status: ServiceStatus, comment: str = "", author: str = ""
) -> StatusEntry:
"""Отметка службы. Проверка перехода — до записи: недопустимый статус
не должен попасть в журнал даже отвергнутым."""
check(self.status_log, service, status, comment)
entry = StatusEntry(
service=service, status=status, at=now_utc(), comment=comment, author=author
)
self.status_log.append(entry)
return entry
def station_snapshot(self) -> StationSnapshot: def station_snapshot(self) -> StationSnapshot:
"""Что видит диспетчер: службы, их статусы и доступные переходы.""" """Что видит диспетчер: службы, их статусы и доступные переходы."""
self.capture_active_dds() desk = self.desk
has_active_dds_card = any( card = desk.active
item.card_id == self.dds_active_card_id for item in self.dds_live_cards if card is not None:
) recipients = list(card.dispatched_card.notify)
recipients = self.notified_services() if has_active_dds_card or not self.dds_scenarios else [] elif not desk.scenarios:
# Пульт ещё пуст (до передачи из 112) или упражнение без ДДС:
# показываем список оповещения карточки оператора.
recipients = self.notified_services()
else:
recipients = []
services = self._managed_services_for(recipients) services = self._managed_services_for(recipients)
statuses = {service: current(self.status_log, service) for service in services} log = list(card.status_log) if card is not None else []
elapsed = self.timers.measured_ms(TimerCode.DDS_ACK) statuses = {service: current(log, service) for service in services}
limit = self.timers.limits[TimerCode.DDS_ACK] overdue = False
overdue = has_active_dds_card and self.dispatched_at is not None and ( if card is not None:
elapsed is None elapsed = card.timers.measured_ms(TimerCode.DDS_ACK)
and (now_utc() - self.dispatched_at).total_seconds() * 1000 > limit limit = card.timers.limits[TimerCode.DDS_ACK]
overdue = elapsed is None and (
(now_utc() - card.dispatched_at).total_seconds() * 1000 > limit
) )
now_mono = time.monotonic() now_mono = time.monotonic()
queue_cards: list[DdsQueueCard] = [] queue_cards: list[DdsQueueCard] = []
for item in sorted(self.dds_live_cards, key=lambda queued: queued.original_index): for item in (desk.ordered() if self.dds_phase else []):
recipients_for_card = list(item.dispatched_card.notify) recipients_for_card = list(item.dispatched_card.notify)
managed = self._managed_services_for(recipients_for_card) managed = self._managed_services_for(recipients_for_card)
service = managed[0] if managed else None service = managed[0] if managed else None
@ -395,60 +305,62 @@ class SessionState:
elapsed_ms=elapsed_ms, elapsed_ms=elapsed_ms,
limit_ms=limit_ms, limit_ms=limit_ms,
timer_stopped=stopped, timer_stopped=stopped,
active=item.card_id == self.dds_active_card_id, active=item.card_id == desk.active_id,
)) ))
shows_card = card is not None or not desk.scenarios
return StationSnapshot( return StationSnapshot(
services=services, services=services,
recipient_services=recipients, recipient_services=recipients,
managed_service=services[0] if services else None, managed_service=services[0] if services else None,
statuses=statuses, statuses=statuses,
available={service: list(NEXT[status]) for service, status in statuses.items()}, available={service: list(NEXT[status]) for service, status in statuses.items()},
card=(card_status(self.status_log, services, notify_deadline_passed=overdue) card=(card_status(log, services, notify_deadline_passed=overdue)
if has_active_dds_card or not self.dds_scenarios else CardStatus.REGISTERED), if shows_card else CardStatus.REGISTERED),
log=list(self.status_log), log=log,
crew_options=(self.crew_options() if has_active_dds_card or not self.dds_scenarios else []), crew_options=(self._crew_options_for(services) if shows_card else []),
crew_selected=self.crew_selected if has_active_dds_card else None, crew_selected=card.crew_selected if card is not None else None,
zone_decision=( zone_decision=(
next((detail == "в зоне" for action, _at, detail in reversed(self.dds_log) next((detail == "в зоне" for action, _at, detail in reversed(card.dds_log)
if action == "zone.decision"), None) if action == "zone.decision"), None)
if has_active_dds_card else None if card is not None else None
), ),
phone_reports=list(self.phone_reports) if has_active_dds_card else [], phone_reports=list(card.phone_reports) if card is not None else [],
phone_lines=list(self.phone_lines) if has_active_dds_card else [], phone_lines=list(card.phone_lines) if card is not None else [],
phone_pending=self.phone_pending if has_active_dds_card else None, phone_pending=card.phone_pending if card is not None else None,
card_id=(self.dispatched_card.card_id if self.dispatched_card and has_active_dds_card else None), card_id=card.card_id if card is not None else None,
card_index=(self.dds_card_index + 1 if has_active_dds_card card_index=(desk.card_index + 1 if card is not None
else min(self.dds_next_scenario_index + 1, len(self.dds_scenarios))), else min(desk.next_index + 1, len(desk.scenarios))),
card_total=len(self.dds_scenarios) or 1, card_total=len(desk.scenarios) or 1,
reply_text=self.reply_text if has_active_dds_card else "", reply_text=card.reply_text if card is not None else "",
completed_cards=[DdsCardSummary(card_id=item.card_id, completed_cards=[DdsCardSummary(card_id=item.card_id,
scenario_id=item.scenario_id, scenario_id=item.scenario_id,
score_auto=item.score_auto) score_auto=item.score_auto)
for item in self.dds_completed], for item in desk.completed],
queue_cards=queue_cards, queue_cards=queue_cards,
pending_cards_count=max( pending_cards_count=max(
0, len(self.dds_scenarios) - len(self.dds_completed) 0, len(desk.scenarios) - len(desk.completed)
- (1 if has_active_dds_card else 0), - (1 if card is not None else 0),
), ),
next_arrival_in_seconds=( next_arrival_in_seconds=(
max(0, int((self.dds_next_arrival_at - now_utc()).total_seconds() + 0.999)) max(0, int((desk.next_arrival_at - now_utc()).total_seconds() + 0.999))
if self.dds_next_arrival_at is not None else None if desk.next_arrival_at is not None else None
), ),
max_waiting_cards=self.dds_max_waiting, max_waiting_cards=desk.max_waiting,
) )
def card_received_event(self): def card_received_event(self):
"""Снимок карточки для станции ДДС.""" """Снимок карточки для станции ДДС."""
from app.domain.events import CardReceived from app.domain.events import CardReceived
card = self.desk.active
return CardReceived( return CardReceived(
card=self.dispatched_card, card=card.dispatched_card if card is not None else self.dispatched_card,
from_operator=("учебный сценарий" if self.exercise is Exercise.DDS from_operator=("учебный сценарий" if self.exercise is Exercise.DDS
or (self.handoff_to_dds and self.dds_card_index > 0) or (self.handoff_to_dds and self.desk.card_index > 0)
else self.trainee_name or "оператор 112"), else self.trainee_name or "оператор 112"),
at=self.dispatched_at or now_utc(), at=(card.dispatched_at if card is not None else self.dispatched_at) or now_utc(),
card_index=self.dds_card_index + 1, card_index=self.desk.card_index + 1,
card_total=len(self.dds_scenarios) or 1, card_total=len(self.desk.scenarios) or 1,
) )
@property @property
@ -458,20 +370,22 @@ class SessionState:
def snapshot(self) -> SessionSnapshot: def snapshot(self) -> SessionSnapshot:
"""Полное состояние. Монитор в классе включают посреди занятия — """Полное состояние. Монитор в классе включают посреди занятия —
он обязан показать текущее, а не ждать следующего события.""" он обязан показать текущее, а не ждать следующего события."""
card = self.shown_card()
return SessionSnapshot( return SessionSnapshot(
session_id=self.session_id, session_id=self.session_id,
scenario_id=self.scenario_id, scenario_id=card.scenario.id if card is not None else self.scenario_id,
scenario_title=self.scenario_title, scenario_title=card.scenario.title if card is not None else self.scenario_title,
level=self.level, level=card.scenario.level.value if card is not None else self.level,
mode=self.mode, mode=self.mode,
exercise=self.exercise, exercise=self.exercise,
criteria=self.criteria, criteria=self.criteria,
trainee_name=self.trainee_name, trainee_name=self.trainee_name,
started_at=self.started_at, started_at=self.started_at,
kio=self.kio, kio=card.kio if card is not None else self.kio,
required_fields=self.required_fields, required_fields=(list(card.scenario.required_fields) if card is not None
else self.required_fields),
transcript=list(self.transcript), transcript=list(self.transcript),
timers=self.timers.snapshot(), timers=self.shown_timers().snapshot(),
hints_used=len(self.hints_shown), hints_used=len(self.hints_shown),
ended=self.ended, ended=self.ended,
) )

View file

@ -10,9 +10,16 @@
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime
from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for from app.domain.timers import NORMATIVES, TimerCode, TimerSnapshot, state_for
def now_utc() -> datetime:
"""Часы серверные. Метрика, посчитанная по часам браузера, недоказуема."""
return datetime.now(UTC)
#: Какое событие какой таймер запускает. #: Какое событие какой таймер запускает.
#: #:
#: `dds_notify` (≤ 60 с) не запускается ничем, и это сознательно. Стартуй он #: `dds_notify` (≤ 60 с) не запускается ничем, и это сознательно. Стартуй он

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,824 @@
{
"dds_several_cards": {
"score_auto": 53.3,
"card_status": "processed",
"metrics": [
[
"dds_primary",
"Карточка 1: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 1: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 1: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 1: Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Карточка 1: Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 1: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 1: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 2: СМП: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 2: СМП: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 2: СМП: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 2: СМП: назначение бригады",
"бригада не выбрана",
false,
2.0,
null
],
[
"dds_progress",
"Карточка 2: СМП: ведение хода реагирования",
"статусов хода нет",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 2: СМП: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 2: СМП: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.",
true,
1.0,
null
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 3: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 3: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 3: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 3: Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Карточка 3: Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 3: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 3: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 3: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
]
],
"findings": [
[
"D5",
"Карточка 1: Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D2",
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
"бригада не выбрана, после приёма статусов не было"
],
[
"D6",
"Карточка 2: СМП: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D5",
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 3: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
56.0,
"Принято, бригада направлена",
[
"card.ack",
"card.status",
"crew.select",
"card.status",
"zone.decision",
"card.reply"
]
],
[
"t20-2-stroke",
48.0,
"",
[
"card.ack",
"card.status"
]
],
[
"t01-1-fire-container",
56.0,
"",
[
"card.ack",
"card.status",
"crew.select",
"card.status",
"zone.decision"
]
]
]
},
"dds_early_finish": {
"score_auto": 35.3,
"card_status": "processed",
"metrics": [
[
"dds_primary",
"Карточка 1: СМП: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 1: СМП: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 1: СМП: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 1: СМП: назначение бригады",
"бригада не выбрана",
false,
2.0,
null
],
[
"dds_progress",
"Карточка 1: СМП: ведение хода реагирования",
"статусов хода нет",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 1: СМП: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 1: СМП: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.",
true,
1.0,
null
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 2: Служба 101: решение по карточке",
"решение отсутствует",
false,
2.0,
null
],
[
"dds_ack",
"Карточка 2: Служба 101: первичная отметка за 30 с",
"не отмечено",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; время обработки не зафиксировано",
false,
1.5,
0.0
]
],
"findings": [
[
"D2",
"Карточка 1: СМП: «Принята» без назначения бригады и хода реагирования",
"бригада не выбрана, после приёма статусов не было"
],
[
"D6",
"Карточка 1: СМП: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D1",
"Карточка 2: Служба 101: статус реагирования не проставлен",
"карточка осталась без отметки"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; время обработки не зафиксировано"
]
],
"cards": [
[
"t20-2-stroke",
48.0,
"",
[
"card.ack",
"card.status"
]
],
[
"fire-apartment-l2",
0.0,
"",
[]
]
]
},
"handoff_112_to_dds": {
"score_auto": 47.8,
"card_status": "processed",
"metrics": [
[
"incident_signs",
"Признаки происшествия",
"не определён",
false,
2.0,
null
],
[
"address",
"Адрес происшествия",
"улица Ленина, 14",
true,
2.0,
null
],
[
"victims_count",
"Число пострадавших",
"не указано",
false,
1.0,
null
],
[
"required_fields",
"Обязательные поля карточки",
"пусто: floor, incident_type, victims_count",
false,
2.0,
null
],
[
"card_fill_time",
"Время заполнения карточки",
"0 с (на 180 с быстрее норматива)",
true,
1.5,
1.0
],
[
"description_grammar",
"Грамматика описания происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания",
false,
1.0,
null
],
[
"dds_primary",
"Карточка 1: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 1: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 1: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 1: Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Карточка 1: Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 1: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 1: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
],
[
"dds_work_time",
"Карточка 1: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
],
[
"dds_primary",
"Карточка 2: Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Карточка 2: Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Карточка 2: Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Карточка 2: Служба 101: назначение бригады",
"бригада не выбрана",
false,
2.0,
null
],
[
"dds_progress",
"Карточка 2: Служба 101: ведение хода реагирования",
"статусов хода нет",
false,
2.0,
null
],
[
"dds_completion",
"Карточка 2: Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Карточка 2: Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.",
true,
1.0,
null
],
[
"dds_work_time",
"Карточка 2: Отработка карточки ДДС",
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
false,
1.5,
0.0
]
],
"findings": [
[
"E2",
"Признаки происшествия: не определён; требуется 1050201",
"не определён"
],
[
"E5",
"Число пострадавших: не указано; требуется 2",
"не указано"
],
[
"E5",
"Обязательные поля карточки: пусто: floor, incident_type, victims_count; требуется все обязательные поля заполнены",
"пусто: floor, incident_type, victims_count"
],
[
"E4",
"Грамматическая ошибка в описании происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания"
],
[
"D5",
"Карточка 1: Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
[
"E3",
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
],
[
"D2",
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
"бригада не выбрана, после приёма статусов не было"
],
[
"D6",
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
"E3",
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
56.0,
"",
[
"card.ack",
"card.status",
"crew.select",
"card.status",
"zone.decision"
]
],
[
"t01-1-fire-container",
48.0,
"",
[
"card.ack",
"card.status"
]
]
]
},
"call_live_dispatcher": {
"score_auto": 62.8,
"card_status": "processed",
"metrics": [
[
"answer_time",
"Ответ на звонок",
"0 с",
true,
1.0,
null
],
[
"interview_time",
"Опрос / идентификация ситуации",
"0 с",
true,
1.5,
null
],
[
"incident_signs",
"Признаки происшествия",
"жилой дом · балкон · открытое пламя",
true,
2.0,
null
],
[
"address",
"Адрес",
"улица Ленина, 14",
true,
2.0,
null
],
[
"victims_count",
"Число пострадавших",
"не указано",
false,
1.0,
null
],
[
"required_fields",
"Обязательные поля КИО",
"пусто: floor, incident_type, dds, victims_count",
false,
2.0,
null
],
[
"description_grammar",
"Грамматика описания происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания",
false,
1.0,
null
],
[
"dds_primary",
"Служба 101: решение по карточке",
"accepted",
true,
2.0,
null
],
[
"dds_ack",
"Служба 101: первичная отметка за 30 с",
"0.0 с",
true,
1.0,
null
],
[
"dds_decision",
"Служба 101: решение по эталону сценария",
"accepted",
true,
2.0,
null
],
[
"dds_crew",
"Служба 101: назначение бригады",
"Служба 101 — бригада №1",
true,
2.0,
null
],
[
"dds_progress",
"Служба 101: ведение хода реагирования",
"responding",
false,
2.0,
null
],
[
"dds_completion",
"Служба 101: закрытие работ",
"не завершено",
false,
1.0,
null
],
[
"dds_reply",
"Служба 101: основание статусов и сведения о ходе работ",
"Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный.; Бригада выехала, доклад дежурному",
false,
1.0,
null
]
],
"findings": [
[
"E5",
"Пострадавших в карточке не указано, верно — 2",
"не указано"
],
[
"E5",
"Не заполнены обязательные поля: floor, incident_type, dds, victims_count",
"пусто: floor, incident_type, dds, victims_count"
],
[
"E4",
"Грамматическая ошибка в описании происшествия",
"предложение начинается со строчной буквы; нет завершающего знака препинания"
],
[
"D5",
"Служба 101: комментарии к статусам не разделяют основание и сведения",
"неполные комментарии: Начало реагирования"
],
[
"D6",
"Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
]
],
"cards": []
}
}

View file

@ -436,7 +436,7 @@ def test_handoff_report_and_checklist_follow_operator_scenario(client, monkeypat
wait_for(lambda: hub.get(session_id).score) wait_for(lambda: hub.get(session_id).score)
state = hub.get(session_id) state = hub.get(session_id)
# Предусловие ошибки: к завершению активна не карточка курсанта. # Предусловие ошибки: к завершению активна не карточка курсанта.
assert state.scenario_id == "t01-1-fire-container" assert state.desk.active.scenario.id == "t01-1-fire-container"
operator = store.get("fire-apartment-l2") operator = store.get("fire-apartment-l2")
checklist = client.get(f"/api/sessions/{session_id}/checklist").json() checklist = client.get(f"/api/sessions/{session_id}/checklist").json()

View file

@ -31,11 +31,12 @@ from app.domain.events import Exercise, SessionMode
from app.domain.roles import Role from app.domain.roles import Role
from app.main import LIBRARY, app from app.main import LIBRARY, app
from app.scenarios import store from app.scenarios import store
from app.session.dds import prepare_card
from app.session.checkpoint import load_state from app.session.checkpoint import load_state
from app.session.dds import build_card
from app.session.hub import hub from app.session.hub import hub
from app.session.journal import DbJournal, SessionLeaseLost from app.session.journal import DbJournal, SessionLeaseLost
from app.session.state import SessionState from app.session.state import SessionState
from app.session.timers import SessionTimers
@pytest.fixture @pytest.fixture
@ -894,16 +895,10 @@ async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres(
scenario = store.get(scenario_id) scenario = store.get(scenario_id)
assert scenario is not None and scenario.student_card is not None assert scenario is not None and scenario.student_card is not None
state = SessionState( card = build_card(scenario, 0, SessionTimers().limits)
session_id=UUID("00000000-0000-4000-8000-000000000702"), assert card.dispatched_card.address == kio["address"]
scenario_id=scenario.id, scenario_title=scenario.title, assert card.dispatched_card.caller_name == kio["caller_name"]
level=scenario.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, assert card.dispatched_card.fire.object_kind == "мусорный контейнер"
)
prepare_card(state, scenario)
assert state.dispatched_card is not None
assert state.dispatched_card.address == kio["address"]
assert state.dispatched_card.caller_name == kio["caller_name"]
assert state.dispatched_card.fire.object_kind == "мусорный контейнер"
# Simulate a peer backend with a cold process-local scenario cache: # Simulate a peer backend with a cold process-local scenario cache:
# scenario.start must resolve the approved row from shared Postgres. # scenario.start must resolve the approved row from shared Postgres.
@ -950,9 +945,9 @@ async def test_kio_submission_moderation_and_dds_card_persist_on_real_postgres(
deadline = time.monotonic() + 3 deadline = time.monotonic() + 3
while time.monotonic() < deadline: while time.monotonic() < deadline:
state = hub.get(session_id) state = hub.get(session_id)
if state and any( if state and state.desk.active and any(
mark.service == service and mark.status.value == expected mark.service == service and mark.status.value == expected
for mark in state.status_log for mark in state.desk.active.status_log
): ):
break break
time.sleep(0.01) time.sleep(0.01)

View file

@ -0,0 +1,127 @@
"""Пульт ДДС без сокетов и хаба: очередь, переключение, завершение, темп."""
from datetime import timedelta
from pathlib import Path
from uuid import uuid4
from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.dds import DdsCardRecord, DdsDesk
from app.session.timers import SessionTimers, now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
NAMES = ("fire-apartment-l2.yaml", "tickets/t20-2-stroke.yaml",
"tickets/t01-1-fire-container.yaml")
def scenarios(count: int = 3):
return [load_file(LIBRARY / name, LIBRARY) for name in NAMES[:count]]
def record(card) -> DdsCardRecord:
return DdsCardRecord(card_id=card.card_id, scenario_id=card.scenario.id, reply_text="",
metrics=[], findings=[], actions=[], duration_ms=0)
def started(interval: int = 0, max_waiting: int = 3, now=None) -> DdsDesk:
desk = DdsDesk()
desk.start(scenarios(), limits=SessionTimers().limits,
arrival_interval_seconds=interval, max_waiting=max_waiting, now=now or now_utc())
return desk
def test_first_card_becomes_active_and_rest_wait_when_no_pace():
desk = started()
assert [card.scenario.id for card in desk.ordered()] == [
"fire-apartment-l2", "t20-2-stroke", "t01-1-fire-container"]
assert desk.active is desk.ordered()[0]
assert desk.next_arrival_at is None
def test_paced_queue_delivers_next_card_only_after_interval():
start = now_utc()
desk = started(interval=60, now=start)
first = desk.active
assert desk.deliver_due(start + timedelta(seconds=59)) == 0
assert desk.deliver_due(start + timedelta(seconds=61)) == 1
assert len(desk.cards) == 2
assert desk.active is first
def test_waiting_cap_holds_back_delivery():
desk = started(max_waiting=1)
assert len(desk.cards) == 2
assert desk.next_index == 2
def test_open_switches_by_reference_without_copying_fields():
desk = started()
first, second = desk.ordered()[:2]
first.status_log.append(object())
assert desk.open(second.card_id)
second.reply_text = "ответ по второй"
assert desk.open(first.card_id)
assert desk.active is first
assert len(first.status_log) == 1 and second.status_log == []
assert second.reply_text == "ответ по второй" and first.reply_text == ""
assert desk.card_index == 0
def test_open_unknown_card_keeps_active():
desk = started()
active = desk.active
assert not desk.open(uuid4())
assert desk.active is active
def test_complete_moves_to_following_card_and_keeps_record():
desk = started()
first, second, _third = desk.ordered()
following = desk.complete(first.card_id, record(first))
assert following is second and desk.active is second
assert first.card_id not in desk.cards
assert desk.is_completed(first.card_id)
def test_completing_last_card_leaves_desk_between_cards_with_last_index():
desk = started(interval=60, max_waiting=1)
only = desk.active
assert desk.complete(only.card_id, record(only)) is None
assert desk.active is None and desk.active_id is None
assert desk.card_index == 0
assert desk.deliver_due(now_utc() + timedelta(seconds=61)) == 1
assert desk.active.scenario.id == "t20-2-stroke"
def test_each_card_runs_own_decision_timer_with_configured_limit():
desk = DdsDesk()
limits = {**SessionTimers().limits, TimerCode.DDS_ACK: 45_000}
desk.start(scenarios(2), limits=limits, now=now_utc())
first, second = desk.ordered()
assert first.timers is not second.timers
assert first.timers.limits[TimerCode.DDS_ACK] == 45_000
assert TimerCode.DDS_ACK in second.timers.timers
def test_card_status_mark_lands_in_that_card_only():
desk = started()
first, second = desk.ordered()[:2]
service = first.dispatched_card.notify[0]
first.set_service_status(service, ServiceStatus.ACCEPTED, "Основание: доклад")
assert [mark.status for mark in first.status_log] == [ServiceStatus.ACCEPTED]
assert second.status_log == []

View file

@ -0,0 +1,172 @@
"""Чекпойнт пульта ДДС: формат прежний, чтения ничего не меняют.
`golden/checkpoint_before_desk.json` записан кодом до `DdsDesk`: занятие,
поднятое из такого снимка, продолжается с той же активной карточкой.
"""
import asyncio
import json
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from pydantic import TypeAdapter
from app.api.ws import station as station_ws
from app.domain.events import Exercise, SessionMode, StationToServer
from app.domain.kio import KIO
from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session import checkpoint
from app.session import finish as finish_module
from app.session.checkpoint import dump_state, load_state
from app.session.dds import prepare_handoff_queue
from app.session.hub import hub
from app.session.state import SessionState
from app.session.timers import now_utc
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
BEFORE_DESK = json.loads(
(Path(__file__).parent / "golden" / "checkpoint_before_desk.json").read_text()
)
_events = TypeAdapter(StationToServer)
@pytest.fixture(autouse=True)
def offline_finish(monkeypatch):
class NoCoaching:
def model_dump(self, **_kwargs):
return {}
async def no_coach(_metrics):
return NoCoaching()
monkeypatch.setattr(finish_module, "coach", no_coach)
monkeypatch.setattr(hub, "journal", None)
def restored(name: str, **changes):
payload = {**json.loads(json.dumps(BEFORE_DESK[name])), **changes}
return load_state(payload, now_utc())
def station(state, **payload) -> None:
hub.register(state)
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
def test_dds_checkpoint_before_desk_resumes_same_active_card_and_work():
state = restored("dds")
saved = BEFORE_DESK["dds"]
assert str(state.desk.active_id) == saved["dds_active_card_id"]
assert state.desk.active.original_index == 1
assert state.desk.active.reply_text == "Ответ по второй карточке"
first = state.desk.ordered()[0]
assert first.status_log[-1].status is ServiceStatus.ACCEPTED
assert first.crew_selected == saved["dds_live_cards"][0]["crew_selected"]
assert first.timers.measured_ms(TimerCode.DDS_ACK) is not None
station(state, type="card.next", card_id=saved["dds_active_card_id"])
assert [record.card_id for record in state.desk.completed] == [
UUID(saved["dds_active_card_id"])]
assert state.desk.active.original_index == 2
assert state.station_snapshot().card_index == 3
def test_legacy_checkpoint_without_active_id_opens_first_card():
payload = json.loads(json.dumps(BEFORE_DESK["dds"]))
del payload["dds_active_card_id"]
state = load_state(payload, now_utc())
assert state.desk.active.original_index == 0
assert state.station_snapshot().card_index == 1
def test_call_checkpoint_before_desk_moves_dispatcher_work_to_desk_card():
state = restored("call")
saved = BEFORE_DESK["call"]
card = state.desk.active
assert card.card_id == state.dispatched_card.card_id
assert card.crew_selected == saved["crew_selected"]
assert [mark.status for mark in card.status_log] == [ServiceStatus.ACCEPTED]
assert card.timers is state.timers
snapshot = state.station_snapshot()
assert snapshot.statuses == {snapshot.managed_service: ServiceStatus.ACCEPTED}
assert snapshot.queue_cards == []
state.ended_at = now_utc()
asyncio.run(finish_module.finish(state.session_id, state))
crew = next(item for item in state.score["metrics"] if item["key"] == "dds_crew")
assert crew["passed"] and crew["fact"] == saved["crew_selected"]
assert any(item["key"] == "interview_time" for item in state.score["metrics"])
def test_handoff_checkpoint_before_desk_keeps_operator_card_and_queue():
state = restored("handoff")
assert state.operator_kio.address == "улица Ленина, 14"
assert [item.id for item in state.desk.scenarios] == [
"fire-apartment-l2", "t01-1-fire-container"]
assert state.desk.active.original_index == 0
assert state.desk.active.status_log[-1].status is ServiceStatus.ACCEPTED
@pytest.mark.parametrize("name", ["dds", "call", "handoff"])
def test_new_checkpoint_keeps_json_keys_of_format_before_desk(name):
assert set(dump_state(restored(name))) == set(BEFORE_DESK[name])
@pytest.mark.parametrize("name", ["dds", "call", "handoff"])
def test_reads_do_not_change_state(name, monkeypatch):
monkeypatch.setattr(checkpoint.time, "monotonic", lambda: 1_000_000.0)
state = restored(name)
active = state.desk.active
first = dump_state(state)
state.station_snapshot()
state.snapshot()
state.card_received_event()
assert dump_state(state) == first
assert state.desk.active is active
@pytest.mark.parametrize("name", ["dds", "handoff"])
def test_finish_scores_every_card_without_switching_active(name):
state = restored(name)
active = state.desk.active
unscored = {card.card_id for card in state.desk.cards.values()}
state.ended_at = now_utc()
asyncio.run(finish_module.finish(state.session_id, state))
assert state.desk.active is active
assert {record.card_id for record in state.desk.completed} == unscored
def test_handoff_keeps_operator_timers_for_card_fill_metric():
"""До пульта связка подменяла таймеры оператора таймерами карточки ДДС,
и время заполнения карточки 112 в оценку не попадало."""
first = load_file(LIBRARY / "fire-apartment-l2.yaml", LIBRARY)
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CARD, handoff_to_dds=True, scenario=first,
)
state.on_event("card.start")
state.kio = KIO(address="улица Ленина, 14", notify=["Служба 101"])
state.on_event("card.submit")
state.dispatch()
prepare_handoff_queue(state, [])
assert state.timers.measured_ms(TimerCode.CARD_FILL) is not None
assert state.desk.active.timers is not state.timers
assert load_state(dump_state(state), now_utc()).timers.measured_ms(
TimerCode.CARD_FILL) is not None

View file

@ -0,0 +1,194 @@
"""Оценки finish() по пульту ДДС закреплены эталоном (lct-dds-desk).
Эталон `golden/dds_desk_scores.json` снят с кода до переноса живых карточек
в `DdsDesk`: перенос меняет владельца состояния, а не правила оценки.
Одно отличие намеренное: в связке 112→ДДС `card_fill_time` теперь считается
по таймерам оператора — до пульта их подменяли таймеры карточки ДДС.
Пересобрать эталон — `UPDATE_GOLDEN=1 pytest tests/test_dds_desk_scores.py`,
только если правила оценки меняются намеренно.
"""
import asyncio
import json
import os
from pathlib import Path
from uuid import uuid4
import pytest
from pydantic import TypeAdapter
from app.api.ws import station as station_ws
from app.domain.events import Exercise, SessionMode, StationToServer
from app.domain.kio import KIO
from app.scenarios.loader import load_file
from app.session import finish as finish_module
from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.hub import hub
from app.session.state import SessionState
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
GOLDEN = Path(__file__).parent / "golden" / "dds_desk_scores.json"
COMMENT = "Основание: доклад старшего.\nСведения: карточка принята, адресат — дежурный."
_events = TypeAdapter(StationToServer)
@pytest.fixture(autouse=True)
def offline_finish(monkeypatch):
class NoCoaching:
def model_dump(self, **_kwargs):
return {}
async def no_coach(_metrics):
return NoCoaching()
monkeypatch.setattr(finish_module, "coach", no_coach)
monkeypatch.setattr(hub, "journal", None)
def scenario(name: str):
return load_file(LIBRARY / name, LIBRARY)
def station(state: SessionState, **payload) -> None:
hub.register(state)
asyncio.run(station_ws._handle(state.session_id, state, _events.validate_python(payload)))
def active_id(state: SessionState):
return state.station_snapshot().card_id
def service_of(state: SessionState) -> str:
return state.station_snapshot().managed_service
def work_card(state: SessionState) -> None:
"""Принять, назначить бригаду и довести до выезда — типичный путь курсанта."""
station(state, type="card.ack", comment=COMMENT)
snapshot = state.station_snapshot()
station(state, type="crew.select", crew=snapshot.crew_options[0])
station(state, type="card.status", service=service_of(state),
status="responding", comment="Бригада выехала, доклад дежурному")
station(state, type="zone.decision", in_zone=True)
def summarize(state: SessionState) -> dict:
score = state.score
return {
"score_auto": score["score_auto"],
"card_status": score["card_status"],
"metrics": [
[m["key"], m["title"], m["fact"], m["passed"], m["weight"], m.get("credit")]
for m in score["metrics"]
],
"findings": [[f["code"], f["summary"], f["fact"]] for f in score["findings"]],
"cards": [[card["scenario_id"], card["score_auto"], card["reply_text"],
[action["type"] for action in card["actions"]]]
for card in score["card_results"]],
}
def dds_exercise_several_cards() -> SessionState:
cards = [scenario("fire-apartment-l2.yaml"),
scenario("tickets/t20-2-stroke.yaml"),
scenario("tickets/t01-1-fire-container.yaml")]
state = SessionState(
session_id=uuid4(), scenario_id=cards[0].id, scenario_title=cards[0].title,
level=cards[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, cards)
first = active_id(state)
work_card(state)
station(state, type="card.reply", card_id=str(first), text="Принято, бригада направлена")
station(state, type="card.next", card_id=str(first))
second = active_id(state)
station(state, type="card.ack", comment=COMMENT)
third = next(card.card_id for card in state.station_snapshot().queue_cards
if card.card_id not in {first, second})
station(state, type="card.open", card_id=str(third))
work_card(state)
station(state, type="card.open", card_id=str(second))
station(state, type="station.finish")
return state
def dds_exercise_early_finish() -> SessionState:
cards = [scenario("tickets/t20-2-stroke.yaml"),
scenario("fire-apartment-l2.yaml"),
scenario("tickets/t01-1-fire-container.yaml")]
state = SessionState(
session_id=uuid4(), scenario_id=cards[0].id, scenario_title=cards[0].title,
level=cards[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, cards, max_waiting=1)
station(state, type="card.ack", comment=COMMENT)
station(state, type="station.finish")
return state
def handoff_112_to_dds() -> SessionState:
first = scenario("fire-apartment-l2.yaml")
second = scenario("tickets/t01-1-fire-container.yaml")
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING,
exercise=Exercise.CARD, handoff_to_dds=True, scenario=first,
required_fields=list(first.required_fields),
pending_dds_scenarios=[second],
)
state.on_event("card.start")
state.kio = KIO(
address="улица Ленина, 14", description="горит балкон на третьем этаже",
notify=["Служба 101"],
)
state.on_event("card.submit")
state.dispatch()
prepare_handoff_queue(state, state.pending_dds_scenarios)
first_card = active_id(state)
work_card(state)
station(state, type="card.next", card_id=str(first_card))
station(state, type="card.ack", comment=COMMENT)
station(state, type="station.finish")
return state
def call_with_live_dispatcher() -> SessionState:
first = scenario("fire-apartment-l2.yaml")
state = SessionState(
session_id=uuid4(), scenario_id=first.id, scenario_title=first.title,
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.CALL,
scenario=first, required_fields=list(first.required_fields),
)
state.on_event("call.incoming")
state.on_event("call.answer")
state.patch_kio({"address": "улица Ленина, 14", "description": "горит квартира",
"signs": list(first.signs)})
state.on_event("dds.dispatch")
state.dispatch()
work_card(state)
state.ended_at = state.dispatched_at
asyncio.run(finish_module.finish(state.session_id, state))
return state
CASES = {
"dds_several_cards": dds_exercise_several_cards,
"dds_early_finish": dds_exercise_early_finish,
"handoff_112_to_dds": handoff_112_to_dds,
"call_live_dispatcher": call_with_live_dispatcher,
}
def test_update_golden_when_requested():
if not os.environ.get("UPDATE_GOLDEN"):
pytest.skip("эталон пересобирается только явно")
GOLDEN.parent.mkdir(exist_ok=True)
GOLDEN.write_text(json.dumps({name: summarize(build()) for name, build in CASES.items()},
ensure_ascii=False, indent=1) + "\n")
@pytest.mark.parametrize("name", list(CASES))
def test_finish_scores_match_golden(name):
state = CASES[name]()
assert summarize(state) == json.loads(GOLDEN.read_text())[name]

View file

@ -110,8 +110,8 @@ def complete_phone_call(station, state, expected_phase):
station.send_json( station.send_json(
{ {
"type": "phone.brief", "type": "phone.brief",
"address": state.dispatched_card.address, "address": state.desk.active.dispatched_card.address,
"incident": state.scenario_title, "incident": state.desk.active.scenario.title,
"request": "Прошу подтвердить выезд и сообщить о прибытии", "request": "Прошу подтвердить выезд и сообщить о прибытии",
} }
) )
@ -131,9 +131,9 @@ def test_dds_starts_with_prepared_card_without_call(client):
try: try:
state = hub.get(session_id) state = hub.get(session_id)
assert state.exercise.value == "dds" assert state.exercise.value == "dds"
assert state.dispatched_card is not None assert state.desk.active.dispatched_card is not None
assert state.kio.address == "улица Ленина, 14" assert state.desk.active.kio.address == "улица Ленина, 14"
assert state.kio.notify assert state.desk.active.kio.notify
assert state.timers.measured_ms(TimerCode.ANSWER) is None assert state.timers.measured_ms(TimerCode.ANSWER) is None
with client.websocket_connect(f"/ws/station/{session_id}") as station: with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received") card = read_until(station, "card.received")
@ -162,12 +162,12 @@ def test_repeated_ack_and_crew_selection_do_not_duplicate_dds_log(client):
station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."}) station.send_json({"type": "card.ack", "comment": "Основание: карточка передана диспетчеру."})
station.send_json({"type": "crew.select", "crew": crew}) station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state") read_until(station, "station.state")
assert sum(action == "card.ack" for action, *_ in state.dds_log) == 1 assert sum(action == "card.ack" for action, *_ in state.desk.active.dds_log) == 1
station.send_json({"type": "crew.select", "crew": crew}) station.send_json({"type": "crew.select", "crew": crew})
station.send_json({"type": "zone.decision", "in_zone": True}) station.send_json({"type": "zone.decision", "in_zone": True})
read_until(station, "station.state") read_until(station, "station.state")
assert sum(action == "crew.select" for action, *_ in state.dds_log) == 1 assert sum(action == "crew.select" for action, *_ in state.desk.active.dds_log) == 1
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)
@ -184,14 +184,14 @@ def test_zone_decision_is_one_shot_and_restored_in_station_snapshot(client):
accepted = read_until(station, "station.state")["snapshot"] accepted = read_until(station, "station.state")["snapshot"]
assert accepted["zone_decision"] is True assert accepted["zone_decision"] is True
state = hub.get(session_id) state = hub.get(session_id)
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1 assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": True}) station.send_json({"type": "zone.decision", "in_zone": True})
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1 assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1
station.send_json({"type": "zone.decision", "in_zone": False}) station.send_json({"type": "zone.decision", "in_zone": False})
error = read_until(station, "error") error = read_until(station, "error")
assert "уже записано" in error["message"] assert "уже записано" in error["message"]
assert sum(action == "zone.decision" for action, *_ in state.dds_log) == 1 assert sum(action == "zone.decision" for action, *_ in state.desk.active.dds_log) == 1
station.close() station.close()
with client.websocket_connect(f"/ws/station/{session_id}") as station: with client.websocket_connect(f"/ws/station/{session_id}") as station:
@ -212,11 +212,11 @@ def test_server_randomly_selects_only_from_instructor_filtered_card_pool(
session_id, control = start(client, random_scenario_ids=pool) session_id, control = start(client, random_scenario_ids=pool)
try: try:
state = hub.get(session_id) state = hub.get(session_id)
assert state.scenario_id == "t01-1-fire-container" assert state.desk.active.scenario.id == "t01-1-fire-container"
assert state.dispatched_card is not None assert state.desk.active.dispatched_card is not None
assert [item.id for item in state.dds_scenarios] == pool[::-1] assert [item.id for item in state.desk.scenarios] == pool[::-1]
assert state.dds_next_scenario_index == 2 assert state.desk.next_index == 2
assert state.dds_next_arrival_at is None assert state.desk.next_arrival_at is None
finally: finally:
hub.stop_ticker(session_id) hub.stop_ticker(session_id)
control.__exit__(None, None, None) control.__exit__(None, None, None)
@ -276,7 +276,7 @@ def test_instructor_live_registry_shows_owned_dds_session_and_deadline_state(cli
assert row["dds_snapshot"]["queue_cards"][0]["service_status"] == "added" assert row["dds_snapshot"]["queue_cards"][0]["service_status"] == "added"
assert row["dds_snapshot"]["phone_reports"] == [] assert row["dds_snapshot"]["phone_reports"] == []
state = hub.get(session_id) state = hub.get(session_id)
live_card = state.dds_live_cards[0] live_card = state.desk.ordered()[0]
live_card.timers.on_event("dds.open") live_card.timers.on_event("dds.open")
live_card.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 181 live_card.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 181
overdue_response = client.get("/api/sessions/active") overdue_response = client.get("/api/sessions/active")
@ -304,10 +304,10 @@ def test_instructor_live_registry_includes_current_crew_report(client, monkeypat
state = hub.get(session_id) state = hub.get(session_id)
with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station: with client.websocket_connect(f"/ws/station/{session_id}?role=dds") as station:
read_until(station, "station.state") read_until(station, "station.state")
crew = state.crew_options()[0] crew = state.crew_options(state.desk.active)[0]
station.send_json({"type": "crew.select", "crew": crew}) station.send_json({"type": "crew.select", "crew": crew})
read_until(station, "station.state") read_until(station, "station.state")
service = state.crew_service(crew) service = state.crew_service(state.desk.active, crew)
station.send_json({ station.send_json({
"type": "card.status", "service": service, "status": "accepted", "type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.", "comment": "Основание: профиль полномочий ДДС.\nСведения: карточка принята.",
@ -329,9 +329,9 @@ def test_ticket_dds_card_uses_source_caller_identity_and_phone(client):
session_id, control = start(client, scenario_id="t01-1-fire-container") session_id, control = start(client, scenario_id="t01-1-fire-container")
try: try:
state = hub.get(session_id) state = hub.get(session_id)
assert state.kio.caller_name == "Сидоров Иван Сергеевич" assert state.desk.active.kio.caller_name == "Сидоров Иван Сергеевич"
assert state.kio.caller_number == "916-126-34-71" assert state.desk.active.kio.caller_number == "916-126-34-71"
assert state.kio.caller_contact == "916-126-34-71" assert state.desk.active.kio.caller_contact == "916-126-34-71"
with client.websocket_connect(f"/ws/station/{session_id}") as station: with client.websocket_connect(f"/ws/station/{session_id}") as station:
card = read_until(station, "card.received")["card"] card = read_until(station, "card.received")["card"]
assert card["caller_name"] == "Сидоров Иван Сергеевич" assert card["caller_name"] == "Сидоров Иван Сергеевич"
@ -345,9 +345,9 @@ def test_ticket_with_multiple_witnesses_does_not_guess_caller_name(client):
session_id, control = start(client, scenario_id="t20-1-three-fighting") session_id, control = start(client, scenario_id="t20-1-three-fighting")
try: try:
state = hub.get(session_id) state = hub.get(session_id)
assert state.kio.caller_name is None assert state.desk.active.kio.caller_name is None
assert state.kio.caller_number == "916-123-98-78" assert state.desk.active.kio.caller_number == "916-123-98-78"
assert state.kio.caller_contact == "916-123-98-78" assert state.desk.active.kio.caller_contact == "916-123-98-78"
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)
@ -432,7 +432,7 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
} }
) )
read_until(station, "station.state") read_until(station, "station.state")
assert len(state.reply_log) == 1 assert len(state.desk.active.reply_log) == 1
station.send_json({"type": "station.finish"}) station.send_json({"type": "station.finish"})
read_until(station, "score.ready") read_until(station, "score.ready")
@ -624,10 +624,10 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
read_until(station, "station.state")["snapshot"]["phone_pending"] read_until(station, "station.state")["snapshot"]["phone_pending"]
is None is None
) )
assert not hub.get(session_id).phone_reports assert not hub.get(session_id).desk.active.phone_reports
complete_phone_call(station, hub.get(session_id), "dispatched") complete_phone_call(station, hub.get(session_id), "dispatched")
read_until(station, "station.state") read_until(station, "station.state")
assert len(hub.get(session_id).phone_reports) == 1 assert len(hub.get(session_id).desk.active.phone_reports) == 1
station.send_json({"type": "phone.dial"}) station.send_json({"type": "phone.dial"})
read_until(station, "phone.line") read_until(station, "phone.line")
read_until(station, "station.state") read_until(station, "station.state")
@ -635,7 +635,7 @@ def test_phone_dial_requires_brief_and_rejects_wrong_card_facts(client):
{"type": "phone.check", "text": "Здравствуйте, хорошая погода"} {"type": "phone.check", "text": "Здравствуйте, хорошая погода"}
) )
assert "обстановку" in read_until(station, "error")["message"] assert "обстановку" in read_until(station, "error")["message"]
assert len(hub.get(session_id).phone_reports) == 1 assert len(hub.get(session_id).desk.active.phone_reports) == 1
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)
@ -718,7 +718,7 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
"comment": "Основание: доклад старшего.\nСведения: карточка принята."} "comment": "Основание: доклад старшего.\nСведения: карточка принята."}
) )
snapshot = read_until(station, "station.state")["snapshot"] snapshot = read_until(station, "station.state")["snapshot"]
state.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61 state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
crew = next( crew = next(
option option
for option in snapshot["crew_options"] for option in snapshot["crew_options"]
@ -789,13 +789,13 @@ def test_complete_dds_workflow_with_training_calls_and_status_updates(client):
read_until(station, "score.ready") read_until(station, "score.ready")
score = wait_for(lambda: state.score) score = wait_for(lambda: state.score)
assert score["score_auto"] == 100.0 assert score["score_auto"] == 100.0
assert [report.phase for report in state.phone_reports] == [ assert [report.phase for report in state.desk.active.phone_reports] == [
"dispatched", "dispatched",
"arrived", "arrived",
"working", "working",
"completed", "completed",
] ]
assert all(report.crew == crew for report in state.phone_reports) assert all(report.crew == crew for report in state.desk.active.phone_reports)
assert not score["findings"] assert not score["findings"]
finally: finally:
control.__exit__(None, None, None) control.__exit__(None, None, None)
@ -828,7 +828,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
assert first_row["active"] is True and second_row["active"] is False assert first_row["active"] is True and second_row["active"] is False
first_live = next( first_live = next(
item item
for item in state.dds_live_cards for item in state.desk.cards.values()
if str(item.card_id) == first_card_id if str(item.card_id) == first_card_id
) )
assert TimerCode.DDS_WORK not in first_live.timers.timers assert TimerCode.DDS_WORK not in first_live.timers.timers
@ -860,7 +860,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
assert second["card"]["card_id"] == second_card_id assert second["card"]["card_id"] == second_card_id
second_live = next( second_live = next(
item item
for item in state.dds_live_cards for item in state.desk.cards.values()
if str(item.card_id) == second_card_id if str(item.card_id) == second_card_id
) )
assert TimerCode.DDS_WORK in second_live.timers.timers assert TimerCode.DDS_WORK in second_live.timers.timers
@ -907,7 +907,7 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
read_until(station, "score.ready") read_until(station, "score.ready")
state = hub.get(session_id) state = hub.get(session_id)
assert state.ended and len(state.dds_completed) == 2 assert state.ended and len(state.desk.completed) == 2
assert len(state.score["card_results"]) == 2 assert len(state.score["card_results"]) == 2
assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2" assert state.score["card_results"][0]["scenario_id"] == "fire-apartment-l2"
assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke" assert state.score["card_results"][1]["scenario_id"] == "t20-2-stroke"
@ -965,8 +965,8 @@ def test_each_dds_card_uses_its_own_scenario_weights():
from app.domain.events import Exercise, SessionMode from app.domain.events import Exercise, SessionMode
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.session.dds import prepare_card from app.session.dds import prepare_queue
from app.session.finish import score_current_dds from app.session.finish import score_dds_card
from app.session.state import SessionState from app.session.state import SessionState
root = Path(__file__).resolve().parents[2] / "scenarios" root = Path(__file__).resolve().parents[2] / "scenarios"
@ -982,13 +982,11 @@ def test_each_dds_card_uses_its_own_scenario_weights():
level=base.level.value, level=base.level.value,
mode=SessionMode.TRAINING, mode=SessionMode.TRAINING,
exercise=Exercise.DDS, exercise=Exercise.DDS,
dds_scenarios=[first, second],
) )
prepare_card(state, first) prepare_queue(state, [first, second])
first_record = score_current_dds(state) first_card, second_card = state.desk.ordered()
state.dds_card_index = 1 first_record = score_dds_card(state, first_card)
prepare_card(state, second) second_record = score_dds_card(state, second_card)
second_record = score_current_dds(state)
assert ( assert (
next(item.weight for item in first_record.metrics if item.key == "dds_primary") next(item.weight for item in first_record.metrics if item.key == "dds_primary")
== 7.0 == 7.0

View file

@ -31,8 +31,9 @@ def offline_finish(monkeypatch):
def accept_active_card(state: SessionState) -> None: def accept_active_card(state: SessionState) -> None:
service = state.notified_services()[0] card = state.desk.active
state.set_service_status( service = card.dispatched_card.notify[0]
card.set_service_status(
service, ServiceStatus.ACCEPTED, service, ServiceStatus.ACCEPTED,
"Основание: доклад старшего.\nСведения: карточка принята.", "Основание: доклад старшего.\nСведения: карточка принята.",
) )
@ -41,8 +42,7 @@ def accept_active_card(state: SessionState) -> None:
def assert_active_card_kept(state: SessionState, active_id, active_status) -> None: def assert_active_card_kept(state: SessionState, active_id, active_status) -> None:
assert active_status is not CardStatus.REGISTERED assert active_status is not CardStatus.REGISTERED
assert state.score["card_status"] == active_status.value assert state.score["card_status"] == active_status.value
assert state.dds_active_card_id == active_id assert state.desk.active_id == active_id
assert state.dispatched_card.card_id == active_id
assert len(state.score["card_results"]) == 2 assert len(state.score["card_results"]) == 2
@ -54,8 +54,8 @@ def test_dds_finish_reports_status_of_active_card_not_last_scored():
level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS, level=first.level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
) )
prepare_queue(state, [first, second]) prepare_queue(state, [first, second])
assert len(state.dds_live_cards) == 2 assert len(state.desk.cards) == 2
active_id = state.dds_active_card_id active_id = state.desk.active_id
accept_active_card(state) accept_active_card(state)
active_status = state.station_snapshot().card active_status = state.station_snapshot().card
@ -78,8 +78,8 @@ def test_handoff_finish_reports_status_of_active_card_not_last_scored():
) )
state.dispatch() state.dispatch()
prepare_handoff_queue(state, state.pending_dds_scenarios) prepare_handoff_queue(state, state.pending_dds_scenarios)
assert len(state.dds_live_cards) == 2 assert len(state.desk.cards) == 2
active_id = state.dds_active_card_id active_id = state.desk.active_id
accept_active_card(state) accept_active_card(state)
active_status = state.station_snapshot().card active_status = state.station_snapshot().card

View file

@ -13,11 +13,10 @@ from app.api.http.scenario_submissions import reset_demo_submissions
from app.config import get_settings from app.config import get_settings
from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee from app.db.models import AuditLog, Group, Scenario, ScenarioSubmission, Trainee
from app.domain import ekp from app.domain import ekp
from app.domain.events import Exercise, SessionMode
from app.main import app from app.main import app
from app.scenarios import store from app.scenarios import store
from app.session.dds import prepare_card from app.session.dds import build_card
from app.session.state import SessionState from app.session.timers import SessionTimers
@pytest.fixture @pytest.fixture
@ -182,19 +181,10 @@ def test_submitted_kio_is_kept_intact_and_becomes_the_dds_card_after_approval(cl
assert scenario.student_card.fire.object_kind == "мусорный контейнер" assert scenario.student_card.fire.object_kind == "мусорный контейнер"
assert scenario.student_card.signs == source.signs assert scenario.student_card.signs == source.signs
assert "moderated-kio" in scenario.topics assert "moderated-kio" in scenario.topics
state = SessionState( card = build_card(scenario, 0, SessionTimers().limits)
session_id=UUID("00000000-0000-4000-8000-000000000701"), assert card.dispatched_card.address == scenario.student_card.address
scenario_id=scenario.id, assert card.dispatched_card.caller_name == "Учебный заявитель"
scenario_title=scenario.title, assert card.dispatched_card.fire.object_kind == "мусорный контейнер"
level=scenario.level.value,
mode=SessionMode.TRAINING,
exercise=Exercise.DDS,
)
prepare_card(state, scenario)
assert state.dispatched_card is not None
assert state.dispatched_card.address == scenario.student_card.address
assert state.dispatched_card.caller_name == "Учебный заявитель"
assert state.dispatched_card.fire.object_kind == "мусорный контейнер"
assert any( assert any(
item["id"] == scenario.id and item["source"] == "trainee" item["id"] == scenario.id and item["source"] == "trainee"
for item in client.get("/api/scenarios").json() for item in client.get("/api/scenarios").json()

View file

@ -20,7 +20,7 @@ from app.domain.taxonomy import ErrorCode, Finding, FindingSource
from app.domain.timers import TimerCode from app.domain.timers import TimerCode
from app.scenarios.loader import load_file from app.scenarios.loader import load_file
from app.session.checkpoint import dump_state, load_state from app.session.checkpoint import dump_state, load_state
from app.session.dds import deliver_due_cards, prepare_handoff_queue, prepare_queue from app.session.dds import prepare_handoff_queue, prepare_queue
from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub from app.session.hub import LEASE_FENCED_MESSAGE, SessionHub
from app.session.state import DdsCardRecord, SessionState, now_utc from app.session.state import DdsCardRecord, SessionState, now_utc
@ -37,7 +37,6 @@ def dds_state() -> SessionState:
mode=SessionMode.TRAINING, mode=SessionMode.TRAINING,
exercise=Exercise.DDS, exercise=Exercise.DDS,
scenario=scenario.model_copy(deep=True), scenario=scenario.model_copy(deep=True),
dds_scenarios=[scenario.model_copy(deep=True)],
required_fields=list(scenario.required_fields), required_fields=list(scenario.required_fields),
trainee_name="Курсант для восстановления", trainee_name="Курсант для восстановления",
attempt=2, attempt=2,
@ -48,17 +47,18 @@ def dds_state() -> SessionState:
), ),
) )
state.timers.limits[TimerCode.DDS_ACK] = 45_000 state.timers.limits[TimerCode.DDS_ACK] = 45_000
prepare_queue(state, state.dds_scenarios) prepare_queue(state, [scenario.model_copy(deep=True)])
service = state.notified_services()[0] card = state.desk.active
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер") service = state.card_services(card)[0]
state.crew_selected = state.crew_options()[0] card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу", author="диспетчер")
state.crew_assignments[service] = state.crew_selected card.crew_selected = state.crew_options(card)[0]
state.phone_pending = PhoneCallPending( card.crew_assignments[service] = card.crew_selected
service=service, crew=state.crew_selected, phase="dispatched" card.phone_pending = PhoneCallPending(
service=service, crew=card.crew_selected, phase="dispatched"
) )
state.reply_text = "Сообщение принято, бригада направлена." card.reply_text = "Сообщение принято, бригада направлена."
state.reply_log.append((now_utc(), state.reply_text)) card.reply_log.append((now_utc(), card.reply_text))
state.dds_log.append(("crew.select", now_utc(), state.crew_selected)) card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
return state return state
@ -74,16 +74,17 @@ def test_active_dds_session_round_trips_without_losing_work():
assert restored.session_id == before.session_id assert restored.session_id == before.session_id
assert restored.exercise is Exercise.DDS assert restored.exercise is Exercise.DDS
assert restored.criteria.decision_time_limit_seconds == 45 assert restored.criteria.decision_time_limit_seconds == 45
assert restored.dispatched_card == before.dispatched_card card, card_before = restored.desk.active, before.desk.active
assert restored.status_log == before.status_log assert card.dispatched_card == card_before.dispatched_card
assert restored.crew_selected == before.crew_selected assert card.status_log == card_before.status_log
assert restored.crew_assignments == before.crew_assignments assert card.crew_selected == card_before.crew_selected
assert restored.phone_pending == before.phone_pending assert card.crew_assignments == card_before.crew_assignments
assert restored.reply_text == before.reply_text assert card.phone_pending == card_before.phone_pending
assert card.reply_text == card_before.reply_text
assert restored.processed_station_commands == before.processed_station_commands assert restored.processed_station_commands == before.processed_station_commands
assert restored.dds_scenarios[0].id == before.scenario_id assert restored.desk.scenarios[0].id == before.scenario_id
# Время простоя backend входит в норматив, а не обнуляет таймер. # Время простоя backend входит в норматив, а не обнуляет таймер.
timer = next(item for item in restored.timers.snapshot() if item.code is TimerCode.DDS_ACK) timer = next(item for item in card.timers.snapshot() if item.code is TimerCode.DDS_ACK)
assert timer.elapsed_ms >= 1_900 assert timer.elapsed_ms >= 1_900
assert timer.limit_ms == 45_000 assert timer.limit_ms == 45_000
@ -112,19 +113,19 @@ def completed_card() -> DdsCardRecord:
def test_text_revealed_facts_and_completed_cards_round_trip_whole(): def test_text_revealed_facts_and_completed_cards_round_trip_whole():
before = dds_state() before = dds_state()
before.text_revealed_facts = {"address": "улица Ленина, 14", "floor": "5 этаж, уточнено"} before.text_revealed_facts = {"address": "улица Ленина, 14", "floor": "5 этаж, уточнено"}
before.dds_completed = [completed_card()] before.desk.completed = [completed_card()]
restored = load_state(dump_state(before), now_utc()) restored = load_state(dump_state(before), now_utc())
assert restored.text_revealed_facts == before.text_revealed_facts assert restored.text_revealed_facts == before.text_revealed_facts
assert restored.dds_completed == before.dds_completed assert restored.desk.completed == before.desk.completed
assert restored.dds_completed[0].received_at.tzinfo is not None assert restored.desk.completed[0].received_at.tzinfo is not None
def test_legacy_checkpoint_without_new_keys_gets_defaults(): def test_legacy_checkpoint_without_new_keys_gets_defaults():
before = dds_state() before = dds_state()
before.text_revealed_facts = {"address": "улица Ленина, 14"} before.text_revealed_facts = {"address": "улица Ленина, 14"}
before.dds_completed = [completed_card()] before.desk.completed = [completed_card()]
payload = dump_state(before) payload = dump_state(before)
del payload["text_revealed_facts"] del payload["text_revealed_facts"]
legacy_keys = {"card_id", "scenario_id", "reply_text", "metrics", legacy_keys = {"card_id", "scenario_id", "reply_text", "metrics",
@ -137,8 +138,8 @@ def test_legacy_checkpoint_without_new_keys_gets_defaults():
restored = load_state(payload, now_utc()) restored = load_state(payload, now_utc())
assert restored.text_revealed_facts == {} assert restored.text_revealed_facts == {}
card = restored.dds_completed[0] card = restored.desk.completed[0]
assert card.card_id == before.dds_completed[0].card_id assert card.card_id == before.desk.completed[0].card_id
assert (card.title, card.address, card.description, card.incident_type, assert (card.title, card.address, card.description, card.incident_type,
card.victims_count, card.received_at, card.managed_service) == (None,) * 7 card.victims_count, card.received_at, card.managed_service) == (None,) * 7
assert card.recipient_services == [] assert card.recipient_services == []
@ -164,26 +165,25 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
level=first.level.value, level=first.level.value,
mode=SessionMode.TRAINING, mode=SessionMode.TRAINING,
exercise=Exercise.DDS, exercise=Exercise.DDS,
dds_scenarios=[first, second],
) )
prepare_queue(state, state.dds_scenarios) prepare_queue(state, [first, second])
first_id = state.dispatched_card.card_id first_card = state.desk.active
first_service = state.managed_services()[0] first_id = first_card.card_id
state.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу") first_service = state.card_services(first_card)[0]
state.on_event("card.ack") first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
second_id = state.dds_live_cards[1].card_id first_card.on_event("card.ack")
assert state.activate_dds_card(second_id) second_id = state.desk.ordered()[1].card_id
assert state.desk.open(second_id)
restored = load_state( restored = load_state(
dump_state(state), dump_state(state),
datetime.now(UTC) - timedelta(seconds=2), datetime.now(UTC) - timedelta(seconds=2),
) )
assert restored.dds_active_card_id == second_id assert restored.desk.active_id == second_id
assert restored.dispatched_card.card_id == second_id assert len(restored.desk.cards) == 2
assert len(restored.dds_live_cards) == 2 first_restored = restored.desk.cards[first_id]
first_restored = next(item for item in restored.dds_live_cards if item.card_id == first_id) second_restored = restored.desk.cards[second_id]
second_restored = next(item for item in restored.dds_live_cards if item.card_id == second_id)
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None assert first_restored.timers.measured_ms(TimerCode.DDS_ACK) is not None
second_timer = next( second_timer = next(
@ -209,27 +209,25 @@ def test_delivering_next_dds_card_does_not_clear_previous_card_state():
level=scenarios[0].level.value, level=scenarios[0].level.value,
mode=SessionMode.TRAINING, mode=SessionMode.TRAINING,
exercise=Exercise.DDS, exercise=Exercise.DDS,
dds_scenarios=scenarios,
) )
prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1) prepare_queue(state, scenarios, arrival_interval_seconds=60, max_waiting=1)
first_id = state.dds_live_cards[0].card_id first = state.desk.active
service = state.managed_services()[0] first_id = first.card_id
state.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу") service = state.card_services(first)[0]
state.capture_active_dds() first.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
assert deliver_due_cards(state, now_utc() + timedelta(seconds=61)) == 1 assert state.desk.deliver_due(now_utc() + timedelta(seconds=61)) == 1
first = next(card for card in state.dds_live_cards if card.card_id == first_id) assert state.desk.cards[first_id].status_log[-1].status is ServiceStatus.ACCEPTED
assert first.status_log[-1].status is ServiceStatus.ACCEPTED second = next(card for card in state.desk.ordered() if card.original_index == 1)
second = next(card for card in state.dds_live_cards if card.original_index == 1) assert state.desk.open(second.card_id)
assert state.activate_dds_card(second.card_id)
restored = load_state(dump_state(state), now_utc()) restored = load_state(dump_state(state), now_utc())
first_restored = next(card for card in restored.dds_live_cards if card.card_id == first_id) first_restored = restored.desk.cards[first_id]
assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED assert first_restored.status_log[-1].status is ServiceStatus.ACCEPTED
assert restored.dds_active_card_id == second.card_id assert restored.desk.active_id == second.card_id
assert len(restored.dds_live_cards) == 2 assert len(restored.desk.cards) == 2
assert restored.dds_next_scenario_index == 2 assert restored.desk.next_index == 2
assert restored.dds_next_arrival_at is not None assert restored.desk.next_arrival_at is not None
def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue(): def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
@ -249,10 +247,10 @@ def test_mixed_handoff_checkpoint_preserves_operator_card_and_generated_queue():
assert restored.operator_kio.address == "улица Ленина, 14" assert restored.operator_kio.address == "улица Ленина, 14"
assert restored.operator_scenario.id == first.id assert restored.operator_scenario.id == first.id
assert restored.dds_scenarios[0].id == first.id assert restored.desk.scenarios[0].id == first.id
assert restored.dds_scenarios[1].id == second.id assert restored.desk.scenarios[1].id == second.id
assert len(restored.dds_live_cards) == 2 assert len(restored.desk.cards) == 2
assert restored.dds_active_card_id == state.dds_active_card_id assert restored.desk.active_id == state.desk.active_id
def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels(): def test_checkpoint_storage_failure_fences_and_notifies_all_data_channels():

View file

@ -180,7 +180,7 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client):
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"}) station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
error = read_until(station, "error") error = read_until(station, "error")
assert "комментар" in error["message"] assert "комментар" in error["message"]
assert hub.get(session_id).status_log == [] assert hub.get(session_id).desk.active.status_log == []
command_id = str(uuid4()) command_id = str(uuid4())
accepted = { accepted = {
"type": "card.status", "service": "Служба 101", "status": "accepted", "type": "card.status", "service": "Служба 101", "status": "accepted",
@ -196,7 +196,7 @@ def test_dispatcher_sets_a_status_and_the_card_follows(client):
station.send_json(accepted) station.send_json(accepted)
assert read_until(station, "command.ack")["command_id"] == command_id assert read_until(station, "command.ack")["command_id"] == command_id
runtime = hub.get(session_id) runtime = hub.get(session_id)
assert sum(item.service == "Служба 101" for item in runtime.status_log) == 1 assert sum(item.service == "Служба 101" for item in runtime.desk.active.status_log) == 1
assert "responding" in state["snapshot"]["available"]["Служба 101"] assert "responding" in state["snapshot"]["available"]["Служба 101"]
assert "accepted" not in state["snapshot"]["available"]["Служба 101"] assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
finally: finally:
@ -212,7 +212,7 @@ def test_out_of_order_status_is_rejected_with_a_reason(client):
"comment": "Бригада доложила старшему о прибытии."}) "comment": "Бригада доложила старшему о прибытии."})
error = read_until(station, "error") error = read_until(station, "error")
assert "Принята" in error["message"] assert "Принята" in error["message"]
assert hub.get(session_id).status_log == [] assert hub.get(session_id).desk.active.status_log == []
finally: finally:
close_all(contexts) close_all(contexts)