refactor: правила пульта ДДС перенесены в DdsDesk.apply, сокет станции — только транспорт

This commit is contained in:
gglamer 2026-09-26 22:01:27 +00:00
commit fca353fdd0
5 changed files with 531 additions and 315 deletions

View file

@ -6,11 +6,13 @@
Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан
этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом
в отчёте и становится сорванным выездом с конкретной причиной. в отчёте и становится сорванным выездом с конкретной причиной.
Правила пульта — в `DdsDesk.apply`; здесь разбор команды, отсев повторов,
durable transition и рассылка итога.
""" """
import asyncio import asyncio
import logging import logging
import re
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@ -22,31 +24,14 @@ from app.domain.events import (
CommandAck, CommandAck,
ErrorEvent, ErrorEvent,
ErrorKind, ErrorKind,
Exercise,
PhoneLine,
PhoneReport,
ScoreReady, ScoreReady,
SessionEnded, SessionEnded,
StationState, StationState,
StationToServer, StationToServer,
) )
from app.domain.roles import Role from app.domain.roles import Role
from app.domain.statuses import ( from app.session.finish import finish
PRIMARY,
SERVICE_STATUS_LABELS,
PhoneCallPending,
PhoneLineRecord,
PhoneReportRecord,
ServiceStatus,
StatusError,
current,
)
from app.domain.timers import TimerCode
from app.scoring.address import address_matches
from app.session.dds import DdsLiveCard
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
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -54,313 +39,32 @@ router = APIRouter()
_adapter = TypeAdapter(StationToServer) _adapter = TypeAdapter(StationToServer)
def _start_dds_work_timer(card: DdsLiveCard) -> None:
"""Start the three-minute work clock once, when the card is opened."""
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is None or timer.started_at is None:
card.on_event("dds.open")
#: Команды, которым не нужна активная карточка пульта.
CARDLESS_EVENTS = {"card.open", "station.finish", "card.bounce"}
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REQUIRED_STATUS = {
"dispatched": ServiceStatus.ACCEPTED,
"arrived": ServiceStatus.RESPONDING,
"working": ServiceStatus.ARRIVED,
"completed": ServiceStatus.WORKING,
}
STATUS_AT_OR_AFTER = {
"dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED},
}
def _error(session_id: UUID, message: str) -> None: 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, card: DdsLiveCard, speaker: str, text: str) -> None:
call = card.phone_pending
if call is None:
return
line = PhoneLineRecord(service=call.service, crew=call.crew,
speaker=speaker, text=text, at=now_utc())
card.phone_lines.append(line)
card.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
hub.to_station(session_id, PhoneLine(**line.model_dump()))
def _address_matches(expected: str | None, supplied: str) -> bool:
"""Не даём сообщить бригаде другой номер дома/другую улицу."""
return address_matches(expected, supplied)
def _incident_matches(card: DdsLiveCard, supplied: str) -> bool:
text = supplied.casefold()
# Описание часто начинается с адреса: его нельзя считать совпадением
# характера происшествия. Берём только название сценария и признаки ЕКП.
source = " ".join((card.scenario.title, " ".join(card.kio.signs)))
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)
def _has_purpose(text: str, stems: tuple[str, ...]) -> bool:
normalized = text.casefold()
return len(text.strip()) >= 8 and any(stem in normalized for stem in stems)
def _report_text(phase: str, crew: str, address: str) -> str:
match phase:
case "dispatched":
return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу."
case "arrived":
return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте."
case "working":
return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении."
case _:
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
def _finish_phone_call(session_id: UUID, card: DdsLiveCard) -> None:
call = card.phone_pending
assert call is not None
text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки")
_line(session_id, card, "crew", text)
report = PhoneReportRecord(service=call.service, crew=call.crew,
phase=call.phase, text=text, at=now_utc())
card.phone_reports.append(report)
card.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
card.phone_pending = None
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() ended_at = state.end(CallEndReason.COMPLETE)
state.end_reason = CallEndReason.COMPLETE
for card in state.desk.cards.values():
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is not None and timer.started_at is not None:
card.timers.on_event("dds.finish")
hub.stop_ticker(session_id) hub.stop_ticker(session_id)
hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE))
if hub.journal: if hub.journal:
await hub.journal.session_ended(session_id, state.ended_at, CallEndReason.COMPLETE.value) await hub.journal.session_ended(session_id, ended_at, CallEndReason.COMPLETE.value)
await finish(session_id, state) await finish(session_id, state)
hub.to_station(session_id, ScoreReady(session_id=session_id)) hub.to_station(session_id, ScoreReady(session_id=session_id))
async def _handle(session_id: UUID, state, event) -> None: async def _handle(session_id: UUID, state, event) -> None:
if state.ended: outcome = state.desk.apply(event, state)
_error(session_id, "Занятие уже завершено") if outcome.error is not None:
_error(session_id, outcome.error)
return return
card = state.desk.active for item in outcome.events:
if card is None and event.type not in CARDLESS_EVENTS: hub.to_station(session_id, item)
_error(session_id, "Нет активной карточки") if outcome.finished:
return
match event.type:
case "card.ack":
# Подтверждение приёма — это статус «Принята» у главной службы.
# Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
# диспетчер один и выбирать службу не из чего.
if not event.comment.strip():
_error(session_id, "Для подтверждения приёма добавьте комментарий с основанием")
return
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
return
card.on_event("card.ack")
card.dds_log.append(("card.ack", now_utc(), None))
services = state.card_services(card)
if services:
_start_dds_work_timer(card)
try:
card.set_service_status(
services[0], ServiceStatus.ACCEPTED, event.comment, author="диспетчер"
)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
if event.service not in state.card_services(card):
_error(session_id, "Можно менять статусы только своей ДДС")
return
_start_dds_work_timer(card)
try:
card.set_service_status(
event.service, event.status, event.comment, author="диспетчер"
)
except StatusError as exc:
hub.to_station(
session_id,
ErrorEvent(code=ErrorKind.UNSUPPORTED_EVENT, message=str(exc)),
)
return
# Первичный статус останавливает норматив 30 секунд.
if event.status in PRIMARY:
card.on_event("card.ack")
if event.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:
card.on_event("dds.complete")
case "crew.select":
if event.crew not in state.crew_options(card):
_error(session_id, "Выберите бригаду из списка доступных")
return
service = state.crew_service(card, event.crew)
assigned = card.crew_assignments.get(service)
if assigned and assigned != event.crew and any(
report.service == service for report in card.phone_reports
):
_error(session_id, "После первого доклада бригаду этой службы менять нельзя")
return
if card.phone_pending is not None:
_error(session_id, "Завершите текущий разговор перед сменой бригады")
return
if card.crew_selected == event.crew and assigned == event.crew:
return
card.crew_selected = event.crew
card.crew_assignments[service] = event.crew
card.dds_log.append(("crew.select", now_utc(), event.crew))
case "phone.dial":
if card.phone_pending is not None:
_error(session_id, "Разговор уже идёт: передайте сведения или завершите звонок")
return
crew = card.crew_selected
service = state.crew_service(card, crew) if crew else None
if service is None:
_error(session_id, "Сначала выберите бригаду")
return
previous = [report for report in card.phone_reports if report.service == service]
if len(previous) >= len(REPORT_PHASES):
_error(session_id, "Все доклады этой бригады уже получены")
return
phase = REPORT_PHASES[len(previous)]
required = REQUIRED_STATUS[phase]
if current(card.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
_error(session_id, f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы")
return
card.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase)
card.dds_log.append(("phone.dial", now_utc(), crew))
greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия "
"и что требуется от бригады."
if phase == "dispatched" else
f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.")
_line(session_id, card, "crew", greeting)
case "phone.brief":
call = card.phone_pending
if call is None or call.phase != "dispatched":
_error(session_id, "Сначала соединитесь со старшим группы для передачи вызова")
return
if not _address_matches(card.dispatched_card.address, event.address):
_error(session_id, "Проверьте адрес: улица и номер дома должны совпадать с карточкой")
return
if not _incident_matches(card, event.incident):
_error(session_id, "Уточните характер происшествия по данным карточки")
return
if not _has_purpose(event.request, ("выезд", "выех", "направ", "прибыт",
"реагир", "помощ", "подтверд", "долож")):
_error(session_id, "Сформулируйте задачу: выезд, помощь или доклад бригады")
return
_line(session_id, card, "dispatcher", f"Адрес: {event.address.strip()}. "
f"Происшествие: {event.incident.strip()}. {event.request.strip()}")
_finish_phone_call(session_id, card)
case "phone.check":
call = card.phone_pending
if call is None or call.phase == "dispatched":
_error(session_id, "Сначала передайте вызов, затем запросите обстановку")
return
if not _has_purpose(event.text, ("обстанов", "статус", "прибыл", "доех",
"выех", "работ", "заверш", "мест",
"ход", "сообщ", "долож", "уточн")):
_error(session_id, "Спросите обстановку, прибытие или ход работ по карточке")
return
_line(session_id, card, "dispatcher", event.text.strip())
_finish_phone_call(session_id, card)
case "phone.hangup":
if card.phone_pending is None:
_error(session_id, "Нет активного разговора")
return
card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew))
card.phone_pending = None
case "card.reply":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or (
event.card_id != card.card_id
):
_error(session_id, "Ответ относится не к текущей карточке")
return
# A browser may lose the acknowledgement after the server has
# committed this replace-style value. Reconnect retries are safe:
# don't create another journal row
# when the current card already contains exactly this text.
if card.reply_text == event.text:
return
card.reply_text = event.text
card.reply_log.append((now_utc(), event.text))
case "card.open":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or not state.desk.open(event.card_id):
_error(session_id, "Карточка отсутствует в текущей очереди")
return
_start_dds_work_timer(state.desk.active)
hub.to_station(session_id, state.card_received_event())
# CardReceived carries the contents, while StationState carries
# the status journal and current queue. Send both on every switch
# so the newly opened card cannot briefly inherit the previous
# card's status snapshot until the next periodic tick.
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
case "card.next":
if (state.exercise is not Exercise.DDS and not state.handoff_to_dds) or (
event.card_id != card.card_id
):
_error(session_id, "Следующая карточка недоступна: ID текущей не совпадает")
return
if state.desk.is_completed(event.card_id):
_error(session_id, "Эта карточка уже завершена")
return
# Keep the lesson alive if selected cards have not arrived yet.
# The next delivery may become the active card immediately or
# after its configured interval; no completed card is reused.
if state.desk.complete(card.card_id, score_dds_card(state, card)) is not None:
hub.to_station(session_id, state.card_received_event())
active_before_delivery = state.desk.active_id
state.desk.deliver_due()
if state.desk.active_id and state.desk.active_id != active_before_delivery:
hub.to_station(session_id, state.card_received_event())
hub.to_station(session_id, StationState(snapshot=state.station_snapshot()))
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": if not outcome.changed:
if state.exercise is not Exercise.DDS and not state.handoff_to_dds:
_error(session_id, "Операторское занятие завершается после звонка 112")
return return
await _finish_dds(session_id, state)
case "card.bounce":
_error(
session_id,
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112",
)
return
case "zone.decision":
previous = next(
(detail for action, _at, detail in reversed(card.dds_log)
if action == "zone.decision"),
None,
)
decision = "в зоне" if event.in_zone else "не в зоне"
if previous is not None:
if previous != decision:
_error(session_id, "Решение по зоне уже записано для этой карточки")
return
card.on_event("zone.decision")
card.dds_log.append(("zone.decision", now_utc(), decision))
case "crew.dispatched":
card.kio = card.kio.model_copy(update={"dispatch_order_at": event.at})
card.dds_log.append(("crew.dispatched", now_utc(), None))
case "crew.arrived":
card.on_event("crew.arrived")
card.kio = card.kio.model_copy(update={"arrival_at": event.at})
card.dds_log.append(("crew.arrived", now_utc(), None))
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())

