lct-50: таймеры ДДС — 30 с до открытия карточки, 3 минуты до первой записи

This commit is contained in:
Ivan Gerasimov 2026-09-27 23:38:10 +03:00
commit 51ef99c472
18 changed files with 169 additions and 169 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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 "стандартные"),

View file

@ -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="первая запись не добавлена",
) )

View file

@ -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("Выберите бригаду из списка доступных")

View file

@ -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:

View file

@ -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,),

View file

@ -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,

View file

@ -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))

View file

@ -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

View file

@ -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)

View file

@ -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 с"
)

View file

@ -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} с`}</>}

View file

@ -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 }) => [

View file

@ -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: "Обратный дозвон",

View file

@ -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>})}

View file

@ -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">
Допустимо нарушенных метрик Допустимо нарушенных метрик