From fca353fdd0a0a04978ae9464269d53527b915a0f Mon Sep 17 00:00:00 2001 From: gglamer Date: Sat, 26 Sep 2026 22:01:27 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BF=D1=83=D0=BB=D1=8C=D1=82=D0=B0=20=D0=94?= =?UTF-8?q?=D0=94=D0=A1=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=B5=D1=81=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B2=20DdsDesk.apply,=20=D1=81=D0=BE=D0=BA?= =?UTF-8?q?=D0=B5=D1=82=20=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=81=D0=BF=D0=BE=D1=80=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/ws/station.py | 324 ++----------------------- backend/app/session/dds.py | 312 +++++++++++++++++++++++- backend/app/session/state.py | 6 + backend/tests/test_address_matching.py | 3 +- backend/tests/test_dds_desk_apply.py | 201 +++++++++++++++ 5 files changed, 531 insertions(+), 315 deletions(-) create mode 100644 backend/tests/test_dds_desk_apply.py diff --git a/backend/app/api/ws/station.py b/backend/app/api/ws/station.py index 65b1287..3c2b584 100644 --- a/backend/app/api/ws/station.py +++ b/backend/app/api/ws/station.py @@ -6,11 +6,13 @@ Самая ценная механика цепочки — `card.bounce`: диспетчер видит, что не указан этаж, и отбивает карточку обратно. Неполнота КИО перестаёт быть процентом в отчёте и становится сорванным выездом с конкретной причиной. + +Правила пульта — в `DdsDesk.apply`; здесь разбор команды, отсев повторов, +durable transition и рассылка итога. """ import asyncio import logging -import re from uuid import UUID from fastapi import APIRouter, WebSocket, WebSocketDisconnect @@ -22,31 +24,14 @@ from app.domain.events import ( CommandAck, ErrorEvent, ErrorKind, - Exercise, - PhoneLine, - PhoneReport, ScoreReady, SessionEnded, StationState, StationToServer, ) from app.domain.roles import Role -from app.domain.statuses import ( - 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.finish import finish from app.session.hub import LEASE_FENCED_MESSAGE, hub -from app.session.state import now_utc log = logging.getLogger(__name__) router = APIRouter() @@ -54,313 +39,32 @@ router = APIRouter() _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: 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: - state.ended_at = now_utc() - 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") + ended_at = state.end(CallEndReason.COMPLETE) hub.stop_ticker(session_id) hub.to_station(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) hub.to_observers(session_id, SessionEnded(reason=CallEndReason.COMPLETE)) 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) hub.to_station(session_id, ScoreReady(session_id=session_id)) async def _handle(session_id: UUID, state, event) -> None: - if state.ended: - _error(session_id, "Занятие уже завершено") + outcome = state.desk.apply(event, state) + if outcome.error is not None: + _error(session_id, outcome.error) return - card = state.desk.active - if card is None and event.type not in CARDLESS_EVENTS: - _error(session_id, "Нет активной карточки") + for item in outcome.events: + hub.to_station(session_id, item) + if outcome.finished: + await _finish_dds(session_id, state) + if not outcome.changed: 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) - case "station.finish": - if state.exercise is not Exercise.DDS and not state.handoff_to_dds: - _error(session_id, "Операторское занятие завершается после звонка 112") - 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) hub.to_station(session_id, StationState(snapshot=state.station_snapshot())) hub.to_observers(session_id, state.snapshot()) diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py index 0382649..47cdcfe 100644 --- a/backend/app/session/dds.py +++ b/backend/app/session/dds.py @@ -2,31 +2,40 @@ Живое состояние карточки существует в одном экземпляре — в `DdsLiveCard`. Пульт хранит карточки и id активной; переключение меняет id и ничего не -копирует, поэтому обработчики станции пишут прямо в `desk.active`. +копирует. Правила команд станции — `DdsDesk.apply`: сокет только разбирает +команду, сохраняет итог и рассылает события. """ import re from dataclasses import dataclass, field from datetime import datetime, timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from uuid import UUID, uuid4 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.statuses import ( + PRIMARY, + SERVICE_STATUS_LABELS, PhoneCallPending, PhoneLineRecord, PhoneReportRecord, ServiceStatus, StatusEntry, + StatusError, check, + current, ) from app.domain.taxonomy import Finding from app.domain.timers import TimerCode from app.scenarios.schema import Scenario +from app.scoring.address import address_matches from app.session.timers import SessionTimers, now_utc +if TYPE_CHECKING: + from app.session.state import SessionState + @dataclass 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 class DdsDesk: """Живые карточки занятия, активная из них и очередь поступления.""" @@ -310,6 +415,207 @@ class DdsDesk: 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( state, diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 60bd7b7..71a85b2 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -367,6 +367,12 @@ class SessionState: def ended(self) -> bool: 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: """Полное состояние. Монитор в классе включают посреди занятия — он обязан показать текущее, а не ждать следующего события.""" diff --git a/backend/tests/test_address_matching.py b/backend/tests/test_address_matching.py index 3c04a7e..f382400 100644 --- a/backend/tests/test_address_matching.py +++ b/backend/tests/test_address_matching.py @@ -1,11 +1,10 @@ -from app.api.ws.station import _address_matches from app.scoring.address import address_matches def test_street_abbreviation_matches_but_similarly_named_street_does_not(): 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(): diff --git a/backend/tests/test_dds_desk_apply.py b/backend/tests/test_dds_desk_apply.py new file mode 100644 index 0000000..fa6c561 --- /dev/null +++ b/backend/tests/test_dds_desk_apply.py @@ -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