View file

@ -2,31 +2,40 @@
Живое состояние карточки существует в одном экземпляре — в `DdsLiveCard`. Живое состояние карточки существует в одном экземпляре — в `DdsLiveCard`.
Пульт хранит карточки и id активной; переключение меняет id и ничего не Пульт хранит карточки и id активной; переключение меняет id и ничего не
копирует, поэтому обработчики станции пишут прямо в `desk.active`. копирует. Правила команд станции — `DdsDesk.apply`: сокет только разбирает
команду, сохраняет итог и рассылает события.
""" """
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from app.domain import ekp from app.domain import ekp
from app.domain.events import Metric from app.domain.events import Exercise, Metric, PhoneLine, PhoneReport, StationState
from app.domain.kio import KIO, ResponseStatus, apply_patch from app.domain.kio import KIO, ResponseStatus, apply_patch
from app.domain.statuses import ( from app.domain.statuses import (
PRIMARY,
SERVICE_STATUS_LABELS,
PhoneCallPending, PhoneCallPending,
PhoneLineRecord, PhoneLineRecord,
PhoneReportRecord, PhoneReportRecord,
ServiceStatus, ServiceStatus,
StatusEntry, StatusEntry,
StatusError,
check, check,
current,
) )
from app.domain.taxonomy import Finding 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.scoring.address import address_matches
from app.session.timers import SessionTimers, now_utc from app.session.timers import SessionTimers, now_utc
if TYPE_CHECKING:
from app.session.state import SessionState
@dataclass @dataclass
class DdsCardRecord: class DdsCardRecord:
@ -192,6 +201,102 @@ def build_card(
) )
#: Команды, которым не нужна активная карточка пульта.
CARDLESS_COMMANDS = {"card.open", "station.finish", "card.bounce"}
#: Доклады бригады идут строго по порядку; перед каждым звонком служба
#: должна стоять не раньше нужного статуса.
REPORT_PHASES = ("dispatched", "arrived", "working", "completed")
REQUIRED_STATUS = {
"dispatched": ServiceStatus.ACCEPTED,
"arrived": ServiceStatus.RESPONDING,
"working": ServiceStatus.ARRIVED,
"completed": ServiceStatus.WORKING,
}
STATUS_AT_OR_AFTER = {
"dispatched": {ServiceStatus.ACCEPTED, ServiceStatus.RESPONDING,
ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"arrived": {ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"working": {ServiceStatus.ARRIVED, ServiceStatus.WORKING},
"completed": {ServiceStatus.WORKING, ServiceStatus.COMPLETED},
}
@dataclass
class DeskOutcome:
"""Итог команды пульта. Отказ ничего не меняет и событий не несёт."""
#: События станции в порядке отправки.
events: list[Any] = field(default_factory=list)
error: str | None = None
#: Состояние изменилось: транспорт сохраняет его и шлёт свежий снимок.
#: Повтор уже учтённой команды проходит молча, без записи.
changed: bool = False
#: Занятие ДДС дошло до конца; завершает его сессия, а не пульт.
finished: bool = False
def _refused(message: str) -> DeskOutcome:
return DeskOutcome(error=message)
def _start_work_timer(card: DdsLiveCard) -> None:
"""Три минуты на работу идут один раз — с открытия карточки."""
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is None or timer.started_at is None:
card.on_event("dds.open")
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
call = card.phone_pending
if call is None:
return None
line = PhoneLineRecord(service=call.service, crew=call.crew,
speaker=speaker, text=text, at=now_utc())
card.phone_lines.append(line)
card.dds_log.append((f"phone.line.{speaker}", line.at, f"{call.crew}: {text}"))
return PhoneLine(**line.model_dump())
def _incident_matches(card: DdsLiveCard, supplied: str) -> bool:
text = supplied.casefold()
# Описание часто начинается с адреса: его нельзя считать совпадением
# характера происшествия. Берём только название сценария и признаки ЕКП.
source = " ".join((card.scenario.title, " ".join(card.kio.signs)))
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)
def _has_purpose(text: str, stems: tuple[str, ...]) -> bool:
normalized = text.casefold()
return len(text.strip()) >= 8 and any(stem in normalized for stem in stems)
def _report_text(phase: str, crew: str, address: str) -> str:
match phase:
case "dispatched":
return f"{crew}: вызов по адресу {address} принят, выезжаем. О прибытии доложу."
case "arrived":
return f"{crew}: прибыли по адресу {address}. Уточняем обстановку на месте."
case "working":
return f"{crew}: обстановка уточнена, приступили к работам. Сообщим о завершении."
case _:
return f"{crew}: работы завершены. Дальнейшая помощь от нашей бригады не требуется."
def _finish_phone_call(card: DdsLiveCard) -> list[Any]:
call = card.phone_pending
assert call is not None
text = _report_text(call.phase, call.crew, card.dispatched_card.address or "из карточки")
line = _line(card, "crew", text)
report = PhoneReportRecord(service=call.service, crew=call.crew,
phase=call.phase, text=text, at=now_utc())
card.phone_reports.append(report)
card.dds_log.append(("phone.report", now_utc(), f"{call.crew}: {call.phase}"))
card.phone_pending = None
return [line, PhoneReport(**report.model_dump())]
@dataclass @dataclass
class DdsDesk: class DdsDesk:
"""Живые карточки занятия, активная из них и очередь поступления.""" """Живые карточки занятия, активная из них и очередь поступления."""
@ -310,6 +415,207 @@ class DdsDesk:
return delivered return delivered
def apply(self, command: Any, session: "SessionState") -> DeskOutcome:
"""Применить команду станции к пульту: правила карточки без транспорта.
Из сессии берутся упражнение, службы обучающегося и снимки для
станции. Отказ возвращается до первой записи — состояние не меняется.
"""
if session.ended:
return _refused("Занятие уже завершено")
card = self.active
if card is None and command.type not in CARDLESS_COMMANDS:
return _refused("Нет активной карточки")
dds_lesson = session.exercise is Exercise.DDS or session.handoff_to_dds
outcome = DeskOutcome(changed=True)
match command.type:
case "card.ack":
# Подтверждение приёма — это статус «Принята» у главной службы.
# Кнопка осталась ради живой цепочки 112 → ДДС (lct-20), где
# диспетчер один и выбирать службу не из чего.
if not command.comment.strip():
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
return DeskOutcome()
card.on_event("card.ack")
card.dds_log.append(("card.ack", now_utc(), None))
services = session.card_services(card)
if services:
_start_work_timer(card)
try:
card.set_service_status(
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
if command.service not in session.card_services(card):
return _refused("Можно менять статусы только своей ДДС")
try:
check(card.status_log, command.service, command.status, command.comment)
except StatusError as exc:
return _refused(str(exc))
_start_work_timer(card)
card.set_service_status(
command.service, command.status, command.comment, author="диспетчер"
)
# Первичный статус останавливает норматив 30 секунд.
if command.status in PRIMARY:
card.on_event("card.ack")
if command.status in {
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
}:
card.on_event("dds.complete")
case "crew.select":
if command.crew not in session.crew_options(card):
return _refused("Выберите бригаду из списка доступных")
service = session.crew_service(card, command.crew)
assigned = card.crew_assignments.get(service)
if assigned and assigned != command.crew and any(
report.service == service for report in card.phone_reports
):
return _refused("После первого доклада бригаду этой службы менять нельзя")
if card.phone_pending is not None:
return _refused("Завершите текущий разговор перед сменой бригады")
if card.crew_selected == command.crew and assigned == command.crew:
return DeskOutcome()
card.crew_selected = command.crew
card.crew_assignments[service] = command.crew
card.dds_log.append(("crew.select", now_utc(), command.crew))
case "phone.dial":
if card.phone_pending is not None:
return _refused("Разговор уже идёт: передайте сведения или завершите звонок")
crew = card.crew_selected
service = session.crew_service(card, crew) if crew else None
if service is None:
return _refused("Сначала выберите бригаду")
previous = [report for report in card.phone_reports if report.service == service]
if len(previous) >= len(REPORT_PHASES):
return _refused("Все доклады этой бригады уже получены")
phase = REPORT_PHASES[len(previous)]
required = REQUIRED_STATUS[phase]
if current(card.status_log, service) not in STATUS_AT_OR_AFTER[phase]:
return _refused(
f"Перед звонком нужен статус «{SERVICE_STATUS_LABELS[required]}» этой службы"
)
card.phone_pending = PhoneCallPending(service=service, crew=crew, phase=phase)
card.dds_log.append(("phone.dial", now_utc(), crew))
greeting = (f"{crew}, старший группы на связи. Назовите адрес, характер происшествия "
"и что требуется от бригады."
if phase == "dispatched" else
f"{crew}, старший группы на связи. Слушаю ваш запрос по карточке.")
outcome.events.append(_line(card, "crew", greeting))
case "phone.brief":
call = card.phone_pending
if call is None or call.phase != "dispatched":
return _refused("Сначала соединитесь со старшим группы для передачи вызова")
if not address_matches(card.dispatched_card.address, command.address):
return _refused("Проверьте адрес: улица и номер дома должны совпадать с карточкой")
if not _incident_matches(card, command.incident):
return _refused("Уточните характер происшествия по данным карточки")
if not _has_purpose(command.request, ("выезд", "выех", "направ", "прибыт",
"реагир", "помощ", "подтверд", "долож")):
return _refused("Сформулируйте задачу: выезд, помощь или доклад бригады")
outcome.events.append(_line(
card, "dispatcher", f"Адрес: {command.address.strip()}. "
f"Происшествие: {command.incident.strip()}. {command.request.strip()}"))
outcome.events.extend(_finish_phone_call(card))
case "phone.check":
call = card.phone_pending
if call is None or call.phase == "dispatched":
return _refused("Сначала передайте вызов, затем запросите обстановку")
if not _has_purpose(command.text, ("обстанов", "статус", "прибыл", "доех",
"выех", "работ", "заверш", "мест",
"ход", "сообщ", "долож", "уточн")):
return _refused("Спросите обстановку, прибытие или ход работ по карточке")
outcome.events.append(_line(card, "dispatcher", command.text.strip()))
outcome.events.extend(_finish_phone_call(card))
case "phone.hangup":
if card.phone_pending is None:
return _refused("Нет активного разговора")
card.dds_log.append(("phone.hangup", now_utc(), card.phone_pending.crew))
card.phone_pending = None
case "card.reply":
if not dds_lesson or command.card_id != card.card_id:
return _refused("Ответ относится не к текущей карточке")
# A browser may lose the acknowledgement after the server has
# committed this replace-style value. Reconnect retries are safe:
# don't create another journal row
# when the current card already contains exactly this text.
if card.reply_text == command.text:
return DeskOutcome()
card.reply_text = command.text
card.reply_log.append((now_utc(), command.text))
case "card.open":
if not dds_lesson or not self.open(command.card_id):
return _refused("Карточка отсутствует в текущей очереди")
_start_work_timer(self.active)
# CardReceived carries the contents, while StationState carries
# the status journal and current queue. Send both on every switch
# so the newly opened card cannot briefly inherit the previous
# card's status snapshot until the next periodic tick.
outcome.events += [session.card_received_event(),
StationState(snapshot=session.station_snapshot())]
case "card.next":
if not dds_lesson or command.card_id != card.card_id:
return _refused("Следующая карточка недоступна: ID текущей не совпадает")
if self.is_completed(command.card_id):
return _refused("Эта карточка уже завершена")
# Оценка живёт в модуле завершения, который сам зависит от пульта.
from app.session.finish import score_dds_card
# Keep the lesson alive if selected cards have not arrived yet.
# The next delivery may become the active card immediately or
# after its configured interval; no completed card is reused.
if self.complete(card.card_id, score_dds_card(session, card)) is not None:
outcome.events.append(session.card_received_event())
active_before_delivery = self.active_id
self.deliver_due()
if self.active_id and self.active_id != active_before_delivery:
outcome.events.append(session.card_received_event())
outcome.events.append(StationState(snapshot=session.station_snapshot()))
if not self.cards and len(self.completed) >= len(self.scenarios):
self._finish_work()
outcome.finished = True
case "station.finish":
if not dds_lesson:
return _refused("Операторское занятие завершается после звонка 112")
self._finish_work()
outcome.finished = True
case "card.bounce":
return _refused(
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112"
)
case "zone.decision":
previous = next(
(detail for action, _at, detail in reversed(card.dds_log)
if action == "zone.decision"),
None,
)
decision = "в зоне" if command.in_zone else "не в зоне"
if previous is not None:
if previous != decision:
return _refused("Решение по зоне уже записано для этой карточки")
return DeskOutcome()
card.on_event("zone.decision")
card.dds_log.append(("zone.decision", now_utc(), decision))
case "crew.dispatched":
card.kio = card.kio.model_copy(update={"dispatch_order_at": command.at})
card.dds_log.append(("crew.dispatched", now_utc(), None))
case "crew.arrived":
card.on_event("crew.arrived")
card.kio = card.kio.model_copy(update={"arrival_at": command.at})
card.dds_log.append(("crew.arrived", now_utc(), None))
return outcome
def _finish_work(self) -> None:
"""Конец занятия останавливает трёхминутный таймер у каждой начатой карточки."""
for card in self.cards.values():
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is not None and timer.started_at is not None:
card.timers.on_event("dds.finish")
def prepare_queue( def prepare_queue(
state, state,

View file

@ -367,6 +367,12 @@ class SessionState:
def ended(self) -> bool: def ended(self) -> bool:
return self.ended_at is not None return self.ended_at is not None
def end(self, reason: CallEndReason) -> datetime:
"""Закрыть занятие; возвращает момент окончания для журнала."""
self.ended_at = now_utc()
self.end_reason = reason
return self.ended_at
def snapshot(self) -> SessionSnapshot: def snapshot(self) -> SessionSnapshot:
"""Полное состояние. Монитор в классе включают посреди занятия — """Полное состояние. Монитор в классе включают посреди занятия —
он обязан показать текущее, а не ждать следующего события.""" он обязан показать текущее, а не ждать следующего события."""

View file

@ -1,11 +1,10 @@
from app.api.ws.station import _address_matches
from app.scoring.address import address_matches from app.scoring.address import address_matches
def test_street_abbreviation_matches_but_similarly_named_street_does_not(): def test_street_abbreviation_matches_but_similarly_named_street_does_not():
expected = "Дубнинская улица, дом 10" expected = "Дубнинская улица, дом 10"
assert address_matches(expected, "ул. Дубнинская, д. 10") assert address_matches(expected, "ул. Дубнинская, д. 10")
assert not _address_matches(expected, "Дубининская улица, дом 10") assert not address_matches(expected, "Дубининская улица, дом 10")
def test_street_type_is_part_of_the_operational_address(): def test_street_type_is_part_of_the_operational_address():

View file

@ -0,0 +1,201 @@
"""Правила пульта ДДС без сокета, хаба и БД: `DdsDesk.apply`."""
import copy
from pathlib import Path
from uuid import uuid4
from app.domain.events import (
CardAck,
CardBounce,
CardNext,
CrewSelect,
Exercise,
PhoneBrief,
PhoneCheck,
PhoneDial,
PhoneLine,
PhoneReport,
ServiceStatusSet,
SessionMode,
StationFinish,
)
from app.domain.statuses import ServiceStatus
from app.domain.timers import TimerCode
from app.scenarios.loader import load_file
from app.session.dds import prepare_queue
from app.session.state import SessionState
LIBRARY = Path(__file__).resolve().parents[2] / "scenarios"
NAMES = ("fire-apartment-l2.yaml", "tickets/t20-2-stroke.yaml")
BASIS = "Основание: доклад старшего. Сведения: карточка принята."
def lesson(count: int = 2) -> SessionState:
scenarios = [load_file(LIBRARY / name, LIBRARY) for name in NAMES[:count]]
state = SessionState(
session_id=uuid4(), scenario_id=scenarios[0].id, scenario_title=scenarios[0].title,
level=scenarios[0].level.value, mode=SessionMode.TRAINING, exercise=Exercise.DDS,
)
prepare_queue(state, scenarios)
return state
def service_of(state: SessionState) -> str:
return state.card_services(state.desk.active)[0]
def test_ack_without_comment_is_refused():
state = lesson()
outcome = state.desk.apply(CardAck.model_construct(comment=" "), state)
assert outcome.error == "Для подтверждения приёма добавьте комментарий с основанием"
assert outcome.events == [] and not outcome.changed
def test_repeated_ack_changes_nothing():
state = lesson()
card = state.desk.active
assert state.desk.apply(CardAck(comment=BASIS), state).changed
log, statuses = list(card.dds_log), list(card.status_log)
outcome = state.desk.apply(CardAck(comment=BASIS), state)
assert outcome.error is None and not outcome.changed and outcome.events == []
assert card.dds_log == log and card.status_log == statuses
def test_status_of_foreign_service_is_refused():
state = lesson()
outcome = state.desk.apply(
ServiceStatusSet(service="Чужая служба", status=ServiceStatus.ACCEPTED, comment=BASIS),
state,
)
assert outcome.error == "Можно менять статусы только своей ДДС"
def test_primary_status_stops_decision_timer():
state = lesson()
card = state.desk.active
assert card.timers.measured_ms(TimerCode.DDS_ACK) is None
outcome = state.desk.apply(
ServiceStatusSet(service=service_of(state), status=ServiceStatus.ACCEPTED,
comment=BASIS),
state,
)
assert outcome.error is None and outcome.changed
assert card.timers.measured_ms(TimerCode.DDS_ACK) is not None
assert card.timers.timers[TimerCode.DDS_WORK].started_at is not None
def test_dial_before_required_status_is_refused():
state = lesson()
crew = state.crew_options(state.desk.active)[0]
assert state.desk.apply(CrewSelect(crew=crew), state).changed
outcome = state.desk.apply(PhoneDial(), state)
assert outcome.error == "Перед звонком нужен статус «Принята» этой службы"
assert state.desk.active.phone_pending is None
def test_crew_reports_follow_four_phases_in_order():
state = lesson()
desk, card, service = state.desk, state.desk.active, service_of(state)
crew = state.crew_options(card)[0]
def mark(status):
outcome = desk.apply(ServiceStatusSet(service=service, status=status, comment=BASIS), state)
assert outcome.error is None
def call(command):
dial = desk.apply(PhoneDial(), state)
assert dial.error is None and [type(event) for event in dial.events] == [PhoneLine]
talk = desk.apply(command, state)
assert talk.error is None
assert [type(event) for event in talk.events] == [PhoneLine, PhoneLine, PhoneReport]
return talk.events[-1]
mark(ServiceStatus.ACCEPTED)
desk.apply(CrewSelect(crew=crew), state)
reports = [call(PhoneBrief(address=card.dispatched_card.address,
incident="Пожар в квартире, есть задымление",
request="Прошу выезд бригады"))]
for status in (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING):
mark(status)
reports.append(call(PhoneCheck(text="Доложите обстановку на месте")))
assert [report.phase for report in reports] == [
"dispatched", "arrived", "working", "completed"]
assert [report.phase for report in card.phone_reports] == [
"dispatched", "arrived", "working", "completed"]
assert desk.apply(PhoneDial(), state).error == "Все доклады этой бригады уже получены"
def test_next_with_foreign_card_id_is_refused():
state = lesson()
outcome = state.desk.apply(CardNext(card_id=uuid4()), state)
assert outcome.error == "Следующая карточка недоступна: ID текущей не совпадает"
def test_next_on_last_card_signals_end_of_lesson():
state = lesson(count=2)
first = state.desk.active
middle = state.desk.apply(CardNext(card_id=first.card_id), state)
assert middle.error is None and not middle.finished
last = state.desk.apply(CardNext(card_id=state.desk.active.card_id), state)
assert last.error is None and last.finished and last.changed
assert state.desk.active is None and len(state.desk.completed) == 2
assert not state.ended, "занятие завершает сессия, пульт только сигналит"
def test_station_finish_stops_work_timers_and_signals_end():
state = lesson()
card = state.desk.active
state.desk.apply(CardAck(comment=BASIS), state)
outcome = state.desk.apply(StationFinish(), state)
assert outcome.finished and outcome.error is None
assert card.timers.timers[TimerCode.DDS_WORK].stopped
def test_bounce_is_refused_in_favour_of_112_control():
state = lesson()
outcome = state.desk.apply(CardBounce(missing_fields=["floor"]), state)
assert outcome.error == (
"ДДС не проверяет заполнение карточки: замечания передаёт служба контроля 112")
def test_refusals_leave_desk_untouched():
state = lesson()
desk, card, service = state.desk, state.desk.active, service_of(state)
refused = [
ServiceStatusSet(service=service, status=ServiceStatus.COMPLETED, comment=""),
ServiceStatusSet(service="Чужая служба", status=ServiceStatus.ARRIVED, comment=BASIS),
CrewSelect(crew="Бригада не из списка"),
PhoneDial(),
PhoneCheck(text="Доложите обстановку на месте"),
CardNext(card_id=uuid4()),
CardBounce(missing_fields=["floor"]),
]
before = copy.deepcopy(desk)
for command in refused:
outcome = desk.apply(command, state)
assert outcome.error is not None, command
assert outcome.events == [] and not outcome.changed and not outcome.finished
assert desk == before
assert desk.active is card