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 чистый.
This commit is contained in:
Ivan Gerasimov 2026-09-20 08:23:55 +03:00
commit d4f0d0a0f8
14 changed files with 953 additions and 9 deletions

View file

@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from app.domain.classifiers import DDSCode, IncidentType, Level
from app.domain.kio import KIO, Coords
from app.domain.statuses import ServiceStatus, StationSnapshot
from app.domain.taxonomy import Finding
from app.domain.timers import TimerSnapshot
@ -453,6 +454,27 @@ class CardBounce(BaseModel):
comment: str = ""
class ServiceStatusSet(BaseModel):
"""Диспетчер ставит статус реагирования своей службе.
Последовательность жёсткая, комментарий к отказу обязателен — это не
валидация формы, а то, чему учит второй режим занятия
(docs/spec/DATASET.md#статусы-реагирования).
"""
type: Literal["card.status"] = "card.status"
service: str
status: ServiceStatus
comment: str = ""
class StationState(BaseModel):
"""Состояние АРМ ДДС после каждой отметки: что стоит и что доступно дальше."""
type: Literal["station.state"] = "station.state"
snapshot: StationSnapshot
class ZoneDecision(BaseModel):
type: Literal["zone.decision"] = "zone.decision"
in_zone: bool
@ -469,12 +491,12 @@ class CrewArrived(BaseModel):
ServerToStation = Annotated[
CardReceived | TimerTick | SessionEnded | ErrorEvent,
CardReceived | StationState | TimerTick | SessionEnded | ErrorEvent,
Field(discriminator="type"),
]
StationToServer = Annotated[
CardAck | CardBounce | ZoneDecision | CrewDispatched | CrewArrived,
CardAck | CardBounce | ServiceStatusSet | ZoneDecision | CrewDispatched | CrewArrived,
Field(discriminator="type"),
]

View file

@ -0,0 +1,198 @@
"""Статусы реагирования служб и статусы карточки.
Взято из памятки ГБУ «Система 112» для дежурно-диспетчерских служб
(docs/spec/DATASET.md#статусы-реагирования). Это не наша модель работы, а её
модель: формулировки, порядок и обязательность комментариев — от заказчика,
и менять их по своему вкусу нельзя, иначе курсант выучит не то.
Главное свойство: статусы выбираются **последовательно**. Сначала доступны
только «Принята» и «Не принята»; всё остальное открывается после «Принята».
"""
from datetime import datetime
from enum import StrEnum
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 = ""
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 = [
service for service, status in latest.items() if status not in PRIMARY | TERMINAL
]
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]

View file

@ -12,18 +12,32 @@ from pydantic import BaseModel
class ErrorCode(StrEnum):
"""E — ошибки приёма вызова, D — ошибки диспетчера ДДС.
Две роли, две таксономии. D1–D6 взяты из памятки заказчика дословно:
она перечисляет типовые нарушения с реальными примерами, и преподаватель
узнаёт их формулировки (docs/spec/DATASET.md).
"""
E1 = "E1"
E2 = "E2"
E3 = "E3"
E4 = "E4"
E5 = "E5"
E6 = "E6"
D1 = "D1"
D2 = "D2"
D3 = "D3"
D4 = "D4"
D5 = "D5"
D6 = "D6"
class FindingSource(StrEnum):
"""Кто выставил отметку. `judge` — единственный недетерминированный источник."""
SLOTS = "slots"
DISPATCHER = "dispatcher"
GROUND_TRUTH = "ground_truth"
TIMERS = "timers"
KIO = "kio"
@ -76,6 +90,42 @@ ERRORS: dict[ErrorCode, ErrorSpec] = {
detail="Карточка ушла в ДДС непригодной, снятие с контроля до подтверждения",
source=FindingSource.CHAIN,
),
ErrorCode.D1: ErrorSpec(
code=ErrorCode.D1,
title="Статус реагирования не проставлен",
detail="Карточка осталась без первичного статуса и ушла в «Не оповещено»",
source=FindingSource.DISPATCHER,
),
ErrorCode.D2: ErrorSpec(
code=ErrorCode.D2,
title="Статус не соответствует факту",
detail="«Принята» там, где работы проводиться не будут, и наоборот",
source=FindingSource.DISPATCHER,
),
ErrorCode.D3: ErrorSpec(
code=ErrorCode.D3,
title="Отказ от профильного происшествия",
detail="Служба есть в списке оповещения, значит происшествие её — отказ неправомерен",
source=FindingSource.DISPATCHER,
),
ErrorCode.D4: ErrorSpec(
code=ErrorCode.D4,
title="Нет комментария к отказу",
detail="«Не принята» и «Отказ от выполнения работ» без причины",
source=FindingSource.DISPATCHER,
),
ErrorCode.D5: ErrorSpec(
code=ErrorCode.D5,
title="Неполный комментарий",
detail="Не указано, куда передана информация и что сделал диспетчер",
source=FindingSource.JUDGE,
),
ErrorCode.D6: ErrorSpec(
code=ErrorCode.D6,
title="Нет статусов хода работ",
detail="Реагирование шло, но начало, прибытие и завершение не отмечены",
source=FindingSource.DISPATCHER,
),
}