lct-50: таймеры ДДС — 30 с до открытия карточки, 3 минуты до первой записи
This commit is contained in:
parent
8ff51a8955
commit
51ef99c472
18 changed files with 169 additions and 169 deletions
|
|
@ -24,7 +24,13 @@ from app.db.base import get_session
|
||||||
from app.db.models import Group, Score, Session, Trainee
|
from app.db.models import Group, Score, Session, Trainee
|
||||||
from app.domain.events import Exercise, SessionMode, SessionReport
|
from app.domain.events import Exercise, SessionMode, SessionReport
|
||||||
from app.domain.roles import Role
|
from app.domain.roles import Role
|
||||||
from app.domain.statuses import SERVICE_STATUS_LABELS, DdsQueueCard, StationSnapshot, current
|
from app.domain.statuses import (
|
||||||
|
SERVICE_STATUS_LABELS,
|
||||||
|
DdsQueueCard,
|
||||||
|
ServiceStatus,
|
||||||
|
StationSnapshot,
|
||||||
|
current,
|
||||||
|
)
|
||||||
from app.domain.timers import TimerCode, TimerState
|
from app.domain.timers import TimerCode, TimerState
|
||||||
from app.scoring.export import to_csv, to_pdf
|
from app.scoring.export import to_csv, to_pdf
|
||||||
from app.scoring.report import build as build_report
|
from app.scoring.report import build as build_report
|
||||||
|
|
@ -212,10 +218,12 @@ def _signals(
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
signals: list[Signal] = []
|
signals: list[Signal] = []
|
||||||
# Необработанная — без первичного статуса: он останавливает норматив
|
# Необработанная — без первичного статуса. Норматив DDS_ACK теперь
|
||||||
# DDS_ACK. Принятая или отклонённая карточка висит в очереди до
|
# останавливается открытием карточки, поэтому считаем по статусу службы:
|
||||||
# «Следующей», но реакции курсанта уже не ждёт.
|
# открытая, но не принятая и не отклонённая карточка реакции ещё ждёт.
|
||||||
pending = sum(not card.timer_stopped for card in queue)
|
pending = sum(
|
||||||
|
card.service_status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED} for card in queue
|
||||||
|
)
|
||||||
if pending >= settings.signal_backlog_threshold:
|
if pending >= settings.signal_backlog_threshold:
|
||||||
signals.append(Signal(
|
signals.append(Signal(
|
||||||
kind="backlog", severity=TimerState.WARN,
|
kind="backlog", severity=TimerState.WARN,
|
||||||
|
|
|
||||||
|
|
@ -59,10 +59,11 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
||||||
TimerCode.DDS_ACK: Normative(
|
TimerCode.DDS_ACK: Normative(
|
||||||
# 30 секунд — ПП РФ № 1931 и памятка АРМ-112, а не ГОСТ: до получения
|
# 30 секунд — ПП РФ № 1931 и памятка АРМ-112, а не ГОСТ: до получения
|
||||||
# датасета здесь стояло 4 секунды, взятые по догадке (docs/spec/DATASET.md).
|
# датасета здесь стояло 4 секунды, взятые по догадке (docs/spec/DATASET.md).
|
||||||
|
# Отсчёт — от появления карточки до её открытия (ответ заказчика, П.5).
|
||||||
code=TimerCode.DDS_ACK,
|
code=TimerCode.DDS_ACK,
|
||||||
title="Подтверждение получения карточки",
|
title="Открытие карточки ДДС",
|
||||||
limit_ms=30_000,
|
limit_ms=30_000,
|
||||||
ref="ПП РФ № 1931",
|
ref="ПП РФ № 1931; ответ заказчика, П.5",
|
||||||
),
|
),
|
||||||
TimerCode.CARD_FILL: Normative(
|
TimerCode.CARD_FILL: Normative(
|
||||||
code=TimerCode.CARD_FILL,
|
code=TimerCode.CARD_FILL,
|
||||||
|
|
@ -72,9 +73,9 @@ NORMATIVES: dict[TimerCode, Normative] = {
|
||||||
),
|
),
|
||||||
TimerCode.DDS_WORK: Normative(
|
TimerCode.DDS_WORK: Normative(
|
||||||
code=TimerCode.DDS_WORK,
|
code=TimerCode.DDS_WORK,
|
||||||
title="Отработка карточки ДДС",
|
title="Первая запись по карточке ДДС",
|
||||||
limit_ms=180_000,
|
limit_ms=180_000,
|
||||||
ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
|
ref="ТЗ задачи № 9; ответ заказчика, П.5",
|
||||||
),
|
),
|
||||||
TimerCode.ZONE_CHECK: Normative(
|
TimerCode.ZONE_CHECK: Normative(
|
||||||
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ from app.domain.statuses import (
|
||||||
)
|
)
|
||||||
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
|
||||||
from app.domain.events import Metric
|
from app.domain.events import Metric
|
||||||
from app.domain.timers import GOST_REF
|
from app.domain.timers import GOST_REF, TimerCode
|
||||||
|
|
||||||
#: Статусы, означающие, что реагирование действительно шло.
|
#: Статусы, означающие, что реагирование действительно шло.
|
||||||
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
PROGRESS = (ServiceStatus.RESPONDING, ServiceStatus.ARRIVED, ServiceStatus.WORKING)
|
||||||
|
|
@ -161,37 +161,37 @@ def evaluate_dispatcher(
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
|
||||||
# D1 — первичного статуса нет либо он проставлен позже норматива.
|
# D1 — карточка открыта позже норматива или не открыта вовсе. 30 с идут
|
||||||
|
# от появления карточки в строке сообщений до её открытия (ответ
|
||||||
|
# заказчика, П.5); `elapsed_ms` — зафиксированное время открытия.
|
||||||
|
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
||||||
|
findings.append(
|
||||||
|
_finding(
|
||||||
|
ErrorCode.D1,
|
||||||
|
(f"{service}: карточка открыта с нарушением срока"
|
||||||
|
if elapsed_ms is not None else f"{service}: карточка не открыта"),
|
||||||
|
fact=(
|
||||||
|
f"открыта через {elapsed_ms // 1000} с"
|
||||||
|
if elapsed_ms is not None
|
||||||
|
else "карточка не открыта до конца занятия"
|
||||||
|
),
|
||||||
|
norm=f"открыть карточку в течение {deadline_ms // 1000} с",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# D1 — первичного статуса нет: карточка ушла в «Не оповещено».
|
||||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||||
if primary is None:
|
if primary is None:
|
||||||
findings.append(
|
findings.append(
|
||||||
_finding(
|
_finding(
|
||||||
ErrorCode.D1,
|
ErrorCode.D1,
|
||||||
f"{service}: статус реагирования не проставлен",
|
f"{service}: статус реагирования не проставлен",
|
||||||
fact=(
|
fact="карточка осталась без первичного статуса",
|
||||||
f"прошло {elapsed_ms // 1000} с"
|
norm="проставить «Принята» или «Не принята» с комментарием",
|
||||||
if elapsed_ms is not None
|
|
||||||
else "карточка осталась без отметки"
|
|
||||||
),
|
|
||||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if elapsed_ms is None or elapsed_ms > deadline_ms:
|
|
||||||
findings.append(
|
|
||||||
_finding(
|
|
||||||
ErrorCode.D1,
|
|
||||||
f"{service}: первичный статус проставлен с нарушением срока",
|
|
||||||
fact=(
|
|
||||||
f"прошло {elapsed_ms // 1000} с"
|
|
||||||
if elapsed_ms is not None
|
|
||||||
else "время первичной отметки не зафиксировано"
|
|
||||||
),
|
|
||||||
norm=f"первичный статус в течение {deadline_ms // 1000} с",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for mark in marks:
|
for mark in marks:
|
||||||
# D4 — отказ без комментария.
|
# D4 — отказ без комментария.
|
||||||
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
if mark.status in REFUSAL_COMMENT_REQUIRED and not mark.comment.strip():
|
||||||
|
|
@ -307,13 +307,14 @@ def dispatcher_metrics(
|
||||||
))
|
))
|
||||||
|
|
||||||
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
primary = next((mark for mark in marks if mark.status in PRIMARY), None)
|
||||||
elapsed = (int((primary.at - state.dispatched_at).total_seconds() * 1000)
|
# Время открытия — то, что зафиксировал таймер событием, а не разница
|
||||||
if primary and state.dispatched_at else None)
|
# отметок: 30 с по ответу заказчика идут до открытия карточки.
|
||||||
|
elapsed = state.timers.measured_ms(TimerCode.DDS_ACK)
|
||||||
add("dds_primary", "решение по карточке", primary is not None,
|
add("dds_primary", "решение по карточке", primary is not None,
|
||||||
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
primary.status.value if primary else "решение отсутствует", "Принята или Не принята", 2.0)
|
||||||
add("dds_ack", f"первичная отметка за {deadline_ms // 1000} с",
|
add("dds_ack", f"открытие карточки за {deadline_ms // 1000} с",
|
||||||
elapsed is not None and elapsed <= deadline_ms,
|
elapsed is not None and elapsed <= deadline_ms,
|
||||||
f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
|
f"{elapsed / 1000:.1f} с" if elapsed is not None else "карточка не открыта",
|
||||||
f"≤ {deadline_ms // 1000} с")
|
f"≤ {deadline_ms // 1000} с")
|
||||||
if primary is None:
|
if primary is None:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ def to_csv(report: SessionReport) -> bytes:
|
||||||
row("Занятие", "", "Сценарий", report.scenario_id)
|
row("Занятие", "", "Сценарий", report.scenario_id)
|
||||||
row("Занятие", "", "Режим", report.mode.value)
|
row("Занятие", "", "Режим", report.mode.value)
|
||||||
row("Занятие", "", "Попытка", report.attempt)
|
row("Занятие", "", "Попытка", report.attempt)
|
||||||
row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
|
row("Критерии", "", "Лимит открытия карточки ДДС, с", report.criteria.decision_time_limit_seconds)
|
||||||
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
|
||||||
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
|
||||||
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
|
||||||
|
|
@ -150,7 +150,7 @@ def to_pdf(report: SessionReport) -> bytes:
|
||||||
("Сценарий", report.scenario_id),
|
("Сценарий", report.scenario_id),
|
||||||
("Режим", report.mode.value),
|
("Режим", report.mode.value),
|
||||||
("Попытка", report.attempt),
|
("Попытка", report.attempt),
|
||||||
("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
("Лимит открытия карточки ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
|
||||||
("Допустимо ошибок", report.criteria.allowed_errors),
|
("Допустимо ошибок", report.criteria.allowed_errors),
|
||||||
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
|
||||||
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
("Переопределения весов", report.criteria.score_weights or "стандартные"),
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,11 @@ CARD_FILL_TIME = TimeNorm(
|
||||||
)
|
)
|
||||||
DDS_WORK_TIME = TimeNorm(
|
DDS_WORK_TIME = TimeNorm(
|
||||||
key="dds_work_time",
|
key="dds_work_time",
|
||||||
title="Отработка карточки ДДС",
|
title="Первая запись по карточке ДДС",
|
||||||
summary="ДДС: норматив времени отработки карточки не выполнен",
|
summary="ДДС: первая запись по карточке позже норматива",
|
||||||
action="завершить",
|
action="открыть карточку и добавить первую запись (статус и текст)",
|
||||||
unmeasured="время обработки не зафиксировано",
|
unmeasured="карточка не открыта",
|
||||||
incomplete="карточка не завершена",
|
incomplete="первая запись не добавлена",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,12 +241,23 @@ def _refused(message: str) -> DeskOutcome:
|
||||||
|
|
||||||
|
|
||||||
def _start_work_timer(card: DdsLiveCard) -> None:
|
def _start_work_timer(card: DdsLiveCard) -> None:
|
||||||
"""Три минуты на работу идут один раз — с открытия карточки."""
|
"""Открытие карточки: останавливает 30 с и запускает 3 мин — один раз.
|
||||||
|
|
||||||
|
Автоматическая активация первой карточки очереди открытием не считается:
|
||||||
|
карточка стоит в строке сообщений, пока диспетчер её не откроет. Первое
|
||||||
|
действие по карточке без явного открытия тоже доказывает, что она открыта.
|
||||||
|
"""
|
||||||
timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
timer = card.timers.timers.get(TimerCode.DDS_WORK)
|
||||||
if timer is None or timer.started_at is None:
|
if timer is None or timer.started_at is None:
|
||||||
card.on_event("dds.open")
|
card.on_event("dds.open")
|
||||||
|
|
||||||
|
|
||||||
|
def _first_record(card: DdsLiveCard, comment: str) -> None:
|
||||||
|
"""Первая запись — ручной статус с текстом; она закрывает 3 минуты (П.5)."""
|
||||||
|
if comment.strip():
|
||||||
|
card.on_event("dds.record")
|
||||||
|
|
||||||
|
|
||||||
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
|
def _line(card: DdsLiveCard, speaker: str, text: str) -> PhoneLine | None:
|
||||||
call = card.phone_pending
|
call = card.phone_pending
|
||||||
if call is None:
|
if call is None:
|
||||||
|
|
@ -436,17 +447,17 @@ class DdsDesk(BaseModel):
|
||||||
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
|
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
|
||||||
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
|
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
|
||||||
return DeskOutcome()
|
return DeskOutcome()
|
||||||
card.on_event("card.ack")
|
|
||||||
card.dds_log.append(("card.ack", now_utc(), None))
|
card.dds_log.append(("card.ack", now_utc(), None))
|
||||||
|
_start_work_timer(card)
|
||||||
services = session.card_services(card)
|
services = session.card_services(card)
|
||||||
if services:
|
if services:
|
||||||
_start_work_timer(card)
|
|
||||||
try:
|
try:
|
||||||
card.set_service_status(
|
card.set_service_status(
|
||||||
services[0], ServiceStatus.ACCEPTED, command.comment,
|
services[0], ServiceStatus.ACCEPTED, command.comment,
|
||||||
author="диспетчер",
|
author="диспетчер",
|
||||||
)
|
)
|
||||||
session.consecutive_refusals = 0
|
session.consecutive_refusals = 0
|
||||||
|
_first_record(card, command.comment)
|
||||||
except StatusError:
|
except StatusError:
|
||||||
pass # статус уже стоит: повторное нажатие ничего не меняет
|
pass # статус уже стоит: повторное нажатие ничего не меняет
|
||||||
case "card.status":
|
case "card.status":
|
||||||
|
|
@ -460,9 +471,8 @@ class DdsDesk(BaseModel):
|
||||||
card.set_service_status(
|
card.set_service_status(
|
||||||
command.service, command.status, command.comment, author="диспетчер"
|
command.service, command.status, command.comment, author="диспетчер"
|
||||||
)
|
)
|
||||||
# Первичный статус останавливает норматив 30 секунд.
|
_first_record(card, command.comment)
|
||||||
if command.status in PRIMARY:
|
if command.status in PRIMARY:
|
||||||
card.on_event("card.ack")
|
|
||||||
# Сигнал реестра "повторные отказы" считает подряд идущие
|
# Сигнал реестра "повторные отказы" считает подряд идущие
|
||||||
# отказы, а не сумму за занятие.
|
# отказы, а не сумму за занятие.
|
||||||
session.consecutive_refusals = (
|
session.consecutive_refusals = (
|
||||||
|
|
@ -470,10 +480,6 @@ class DdsDesk(BaseModel):
|
||||||
if command.status is ServiceStatus.DECLINED
|
if command.status is ServiceStatus.DECLINED
|
||||||
else 0
|
else 0
|
||||||
)
|
)
|
||||||
if command.status in {
|
|
||||||
ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
|
|
||||||
}:
|
|
||||||
card.on_event("dds.complete")
|
|
||||||
case "crew.select":
|
case "crew.select":
|
||||||
if command.crew not in session.crew_options(card):
|
if command.crew not in session.crew_options(card):
|
||||||
return _refused("Выберите бригаду из списка доступных")
|
return _refused("Выберите бригаду из списка доступных")
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
|
from app.domain.events import CallEnded, CallEndReason, Exercise, ScoreReady, SessionEnded
|
||||||
from app.domain.statuses import ServiceStatus, current
|
|
||||||
from app.domain.timers import TimerCode
|
from app.domain.timers import TimerCode
|
||||||
from app.scoring.ai_coach import coach
|
from app.scoring.ai_coach import coach
|
||||||
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
from app.scoring.dispatcher import dispatcher_metrics, evaluate_dispatcher
|
||||||
|
|
@ -65,16 +64,11 @@ def score_dds_card(state, card: DdsLiveCard) -> DdsCardRecord:
|
||||||
work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
|
work_elapsed_ms = card.timers.measured_ms(TimerCode.DDS_WORK)
|
||||||
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
if work_elapsed_ms is None and work_timer is not None and work_timer.started_at is not None:
|
||||||
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
work_elapsed_ms = work_timer.current_ms(time.monotonic())
|
||||||
terminal = bool(services) and all(
|
# 3 минуты — на открытие и первую запись (статус с текстом), а не на
|
||||||
current(card.status_log, service) in {
|
# закрытие: работы по карточке могут идти часы и дни (ответ заказчика, П.5).
|
||||||
ServiceStatus.COMPLETED,
|
recorded = any(mark.comment.strip() for mark in card.status_log if mark.service in services)
|
||||||
ServiceStatus.DECLINED,
|
|
||||||
ServiceStatus.REFUSED,
|
|
||||||
}
|
|
||||||
for service in services
|
|
||||||
)
|
|
||||||
work_metric, work_finding = time_metric(
|
work_metric, work_finding = time_metric(
|
||||||
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=terminal
|
DDS_WORK_TIME, work_elapsed_ms, work_limit_ms, completed=recorded
|
||||||
)
|
)
|
||||||
weighted.metrics.append(work_metric)
|
weighted.metrics.append(work_metric)
|
||||||
if work_finding is not None:
|
if work_finding is not None:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
|
||||||
"call.incoming": (TimerCode.ANSWER,),
|
"call.incoming": (TimerCode.ANSWER,),
|
||||||
"call.answer": (TimerCode.INTERVIEW,),
|
"call.answer": (TimerCode.INTERVIEW,),
|
||||||
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
|
||||||
|
# Ответ заказчика (П.5): 30 с — от появления карточки в строке сообщений
|
||||||
|
# до её открытия, 3 мин — на открытие и первую запись.
|
||||||
"dds.open": (TimerCode.DDS_WORK,),
|
"dds.open": (TimerCode.DDS_WORK,),
|
||||||
"card.start": (TimerCode.CARD_FILL,),
|
"card.start": (TimerCode.CARD_FILL,),
|
||||||
"card.received": (TimerCode.ZONE_CHECK,),
|
"card.received": (TimerCode.ZONE_CHECK,),
|
||||||
|
|
@ -55,10 +57,12 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
|
||||||
# Справка и передача в другой регион заканчивают опрос так же, как передача
|
# Справка и передача в другой регион заканчивают опрос так же, как передача
|
||||||
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
# карточки: норматив опроса не должен тикать после решения (lct-36).
|
||||||
"call.resolve": (TimerCode.INTERVIEW,),
|
"call.resolve": (TimerCode.INTERVIEW,),
|
||||||
"card.ack": (TimerCode.DDS_ACK,),
|
|
||||||
"card.submit": (TimerCode.CARD_FILL,),
|
"card.submit": (TimerCode.CARD_FILL,),
|
||||||
"card.end": (TimerCode.CARD_FILL,),
|
"card.end": (TimerCode.CARD_FILL,),
|
||||||
"dds.complete": (TimerCode.DDS_WORK,),
|
"dds.open": (TimerCode.DDS_ACK,),
|
||||||
|
# Первая запись — первый ручной статус с текстом. Дальше работы могут идти
|
||||||
|
# часы и дни: остальные статусы по времени не нормируются (П.5).
|
||||||
|
"dds.record": (TimerCode.DDS_WORK,),
|
||||||
"dds.finish": (TimerCode.DDS_WORK,),
|
"dds.finish": (TimerCode.DDS_WORK,),
|
||||||
"zone.decision": (TimerCode.ZONE_CHECK,),
|
"zone.decision": (TimerCode.ZONE_CHECK,),
|
||||||
"crew.arrived": (TimerCode.CLOSE,),
|
"crew.arrived": (TimerCode.CLOSE,),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"dds_several_cards": {
|
"dds_several_cards": {
|
||||||
"score_auto": 53.3,
|
"score_auto": 65.3,
|
||||||
"card_status": "processed",
|
"card_status": "processed",
|
||||||
"metrics": [
|
"metrics": [
|
||||||
[
|
[
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 1: Служба 101: первичная отметка за 30 с",
|
"Карточка 1: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -61,11 +61,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 1: Отработка карточки ДДС",
|
"Карточка 1: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 2: СМП: первичная отметка за 30 с",
|
"Карточка 2: СМП: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -125,11 +125,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 2: Отработка карточки ДДС",
|
"Карточка 2: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -141,7 +141,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 3: Служба 101: первичная отметка за 30 с",
|
"Карточка 3: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -189,11 +189,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 3: Отработка карточки ДДС",
|
"Карточка 3: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"findings": [
|
"findings": [
|
||||||
|
|
@ -207,11 +207,6 @@
|
||||||
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Прибытие, Проведение работ"
|
"не отмечены: Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"D2",
|
"D2",
|
||||||
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
|
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
|
||||||
|
|
@ -222,11 +217,6 @@
|
||||||
"Карточка 2: СМП: ход реагирования не доведён до конца",
|
"Карточка 2: СМП: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"D5",
|
"D5",
|
||||||
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
|
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
|
||||||
|
|
@ -236,17 +226,12 @@
|
||||||
"D6",
|
"D6",
|
||||||
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Прибытие, Проведение работ"
|
"не отмечены: Прибытие, Проведение работ"
|
||||||
],
|
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 3: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"cards": [
|
"cards": [
|
||||||
[
|
[
|
||||||
"fire-apartment-l2",
|
"fire-apartment-l2",
|
||||||
56.0,
|
68.0,
|
||||||
"Принято, бригада направлена",
|
"Принято, бригада направлена",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -259,7 +244,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"t20-2-stroke",
|
"t20-2-stroke",
|
||||||
48.0,
|
60.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -268,7 +253,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"t01-1-fire-container",
|
"t01-1-fire-container",
|
||||||
56.0,
|
68.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -281,7 +266,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dds_early_finish": {
|
"dds_early_finish": {
|
||||||
"score_auto": 35.3,
|
"score_auto": 44.1,
|
||||||
"card_status": "processed",
|
"card_status": "processed",
|
||||||
"metrics": [
|
"metrics": [
|
||||||
[
|
[
|
||||||
|
|
@ -294,7 +279,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 1: СМП: первичная отметка за 30 с",
|
"Карточка 1: СМП: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -342,11 +327,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 1: Отработка карточки ДДС",
|
"Карточка 1: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -358,16 +343,16 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 2: Служба 101: первичная отметка за 30 с",
|
"Карточка 2: Служба 101: открытие карточки за 30 с",
|
||||||
"не отмечено",
|
"карточка не открыта",
|
||||||
false,
|
false,
|
||||||
1.0,
|
1.0,
|
||||||
null
|
null
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 2: Отработка карточки ДДС",
|
"Карточка 2: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; время обработки не зафиксировано",
|
"первая запись не добавлена; карточка не открыта",
|
||||||
false,
|
false,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
0.0
|
||||||
|
|
@ -385,25 +370,25 @@
|
||||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"E3",
|
"D1",
|
||||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
"Карточка 2: Служба 101: карточка не открыта",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
"карточка не открыта до конца занятия"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"D1",
|
"D1",
|
||||||
"Карточка 2: Служба 101: статус реагирования не проставлен",
|
"Карточка 2: Служба 101: статус реагирования не проставлен",
|
||||||
"карточка осталась без отметки"
|
"карточка осталась без первичного статуса"
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"E3",
|
"E3",
|
||||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
"Карточка 2: ДДС: первая запись по карточке позже норматива",
|
||||||
"карточка не завершена; время обработки не зафиксировано"
|
"первая запись не добавлена; карточка не открыта"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"cards": [
|
"cards": [
|
||||||
[
|
[
|
||||||
"t20-2-stroke",
|
"t20-2-stroke",
|
||||||
48.0,
|
60.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -419,7 +404,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"handoff_112_to_dds": {
|
"handoff_112_to_dds": {
|
||||||
"score_auto": 47.8,
|
"score_auto": 56.5,
|
||||||
"card_status": "processed",
|
"card_status": "processed",
|
||||||
"metrics": [
|
"metrics": [
|
||||||
[
|
[
|
||||||
|
|
@ -480,7 +465,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 1: Служба 101: первичная отметка за 30 с",
|
"Карточка 1: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -528,11 +513,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 1: Отработка карточки ДДС",
|
"Карточка 1: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_primary",
|
"dds_primary",
|
||||||
|
|
@ -544,7 +529,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Карточка 2: Служба 101: первичная отметка за 30 с",
|
"Карточка 2: Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
@ -592,11 +577,11 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_work_time",
|
"dds_work_time",
|
||||||
"Карточка 2: Отработка карточки ДДС",
|
"Карточка 2: Первая запись по карточке ДДС",
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)",
|
"0 с (на 180 с быстрее норматива)",
|
||||||
false,
|
true,
|
||||||
1.5,
|
1.5,
|
||||||
0.0
|
1.0
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"findings": [
|
"findings": [
|
||||||
|
|
@ -630,11 +615,6 @@
|
||||||
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Прибытие, Проведение работ"
|
"не отмечены: Прибытие, Проведение работ"
|
||||||
],
|
],
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"D2",
|
"D2",
|
||||||
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
|
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
|
||||||
|
|
@ -644,17 +624,12 @@
|
||||||
"D6",
|
"D6",
|
||||||
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
|
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
|
||||||
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
|
||||||
],
|
|
||||||
[
|
|
||||||
"E3",
|
|
||||||
"Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
|
|
||||||
"карточка не завершена; 0 с (на 180 с быстрее норматива)"
|
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"cards": [
|
"cards": [
|
||||||
[
|
[
|
||||||
"fire-apartment-l2",
|
"fire-apartment-l2",
|
||||||
56.0,
|
68.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -666,7 +641,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"t01-1-fire-container",
|
"t01-1-fire-container",
|
||||||
48.0,
|
60.0,
|
||||||
"",
|
"",
|
||||||
[
|
[
|
||||||
"card.ack",
|
"card.ack",
|
||||||
|
|
@ -745,7 +720,7 @@
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"dds_ack",
|
"dds_ack",
|
||||||
"Служба 101: первичная отметка за 30 с",
|
"Служба 101: открытие карточки за 30 с",
|
||||||
"0.0 с",
|
"0.0 с",
|
||||||
true,
|
true,
|
||||||
1.0,
|
1.0,
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,9 @@ def fire():
|
||||||
|
|
||||||
def accept_with_crew(state: SessionState, card) -> None:
|
def accept_with_crew(state: SessionState, card) -> None:
|
||||||
service = state.card_services(card)[0]
|
service = state.card_services(card)[0]
|
||||||
|
card.on_event("dds.open") # карточка открыта: 30 с остановлены
|
||||||
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
card.set_service_status(service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||||
card.on_event("card.ack")
|
card.on_event("dds.record")
|
||||||
card.crew_selected = state.crew_options(card)[0]
|
card.crew_selected = state.crew_options(card)[0]
|
||||||
card.crew_assignments[service] = card.crew_selected
|
card.crew_assignments[service] = card.crew_selected
|
||||||
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
|
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
|
||||||
|
|
|
||||||
|
|
@ -443,11 +443,13 @@ def test_instructor_criteria_change_timer_and_success_threshold(client, monkeypa
|
||||||
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
|
reply_metric = next(item for item in score["metrics"] if item["key"] == "dds_reply")
|
||||||
assert reply_metric["passed"]
|
assert reply_metric["passed"]
|
||||||
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
|
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
|
||||||
|
# 3 минуты — до первой записи, а она сделана сразу: незавершённая
|
||||||
|
# работа по времени не штрафуется (ответ заказчика, П.5).
|
||||||
assert (
|
assert (
|
||||||
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
|
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
|
||||||
"passed"
|
"passed"
|
||||||
]
|
]
|
||||||
is False
|
is True
|
||||||
)
|
)
|
||||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
assert report["criteria"] == {
|
assert report["criteria"] == {
|
||||||
|
|
@ -500,9 +502,8 @@ def test_dds_statuses_do_not_require_phone_reports(client):
|
||||||
finding["code"] == "D2" and "бригады" in finding["summary"]
|
finding["code"] == "D2" and "бригады" in finding["summary"]
|
||||||
for finding in score["findings"]
|
for finding in score["findings"]
|
||||||
)
|
)
|
||||||
assert any(
|
assert not any(finding["code"] == "E3" for finding in score["findings"]), (
|
||||||
finding["code"] == "E3" and "времени отработки" in finding["summary"]
|
"первая запись сделана сразу: дальше работы по времени не нормируются"
|
||||||
for finding in score["findings"]
|
|
||||||
)
|
)
|
||||||
finding_codes = {finding["code"] for finding in score["findings"]}
|
finding_codes = {finding["code"] for finding in score["findings"]}
|
||||||
penalty_codes = {
|
penalty_codes = {
|
||||||
|
|
@ -703,22 +704,26 @@ def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
|
||||||
control.__exit__(None, None, None)
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
||||||
def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client):
|
def test_late_first_record_has_e3_and_long_work_after_it_does_not(client):
|
||||||
|
"""П.5: 3 минуты — на открытие и первую запись; дальше работы идут часы."""
|
||||||
session_id, control = start(
|
session_id, control = start(
|
||||||
client, criteria={"dds_card_work_time_limit_seconds": 60}
|
client, criteria={"dds_card_work_time_limit_seconds": 60}
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
state = hub.get(session_id)
|
state = hub.get(session_id)
|
||||||
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
with client.websocket_connect(f"/ws/station/{session_id}") as station:
|
||||||
read_until(station, "card.received")
|
received = read_until(station, "card.received")
|
||||||
snapshot = read_until(station, "station.state")["snapshot"]
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
service = snapshot["services"][0]
|
service = snapshot["services"][0]
|
||||||
|
station.send_json({"type": "card.open", "card_id": received["card"]["card_id"]})
|
||||||
|
read_until(station, "station.state")
|
||||||
|
assert state.desk.active.timers.measured_ms(TimerCode.DDS_ACK) is not None
|
||||||
|
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
|
||||||
station.send_json(
|
station.send_json(
|
||||||
{"type": "card.status", "service": service, "status": "accepted",
|
{"type": "card.status", "service": service, "status": "accepted",
|
||||||
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
|
||||||
)
|
)
|
||||||
snapshot = read_until(station, "station.state")["snapshot"]
|
snapshot = read_until(station, "station.state")["snapshot"]
|
||||||
state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
|
|
||||||
crew = next(
|
crew = next(
|
||||||
option
|
option
|
||||||
for option in snapshot["crew_options"]
|
for option in snapshot["crew_options"]
|
||||||
|
|
@ -740,8 +745,8 @@ def test_dds_card_over_three_minutes_has_e3_finding_and_actual_deviation(client)
|
||||||
)
|
)
|
||||||
assert not metric["passed"]
|
assert not metric["passed"]
|
||||||
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
|
assert "61 с" in metric["fact"] and "+1 с" in metric["fact"]
|
||||||
finding = next(item for item in score["findings"] if item["code"] == "E3")
|
findings = [item for item in score["findings"] if item["code"] == "E3"]
|
||||||
assert "времени отработки карточки" in finding["summary"]
|
assert len(findings) == 1 and "первая запись" in findings[0]["summary"]
|
||||||
finally:
|
finally:
|
||||||
control.__exit__(None, None, None)
|
control.__exit__(None, None, None)
|
||||||
|
|
||||||
|
|
@ -916,7 +921,9 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
|
||||||
item["key"] == "dds_grammar"
|
item["key"] == "dds_grammar"
|
||||||
for item in state.score["metrics"]
|
for item in state.score["metrics"]
|
||||||
)
|
)
|
||||||
assert any(item["code"] == "E3" for item in state.score["findings"])
|
assert not any(item["code"] == "E3" for item in state.score["findings"]), (
|
||||||
|
"обе карточки получили первую запись сразу"
|
||||||
|
)
|
||||||
report = client.get(f"/api/sessions/{session_id}/report").json()
|
report = client.get(f"/api/sessions/{session_id}/report").json()
|
||||||
assert report["scenario_id"] == "fire-apartment-l2"
|
assert report["scenario_id"] == "fire-apartment-l2"
|
||||||
assert len(report["card_results"]) == 2
|
assert len(report["card_results"]) == 2
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,9 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
|
||||||
first_card = state.desk.active
|
first_card = state.desk.active
|
||||||
first_id = first_card.card_id
|
first_id = first_card.card_id
|
||||||
first_service = state.card_services(first_card)[0]
|
first_service = state.card_services(first_card)[0]
|
||||||
|
first_card.on_event("dds.open") # карточка открыта: 30 с остановлены
|
||||||
first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
|
first_card.set_service_status(first_service, ServiceStatus.ACCEPTED, "Принято в работу")
|
||||||
first_card.on_event("card.ack")
|
first_card.on_event("dds.record")
|
||||||
second_id = state.desk.ordered()[1].card_id
|
second_id = state.desk.ordered()[1].card_id
|
||||||
assert state.desk.open(second_id)
|
assert state.desk.open(second_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,19 +62,21 @@ def test_card_fill_time_metric_texts(elapsed_ms, completed, fact, passed):
|
||||||
(605_000, True, "605 с (+425 с сверх норматива)", False),
|
(605_000, True, "605 с (+425 с сверх норматива)", False),
|
||||||
(25_000, True, "25 с (на 155 с быстрее норматива)", True),
|
(25_000, True, "25 с (на 155 с быстрее норматива)", True),
|
||||||
(180_000, True, "180 с (точно в норматив)", True),
|
(180_000, True, "180 с (точно в норматив)", True),
|
||||||
(None, False, "карточка не завершена; время обработки не зафиксировано", False),
|
(None, False, "первая запись не добавлена; карточка не открыта", False),
|
||||||
(25_000, False, "карточка не завершена; 25 с (на 155 с быстрее норматива)", False),
|
(25_000, False, "первая запись не добавлена; 25 с (на 155 с быстрее норматива)", False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_dds_work_time_metric_texts(elapsed_ms, completed, fact, passed):
|
def test_dds_work_time_metric_texts(elapsed_ms, completed, fact, passed):
|
||||||
metric, finding = time_metric(DDS_WORK_TIME, elapsed_ms, 180_000, completed=completed)
|
metric, finding = time_metric(DDS_WORK_TIME, elapsed_ms, 180_000, completed=completed)
|
||||||
|
|
||||||
assert metric.key == "dds_work_time"
|
assert metric.key == "dds_work_time"
|
||||||
assert metric.title == "Отработка карточки ДДС"
|
assert metric.title == "Первая запись по карточке ДДС"
|
||||||
assert metric.fact == fact
|
assert metric.fact == fact
|
||||||
assert metric.norm == "завершить за 180 с"
|
assert metric.norm == "открыть карточку и добавить первую запись (статус и текст) за 180 с"
|
||||||
assert metric.passed is passed
|
assert metric.passed is passed
|
||||||
assert (finding is None) is passed
|
assert (finding is None) is passed
|
||||||
if finding is not None:
|
if finding is not None:
|
||||||
assert finding.summary == "ДДС: норматив времени отработки карточки не выполнен"
|
assert finding.summary == "ДДС: первая запись по карточке позже норматива"
|
||||||
assert (finding.fact, finding.norm) == (fact, "завершить за 180 с")
|
assert (finding.fact, finding.norm) == (
|
||||||
|
fact, "открыть карточку и добавить первую запись (статус и текст) за 180 с"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
|
||||||
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>документ (PDF)</a>
|
{" · "}<a href={`/api/sessions/${report.session_id}/report.pdf`}>документ (PDF)</a>
|
||||||
</p>
|
</p>
|
||||||
<p className={report.passed ? "state-ok" : "state-violated"}>
|
<p className={report.passed ? "state-ok" : "state-violated"}>
|
||||||
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На первичное решение ДДС отводится
|
{report.passed ? "Зачёт" : "Не зачтено"}: на разборе {report.failed_metrics} пунктов; допустимо пропустить {report.criteria.allowed_errors}. {report.exercise === "dds" && <>На открытие карточки ДДС отводится
|
||||||
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
|
{` ${report.criteria.decision_time_limit_seconds} с`}</>}
|
||||||
{report.exercise === "dds" && <>; норматив отработки карточки ДДС —
|
{report.exercise === "dds" && <>; на первую запись по карточке —
|
||||||
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
|
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}</>}
|
||||||
{report.exercise === "card" && <>Норматив заполнения КИО —
|
{report.exercise === "card" && <>Норматив заполнения КИО —
|
||||||
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
|
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}</>}
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,13 @@ const OPERATOR_METRICS: MetricOption[] = [
|
||||||
|
|
||||||
const DDS_METRICS: MetricOption[] = [
|
const DDS_METRICS: MetricOption[] = [
|
||||||
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
|
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
|
||||||
{ key: "dds_ack", label: "Срок первичного решения", hint: "Соблюдение установленного лимита реакции." },
|
{ key: "dds_ack", label: "Открытие карточки", hint: "Карточка открыта в пределах лимита после появления в строке сообщений." },
|
||||||
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
|
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
|
||||||
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
|
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
|
||||||
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
|
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
|
||||||
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
|
{ key: "dds_completion", label: "Завершение работ", hint: "Фиксация завершения или отказа от работ." },
|
||||||
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
{ key: "dds_reply", label: "Основание и сведения", hint: "Комментарии к статусам и содержание полученных сведений." },
|
||||||
{ key: "dds_work_time", label: "Время отработки карточки", hint: "Соблюдение лимита полного цикла карточки." },
|
{ key: "dds_work_time", label: "Первая запись", hint: "Первый статус с текстом в пределах лимита после открытия карточки." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
const CARD_METRICS = OPERATOR_METRICS.filter(({ key }) => [
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ const TITLES: Record<string, string> = {
|
||||||
answer: "Ответ на звонок",
|
answer: "Ответ на звонок",
|
||||||
interview: "Опрос",
|
interview: "Опрос",
|
||||||
dds_notify: "Оповещение ДДС",
|
dds_notify: "Оповещение ДДС",
|
||||||
dds_ack: "Подтверждение ДДС",
|
dds_ack: "Открытие карточки ДДС",
|
||||||
dds_work: "Отработка карточки ДДС",
|
dds_work: "Первая запись ДДС",
|
||||||
card_fill: "Заполнение карточки КИО",
|
card_fill: "Заполнение карточки КИО",
|
||||||
zone_check: "Зона ответственности",
|
zone_check: "Зона ответственности",
|
||||||
callback: "Обратный дозвон",
|
callback: "Обратный дозвон",
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,7 @@ export function Dds() {
|
||||||
<td data-label="Статус службы"><span className="dds-cell-value">
|
<td data-label="Статус службы"><span className="dds-cell-value">
|
||||||
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
|
<span>{item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]}</span>
|
||||||
<small className={!item.timer_stopped && item.elapsed_ms > item.limit_ms ? "state-violated" : ""}>
|
<small className={!item.timer_stopped && item.elapsed_ms > item.limit_ms ? "state-violated" : ""}>
|
||||||
{item.timer_stopped ? "решение за" : "до первичного решения"} {Math.ceil(item.elapsed_ms / 1000)} с
|
{item.timer_stopped ? "открыта за" : "не открыта"} {Math.ceil(item.elapsed_ms / 1000)} с
|
||||||
</small>
|
</small>
|
||||||
</span></td>
|
</span></td>
|
||||||
</tr>})}
|
</tr>})}
|
||||||
|
|
|
||||||
|
|
@ -364,9 +364,9 @@ export function Instructor() {
|
||||||
<strong>{card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title}</strong>
|
<strong>{card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title}</strong>
|
||||||
{card.address && <span>{card.address}</span>}
|
{card.address && <span>{card.address}</span>}
|
||||||
<small>{card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]}</small>
|
<small>{card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]}</small>
|
||||||
<small>{card.timer_stopped ? `Первичная реакция за ${Math.ceil(card.elapsed_ms / 1000)} с` :
|
<small>{card.timer_stopped ? `Открыта за ${Math.ceil(card.elapsed_ms / 1000)} с` :
|
||||||
card.elapsed_ms > card.limit_ms ? `Просрочена первичная реакция · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` :
|
card.elapsed_ms > card.limit_ms ? `Просрочено открытие · ${Math.ceil((card.elapsed_ms - card.limit_ms) / 1000)} с` :
|
||||||
`На первичное решение осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}</small>
|
`До открытия осталось ${Math.ceil((card.limit_ms - card.elapsed_ms) / 1000)} с`}</small>
|
||||||
</article>)}
|
</article>)}
|
||||||
{item.dds_snapshot.pending_cards_count ? <p className="ref">Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}</p> : null}
|
{item.dds_snapshot.pending_cards_count ? <p className="ref">Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}</p> : null}
|
||||||
{item.dds_snapshot.log.length > 0 && <div className="active-session-log">
|
{item.dds_snapshot.log.length > 0 && <div className="active-session-log">
|
||||||
|
|
@ -387,7 +387,7 @@ export function Instructor() {
|
||||||
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
|
<td>{item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_cards}` : "—"}</td>
|
||||||
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
|
<td className={item.dds_overdue_cards ? "state-violated" : "state-ok"}>
|
||||||
{item.dds_overdue_cards || item.dds_work_overdue_cards
|
{item.dds_overdue_cards || item.dds_work_overdue_cards
|
||||||
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
|
? `Просрочено: открытие ${item.dds_overdue_cards}; первая запись ${item.dds_work_overdue_cards}`
|
||||||
: "Нормативы не нарушены"}
|
: "Нормативы не нарушены"}
|
||||||
</td>
|
</td>
|
||||||
<td className={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
|
<td className={worstSignalSeverity(item.signals) ? `state-${worstSignalSeverity(item.signals)}` : item.presence_known ? "state-ok" : undefined}>
|
||||||
|
|
@ -623,9 +623,9 @@ export function Instructor() {
|
||||||
<summary>Настроить нормативы и критерии оценки</summary>
|
<summary>Настроить нормативы и критерии оценки</summary>
|
||||||
<p className="ref">
|
<p className="ref">
|
||||||
{exercise === "dds"
|
{exercise === "dds"
|
||||||
? `По умолчанию: решение за ${decisionTimeLimit} с, отработка карточки за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.`
|
? `По умолчанию: открытие карточки за ${decisionTimeLimit} с, первая запись за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.`
|
||||||
: exercise === "card_dds"
|
: exercise === "card_dds"
|
||||||
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, отработка карточки ДДС за ${ddsCardWorkTimeLimit} с.`
|
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, первая запись ДДС за ${ddsCardWorkTimeLimit} с.`
|
||||||
: exercise === "card"
|
: exercise === "card"
|
||||||
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с.`
|
? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с.`
|
||||||
: "Для звонка действуют нормативы приёма вызова по ГОСТ."}
|
: "Для звонка действуют нормативы приёма вызова по ГОСТ."}
|
||||||
|
|
@ -644,8 +644,8 @@ export function Instructor() {
|
||||||
</label>
|
</label>
|
||||||
</>}
|
</>}
|
||||||
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
Решение ДДС, секунд
|
Открытие карточки ДДС, секунд
|
||||||
<LessonNumberInput label="Лимит решения ДДС, секунд" value={decisionTimeLimit}
|
<LessonNumberInput label="Лимит открытия карточки ДДС, секунд" value={decisionTimeLimit}
|
||||||
min={5} max={300} fallback={30} onCommit={setDecisionTimeLimit} />
|
min={5} max={300} fallback={30} onCommit={setDecisionTimeLimit} />
|
||||||
</label>}
|
</label>}
|
||||||
{(exercise === "card" || exercise === "card_dds") && <label className="lesson-criterion">
|
{(exercise === "card" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
|
|
@ -655,11 +655,11 @@ export function Instructor() {
|
||||||
<small className="ref">По умолчанию 180 секунд; в отчёте будут факт и отклонение от норматива.</small>
|
<small className="ref">По умолчанию 180 секунд; в отчёте будут факт и отклонение от норматива.</small>
|
||||||
</label>}
|
</label>}
|
||||||
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
{(exercise === "dds" || exercise === "card_dds") && <label className="lesson-criterion">
|
||||||
Норматив отработки карточки ДДС, секунд
|
Первая запись по карточке ДДС, секунд
|
||||||
<LessonNumberInput label="Норматив отработки карточки ДДС, секунд"
|
<LessonNumberInput label="Норматив первой записи по карточке ДДС, секунд"
|
||||||
value={ddsCardWorkTimeLimit} min={30} max={1800} fallback={180}
|
value={ddsCardWorkTimeLimit} min={30} max={1800} fallback={180}
|
||||||
onCommit={setDdsCardWorkTimeLimit} />
|
onCommit={setDdsCardWorkTimeLimit} />
|
||||||
<small className="ref">По умолчанию 180 секунд; не заменяет 30-секундное первичное решение.</small>
|
<small className="ref">По умолчанию 180 секунд от открытия до первого статуса с текстом; дальше работы по времени не нормируются.</small>
|
||||||
</label>}
|
</label>}
|
||||||
<label className="lesson-criterion">
|
<label className="lesson-criterion">
|
||||||
Допустимо нарушенных метрик
|
Допустимо нарушенных метрик
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue