feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
"""Статусы реагирования служб и статусы карточки.
|
|
|
|
|
|
|
|
|
|
|
|
Взято из памятки ГБУ «Система 112» для дежурно-диспетчерских служб
|
|
|
|
|
|
(docs/spec/DATASET.md#статусы-реагирования). Это не наша модель работы, а её
|
|
|
|
|
|
модель: формулировки, порядок и обязательность комментариев — от заказчика,
|
|
|
|
|
|
и менять их по своему вкусу нельзя, иначе курсант выучит не то.
|
|
|
|
|
|
|
|
|
|
|
|
Главное свойство: статусы выбираются **последовательно**. Сначала доступны
|
|
|
|
|
|
только «Принята» и «Не принята»; всё остальное открывается после «Принята».
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from enum import StrEnum
|
2026-09-21 19:56:13 +03:00
|
|
|
|
from uuid import UUID
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
|
|
|
|
|
|
from pydantic import BaseModel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ServiceStatus(StrEnum):
|
|
|
|
|
|
"""Статус реагирования одной службы из списка оповещения."""
|
|
|
|
|
|
|
|
|
|
|
|
ADDED = "added" # технический: карточка сохранена с оповещением
|
|
|
|
|
|
RECEIVED = "received" # технический: карточка открыта на АРМ
|
|
|
|
|
|
ACCEPTED = "accepted" # Принята
|
|
|
|
|
|
DECLINED = "declined" # Не принята
|
|
|
|
|
|
RESPONDING = "responding" # Начало реагирования
|
|
|
|
|
|
ARRIVED = "arrived" # Прибытие
|
|
|
|
|
|
WORKING = "working" # Проведение работ
|
|
|
|
|
|
COMPLETED = "completed" # Работы завершены
|
|
|
|
|
|
REFUSED = "refused" # Отказ от выполнения работ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SERVICE_STATUS_LABELS: dict[ServiceStatus, str] = {
|
|
|
|
|
|
ServiceStatus.ADDED: "Добавлена",
|
|
|
|
|
|
ServiceStatus.RECEIVED: "Получена службой",
|
|
|
|
|
|
ServiceStatus.ACCEPTED: "Принята",
|
|
|
|
|
|
ServiceStatus.DECLINED: "Не принята",
|
|
|
|
|
|
ServiceStatus.RESPONDING: "Начало реагирования",
|
|
|
|
|
|
ServiceStatus.ARRIVED: "Прибытие",
|
|
|
|
|
|
ServiceStatus.WORKING: "Проведение работ",
|
|
|
|
|
|
ServiceStatus.COMPLETED: "Работы завершены",
|
|
|
|
|
|
ServiceStatus.REFUSED: "Отказ от выполнения работ",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#: Автомат переходов. «Не принята» ведёт только к «Принята» — служба может
|
|
|
|
|
|
#: передумать, но не может отказаться дважды по-разному.
|
|
|
|
|
|
NEXT: dict[ServiceStatus, tuple[ServiceStatus, ...]] = {
|
|
|
|
|
|
ServiceStatus.ADDED: (ServiceStatus.ACCEPTED, ServiceStatus.DECLINED),
|
|
|
|
|
|
ServiceStatus.RECEIVED: (ServiceStatus.ACCEPTED, ServiceStatus.DECLINED),
|
|
|
|
|
|
ServiceStatus.DECLINED: (ServiceStatus.ACCEPTED,),
|
|
|
|
|
|
ServiceStatus.ACCEPTED: (
|
|
|
|
|
|
ServiceStatus.RESPONDING,
|
|
|
|
|
|
ServiceStatus.ARRIVED,
|
|
|
|
|
|
ServiceStatus.WORKING,
|
|
|
|
|
|
ServiceStatus.COMPLETED,
|
|
|
|
|
|
ServiceStatus.REFUSED,
|
|
|
|
|
|
),
|
|
|
|
|
|
ServiceStatus.RESPONDING: (
|
|
|
|
|
|
ServiceStatus.ARRIVED,
|
|
|
|
|
|
ServiceStatus.WORKING,
|
|
|
|
|
|
ServiceStatus.COMPLETED,
|
|
|
|
|
|
ServiceStatus.REFUSED,
|
|
|
|
|
|
),
|
|
|
|
|
|
ServiceStatus.ARRIVED: (
|
|
|
|
|
|
ServiceStatus.WORKING,
|
|
|
|
|
|
ServiceStatus.COMPLETED,
|
|
|
|
|
|
ServiceStatus.REFUSED,
|
|
|
|
|
|
),
|
|
|
|
|
|
ServiceStatus.WORKING: (ServiceStatus.COMPLETED, ServiceStatus.REFUSED),
|
|
|
|
|
|
# Закрывают карточку от правки: дальше только звонок в отдел контроля.
|
|
|
|
|
|
ServiceStatus.COMPLETED: (),
|
|
|
|
|
|
ServiceStatus.REFUSED: (),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#: Без комментария не сохраняются. Это не валидация формы, а предмет обучения:
|
|
|
|
|
|
#: половина нарушений в памятке — отказ без указания, куда передана информация.
|
|
|
|
|
|
COMMENT_REQUIRED: frozenset[ServiceStatus] = frozenset(
|
|
|
|
|
|
{ServiceStatus.DECLINED, ServiceStatus.REFUSED}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
#: Первичные статусы: их ждут в норматив 30 секунд.
|
|
|
|
|
|
PRIMARY: frozenset[ServiceStatus] = frozenset(
|
|
|
|
|
|
{ServiceStatus.ACCEPTED, ServiceStatus.DECLINED}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
TERMINAL: frozenset[ServiceStatus] = frozenset(
|
|
|
|
|
|
{ServiceStatus.COMPLETED, ServiceStatus.REFUSED}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CardStatus(StrEnum):
|
|
|
|
|
|
"""Статус карточки целиком — считается из статусов служб, руками не ставится."""
|
|
|
|
|
|
|
|
|
|
|
|
REGISTERED = "registered" # Зарегистрирована
|
|
|
|
|
|
PROCESSED = "processed" # Отработана: оповещение завершено
|
|
|
|
|
|
CHECKED = "checked" # Проверена главным специалистом
|
|
|
|
|
|
NOT_NOTIFIED = "not_notified" # Не оповещено: служба промолчала в норматив
|
|
|
|
|
|
REFUSED = "refused" # Отказ
|
|
|
|
|
|
NOT_COMPLETED = "not_completed" # Не завершено: 48 часов без завершения работ
|
|
|
|
|
|
COMPLETED = "completed" # Завершена
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CARD_STATUS_LABELS: dict[CardStatus, str] = {
|
|
|
|
|
|
CardStatus.REGISTERED: "Зарегистрирована",
|
|
|
|
|
|
CardStatus.PROCESSED: "Отработана",
|
|
|
|
|
|
CardStatus.CHECKED: "Проверена",
|
|
|
|
|
|
CardStatus.NOT_NOTIFIED: "Не оповещено",
|
|
|
|
|
|
CardStatus.REFUSED: "Отказ",
|
|
|
|
|
|
CardStatus.NOT_COMPLETED: "Не завершено",
|
|
|
|
|
|
CardStatus.COMPLETED: "Завершена",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#: Красные в интерфейсе и в разборе: именно они попадают в раздел отдела
|
|
|
|
|
|
#: контроля реагирования. Цвет несёт смысл только здесь (docs/arch/FRONTEND.md).
|
|
|
|
|
|
ALARMING: frozenset[CardStatus] = frozenset(
|
|
|
|
|
|
{CardStatus.NOT_NOTIFIED, CardStatus.REFUSED, CardStatus.NOT_COMPLETED}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StatusEntry(BaseModel):
|
|
|
|
|
|
"""Одна отметка службы. Комментарий — часть отметки, а не примечание к ней."""
|
|
|
|
|
|
|
|
|
|
|
|
service: str
|
|
|
|
|
|
status: ServiceStatus
|
|
|
|
|
|
at: datetime
|
|
|
|
|
|
comment: str = ""
|
|
|
|
|
|
author: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 17:40:54 +03:00
|
|
|
|
class PhoneReportRecord(BaseModel):
|
|
|
|
|
|
service: str
|
|
|
|
|
|
crew: str
|
|
|
|
|
|
phase: str
|
|
|
|
|
|
text: str
|
|
|
|
|
|
at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-21 19:56:13 +03:00
|
|
|
|
class DdsCardSummary(BaseModel):
|
|
|
|
|
|
card_id: UUID
|
|
|
|
|
|
scenario_id: str
|
|
|
|
|
|
score_auto: float
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
class StatusError(ValueError):
|
|
|
|
|
|
"""Переход запрещён автоматом или нет обязательного комментария."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def current(entries: list[StatusEntry], service: str) -> ServiceStatus:
|
|
|
|
|
|
"""Последний статус службы. Без отметок — «Добавлена»."""
|
|
|
|
|
|
for entry in reversed(entries):
|
|
|
|
|
|
if entry.service == service:
|
|
|
|
|
|
return entry.status
|
|
|
|
|
|
return ServiceStatus.ADDED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def check(entries: list[StatusEntry], service: str, status: ServiceStatus, comment: str) -> None:
|
|
|
|
|
|
"""Проверить отметку до записи. Бросает `StatusError` с текстом для курсанта."""
|
|
|
|
|
|
now = current(entries, service)
|
|
|
|
|
|
if status not in NEXT[now]:
|
|
|
|
|
|
allowed = ", ".join(SERVICE_STATUS_LABELS[item] for item in NEXT[now]) or "ничего"
|
|
|
|
|
|
raise StatusError(
|
|
|
|
|
|
f"после «{SERVICE_STATUS_LABELS[now]}» доступно: {allowed}"
|
|
|
|
|
|
+ ("" if NEXT[now] else " — карточка закрыта для редактирования")
|
|
|
|
|
|
)
|
|
|
|
|
|
if status in COMMENT_REQUIRED and not comment.strip():
|
|
|
|
|
|
raise StatusError(
|
|
|
|
|
|
f"«{SERVICE_STATUS_LABELS[status]}» требует комментария: причина и то, "
|
|
|
|
|
|
"куда передана информация"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def card_status(
|
|
|
|
|
|
entries: list[StatusEntry], services: list[str], notify_deadline_passed: bool = False
|
|
|
|
|
|
) -> CardStatus:
|
|
|
|
|
|
"""Статус карточки по статусам служб.
|
|
|
|
|
|
|
|
|
|
|
|
Упрощение против боевой системы, и оно намеренное: статусы «Проверена»
|
|
|
|
|
|
и «Не завершено» требуют главного специалиста и 48 часов — ни того, ни
|
|
|
|
|
|
другого в занятии на полтора часа нет. Остальные четыре считаются честно.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not services:
|
|
|
|
|
|
return CardStatus.COMPLETED
|
|
|
|
|
|
|
|
|
|
|
|
latest = {service: current(entries, service) for service in services}
|
|
|
|
|
|
silent = [
|
2026-09-21 17:40:54 +03:00
|
|
|
|
service for service, status in latest.items()
|
|
|
|
|
|
if status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED}
|
feat: статусы реагирования на АРМ ДДС и ошибки диспетчера D1–D6 (lct-33)
ТЗ называет обучаемого оператором ДДС, а его работа в системе-112 состоит
из одного действия: получить карточку и правильно проставить статус
реагирования с комментарием. Памятка заказчика посвящена этому целиком.
Наш АРМ умел два статуса из девяти.
- domain/statuses.py: девять статусов реагирования с автоматом переходов,
семь статусов карточки, обязательные комментарии к отказам. Формулировки
из памятки дословно — диспетчер должен узнать слова своего боевого АРМ.
- Автомат на сервере: фронт рисует только пришедшее в available. Отвергнутая
отметка не попадает в журнал и возвращается текстом для курсанта.
- scoring/dispatcher.py: D1, D2, D3, D4, D6 детерминированно. D3 (отказ
от профильного происшествия) проверяется по списку оповещения из ЕКП —
без классификатора его было бы не на чем посчитать. D5 оставлен судье.
- Отметки D идут в отчёт рядом с E: в живой цепочке 112 → ДДС участвуют обе роли.
- Экран ДДС: таблица служб со статусами, поле комментария у отказов,
журнал отметок, статус карточки красным в трёх случаях из семи.
Сквозной тест повис на ожидании station.state и вскрыл продуктовый дефект:
снимок уходил только в ответ на действие диспетчера, то есть список оповещения
он видел лишь после того, как что-то нажмёт. Теперь station.state отправляется
сразу за card.received.
Упрощения записаны в карточке: статусы «Проверена» и «Не завершено» не
считаются — первый требует главного специалиста, второй 48 часов.
148 тестов зелёных (23 новых), make typecheck чистый.
2026-09-20 08:23:55 +03:00
|
|
|
|
]
|
|
|
|
|
|
if silent and notify_deadline_passed:
|
|
|
|
|
|
return CardStatus.NOT_NOTIFIED
|
|
|
|
|
|
if any(
|
|
|
|
|
|
status in {ServiceStatus.DECLINED, ServiceStatus.REFUSED} for status in latest.values()
|
|
|
|
|
|
):
|
|
|
|
|
|
return CardStatus.REFUSED
|
|
|
|
|
|
if all(status is ServiceStatus.COMPLETED for status in latest.values()):
|
|
|
|
|
|
return CardStatus.COMPLETED
|
|
|
|
|
|
if silent:
|
|
|
|
|
|
return CardStatus.REGISTERED
|
|
|
|
|
|
return CardStatus.PROCESSED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class StationSnapshot(BaseModel):
|
|
|
|
|
|
"""Что видит диспетчер: службы, их статусы и что доступно дальше.
|
|
|
|
|
|
|
|
|
|
|
|
Все поля обязательные, хотя списки и могут быть пустыми: снимок всегда
|
|
|
|
|
|
собирается целиком, а необязательное поле уезжает в TypeScript как
|
|
|
|
|
|
`| undefined` и заставляет фронт проверять то, чего не бывает.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
services: list[str]
|
|
|
|
|
|
statuses: dict[str, ServiceStatus]
|
|
|
|
|
|
available: dict[str, list[ServiceStatus]]
|
|
|
|
|
|
card: CardStatus
|
|
|
|
|
|
log: list[StatusEntry]
|
2026-09-21 17:40:54 +03:00
|
|
|
|
crew_options: list[str] = []
|
|
|
|
|
|
crew_selected: str | None = None
|
|
|
|
|
|
phone_reports: list[PhoneReportRecord] = []
|
2026-09-21 19:56:13 +03:00
|
|
|
|
card_id: UUID | None = None
|
|
|
|
|
|
card_index: int = 1
|
|
|
|
|
|
card_total: int = 1
|
|
|
|
|
|
reply_text: str = ""
|
|
|
|
|
|
completed_cards: list[DdsCardSummary] = []
|