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

@ -124,3 +124,91 @@ def test_bounced_card_becomes_e6_with_the_reason(client):
assert "floor" in chain[0]["summary"] and "выезд сорван" in chain[0]["summary"]
finally:
control.__exit__(None, None, None)
# ── статусы реагирования (lct-33) ──
def dispatched(client):
"""Занятие, доведённое до передачи карточки в ДДС, и подключённая станция."""
session_id, control, _ = lesson(client)
station_ctx = client.websocket_connect(f"/ws/station/{session_id}?role=dds_01")
station = station_ctx.__enter__()
call_ctx = client.websocket_connect(f"/ws/call/{session_id}")
trainee = call_ctx.__enter__()
trainee.send_json({"type": "call.answer"})
trainee.send_json(
{"type": "kio.patch", "fields": {"signs": ["жилой дом", "балкон", "открытое пламя"]}}
)
wait_for(lambda: hub.get(session_id).kio.incident_code)
trainee.send_json({"type": "dds.dispatch", "service": "01"})
read_until(station, "card.received")
return session_id, station, (control, station_ctx, call_ctx)
def close_all(contexts):
for context in contexts:
context.__exit__(None, None, None)
def test_station_sees_the_notify_list_from_the_classifier(client):
"""Диспетчеру есть что отмечать только потому, что список оповещения
посчитан по ЕКП, а не выбран оператором (lct-32)."""
session_id, station, contexts = dispatched(client)
try:
state = read_until(station, "station.state")
assert "Служба 101" in state["snapshot"]["services"]
assert len(state["snapshot"]["services"]) > 1
assert state["snapshot"]["statuses"]["Служба 101"] == "added"
finally:
close_all(contexts)
def test_dispatcher_sets_a_status_and_the_card_follows(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "accepted"})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
assert "responding" in state["snapshot"]["available"]["Служба 101"]
assert "accepted" not in state["snapshot"]["available"]["Служба 101"]
finally:
close_all(contexts)
def test_out_of_order_status_is_rejected_with_a_reason(client):
"""Автомат живёт на сервере: фронт может ошибиться, состояние — нет."""
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "arrived"})
error = read_until(station, "error")
assert "Принята" in error["message"]
assert hub.get(session_id).status_log == []
finally:
close_all(contexts)
def test_refusal_without_a_comment_is_rejected(client):
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.status", "service": "Служба 101", "status": "declined"})
error = read_until(station, "error")
assert "комментар" in error["message"]
finally:
close_all(contexts)
def test_ack_button_still_works_and_counts_as_accepted(client):
"""Кнопка подтверждения из живой цепочки (lct-20) осталась и означает
«Принята» у главной службы — иначе два экрана учили бы разному."""
session_id, station, contexts = dispatched(client)
try:
read_until(station, "station.state")
station.send_json({"type": "card.ack"})
state = read_until(station, "station.state")
assert state["snapshot"]["statuses"]["Служба 101"] == "accepted"
finally:
close_all(contexts)

View file

@ -0,0 +1,175 @@
"""Статусы реагирования на АРМ ДДС.
Пункты приёмки lct-33: автомат переходов, обязательный комментарий,
необратимость завершения, статус карточки и ошибки диспетчера D1–D6.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.domain.statuses import (
CardStatus,
ServiceStatus,
StatusEntry,
StatusError,
card_status,
check,
current,
)
from app.domain.taxonomy import ErrorCode
from app.scoring.dispatcher import evaluate_dispatcher
SERVICES = ["Служба 101", "МВД"]
NOW = datetime(2026, 9, 19, 12, 0, tzinfo=timezone.utc)
def mark(service: str, status: ServiceStatus, comment: str = "", minute: int = 0) -> StatusEntry:
return StatusEntry(
service=service, status=status, at=NOW + timedelta(minutes=minute), comment=comment
)
# ── автомат переходов ──
def test_only_primary_statuses_are_available_at_first():
check([], "Служба 101", ServiceStatus.ACCEPTED, "")
check([], "Служба 101", ServiceStatus.DECLINED, "не обслуживаем, передано в УК")
with pytest.raises(StatusError):
check([], "Служба 101", ServiceStatus.ARRIVED, "")
def test_accepted_opens_the_rest():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
check(log, "Служба 101", ServiceStatus.COMPLETED, "")
def test_declined_leads_only_back_to_accepted():
"""Служба может передумать, но не может отказаться дважды по-разному."""
log = [mark("Служба 101", ServiceStatus.DECLINED, "не наш адрес")]
check(log, "Служба 101", ServiceStatus.ACCEPTED, "")
with pytest.raises(StatusError):
check(log, "Служба 101", ServiceStatus.RESPONDING, "")
def test_completed_closes_the_card_for_editing():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("Служба 101", ServiceStatus.COMPLETED, minute=5),
]
with pytest.raises(StatusError) as exc:
check(log, "Служба 101", ServiceStatus.WORKING, "")
assert "закрыта" in str(exc.value)
def test_refusal_without_a_comment_is_rejected():
"""Не валидация формы, а предмет обучения: половина нарушений в памятке —
отказ без указания, куда передана информация."""
with pytest.raises(StatusError) as exc:
check([], "МВД", ServiceStatus.DECLINED, " ")
assert "комментар" in str(exc.value)
def test_statuses_are_tracked_per_service():
log = [mark("Служба 101", ServiceStatus.ACCEPTED)]
assert current(log, "Служба 101") is ServiceStatus.ACCEPTED
assert current(log, "МВД") is ServiceStatus.ADDED
# ── статус карточки ──
def test_silence_past_the_deadline_is_not_notified():
assert card_status([], SERVICES, notify_deadline_passed=True) is CardStatus.NOT_NOTIFIED
def test_silence_within_the_deadline_is_still_registered():
assert card_status([], SERVICES, notify_deadline_passed=False) is CardStatus.REGISTERED
def test_any_refusal_marks_the_whole_card():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("МВД", ServiceStatus.DECLINED, "не наша территория, передано в ОМВД"),
]
assert card_status(log, SERVICES) is CardStatus.REFUSED
def test_all_completed_closes_the_card():
log = []
for service in SERVICES:
log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
assert card_status(log, SERVICES) is CardStatus.COMPLETED
def test_alarming_statuses_are_the_three_from_the_memo():
from app.domain.statuses import ALARMING
assert ALARMING == {CardStatus.NOT_NOTIFIED, CardStatus.REFUSED, CardStatus.NOT_COMPLETED}
# ── ошибки диспетчера ──
def _codes(entries, services=SERVICES, elapsed=45_000):
findings = evaluate_dispatcher(
entries=entries, services=services, deadline_ms=30_000, elapsed_ms=elapsed
)
return [finding.code for finding in findings]
def test_no_status_is_d1():
assert ErrorCode.D1 in _codes([])
def test_refusal_without_comment_is_d4():
"""Автомат такую отметку не пропустит, но карточка может прийти из внешней
системы уже с пустым комментарием — разбор обязан это показать."""
log = [mark(service, ServiceStatus.DECLINED) for service in SERVICES]
assert ErrorCode.D4 in _codes(log)
def test_declining_a_profile_incident_is_d3():
log = [
mark("Служба 101", ServiceStatus.ACCEPTED),
mark("МВД", ServiceStatus.DECLINED, "не в нашей компетенции"),
]
codes = _codes(log)
assert ErrorCode.D3 in codes, "служба в списке оповещения — происшествие профильное"
def test_accepted_and_forgotten_is_d2():
log = [mark(service, ServiceStatus.ACCEPTED) for service in SERVICES]
assert ErrorCode.D2 in _codes(log)
def test_completed_without_progress_is_d6():
log = []
for service in SERVICES:
log += [mark(service, ServiceStatus.ACCEPTED), mark(service, ServiceStatus.COMPLETED, minute=9)]
assert ErrorCode.D6 in _codes(log)
def test_clean_work_has_no_findings():
log = []
for service in SERVICES:
log += [
mark(service, ServiceStatus.ACCEPTED),
mark(service, ServiceStatus.RESPONDING, minute=1),
mark(service, ServiceStatus.ARRIVED, minute=4),
mark(service, ServiceStatus.WORKING, minute=5),
mark(service, ServiceStatus.COMPLETED, minute=20),
]
assert _codes(log) == []
def test_every_finding_carries_its_reason():
"""Правило проекта: ни одной отметки без обоснования."""
findings = evaluate_dispatcher(
entries=[], services=SERVICES, deadline_ms=30_000, elapsed_ms=45_000
)
assert findings
for finding in findings:
assert finding.fact and finding.ref