From 51ef99c47271061fa1077db8a6096a68717c1345 Mon Sep 17 00:00:00 2001
From: Ivan Gerasimov
Date: Sun, 27 Sep 2026 23:38:10 +0300
Subject: [PATCH] =?UTF-8?q?lct-50:=20=D1=82=D0=B0=D0=B9=D0=BC=D0=B5=D1=80?=
=?UTF-8?q?=D1=8B=20=D0=94=D0=94=D0=A1=20=E2=80=94=2030=20=D1=81=20=D0=B4?=
=?UTF-8?q?=D0=BE=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20?=
=?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8,=203=20=D0=BC?=
=?UTF-8?q?=D0=B8=D0=BD=D1=83=D1=82=D1=8B=20=D0=B4=D0=BE=20=D0=BF=D0=B5?=
=?UTF-8?q?=D1=80=D0=B2=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/api/http/sessions.py | 18 ++-
backend/app/domain/timers.py | 9 +-
backend/app/scoring/dispatcher.py | 53 ++++----
backend/app/scoring/export.py | 4 +-
backend/app/scoring/timing.py | 10 +-
backend/app/session/dds.py | 24 ++--
backend/app/session/finish.py | 14 +-
backend/app/session/timers.py | 8 +-
backend/tests/golden/dds_desk_scores.json | 125 +++++++-----------
backend/tests/test_dds_desk_checkpoint.py | 3 +-
backend/tests/test_dds_exercise.py | 27 ++--
backend/tests/test_session_checkpoint.py | 3 +-
backend/tests/test_timing_score.py | 14 +-
frontend/src/features/debrief/Debrief.tsx | 4 +-
.../instructor/ScoreWeightsEditor.tsx | 4 +-
frontend/src/features/timers/Timers.tsx | 4 +-
frontend/src/pages/dds/Dds.tsx | 2 +-
frontend/src/pages/instructor/Instructor.tsx | 22 +--
18 files changed, 174 insertions(+), 174 deletions(-)
diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py
index c91eb16..dc74837 100644
--- a/backend/app/api/http/sessions.py
+++ b/backend/app/api/http/sessions.py
@@ -24,7 +24,13 @@ from app.db.base import get_session
from app.db.models import Group, Score, Session, Trainee
from app.domain.events import Exercise, SessionMode, SessionReport
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.scoring.export import to_csv, to_pdf
from app.scoring.report import build as build_report
@@ -212,10 +218,12 @@ def _signals(
"""
settings = get_settings()
signals: list[Signal] = []
- # Необработанная — без первичного статуса: он останавливает норматив
- # DDS_ACK. Принятая или отклонённая карточка висит в очереди до
- # «Следующей», но реакции курсанта уже не ждёт.
- pending = sum(not card.timer_stopped for card in queue)
+ # Необработанная — без первичного статуса. Норматив DDS_ACK теперь
+ # останавливается открытием карточки, поэтому считаем по статусу службы:
+ # открытая, но не принятая и не отклонённая карточка реакции ещё ждёт.
+ pending = sum(
+ card.service_status in {ServiceStatus.ADDED, ServiceStatus.RECEIVED} for card in queue
+ )
if pending >= settings.signal_backlog_threshold:
signals.append(Signal(
kind="backlog", severity=TimerState.WARN,
diff --git a/backend/app/domain/timers.py b/backend/app/domain/timers.py
index 5a9ac43..733b2c5 100644
--- a/backend/app/domain/timers.py
+++ b/backend/app/domain/timers.py
@@ -59,10 +59,11 @@ NORMATIVES: dict[TimerCode, Normative] = {
TimerCode.DDS_ACK: Normative(
# 30 секунд — ПП РФ № 1931 и памятка АРМ-112, а не ГОСТ: до получения
# датасета здесь стояло 4 секунды, взятые по догадке (docs/spec/DATASET.md).
+ # Отсчёт — от появления карточки до её открытия (ответ заказчика, П.5).
code=TimerCode.DDS_ACK,
- title="Подтверждение получения карточки",
+ title="Открытие карточки ДДС",
limit_ms=30_000,
- ref="ПП РФ № 1931",
+ ref="ПП РФ № 1931; ответ заказчика, П.5",
),
TimerCode.CARD_FILL: Normative(
code=TimerCode.CARD_FILL,
@@ -72,9 +73,9 @@ NORMATIVES: dict[TimerCode, Normative] = {
),
TimerCode.DDS_WORK: Normative(
code=TimerCode.DDS_WORK,
- title="Отработка карточки ДДС",
+ title="Первая запись по карточке ДДС",
limit_ms=180_000,
- ref="ТЗ задачи № 9; сессия вопросов и ответов 16.09.2026",
+ ref="ТЗ задачи № 9; ответ заказчика, П.5",
),
TimerCode.ZONE_CHECK: Normative(
code=TimerCode.ZONE_CHECK, title="Проверка зоны ответственности", limit_ms=30_000
diff --git a/backend/app/scoring/dispatcher.py b/backend/app/scoring/dispatcher.py
index 0be2530..1efc1d9 100644
--- a/backend/app/scoring/dispatcher.py
+++ b/backend/app/scoring/dispatcher.py
@@ -22,7 +22,7 @@ from app.domain.statuses import (
)
from app.domain.taxonomy import Competency, ErrorCode, Finding, FindingSource
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)
@@ -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)
if primary is None:
findings.append(
_finding(
ErrorCode.D1,
f"{service}: статус реагирования не проставлен",
- fact=(
- f"прошло {elapsed_ms // 1000} с"
- if elapsed_ms is not None
- else "карточка осталась без отметки"
- ),
- norm=f"первичный статус в течение {deadline_ms // 1000} с",
+ fact="карточка осталась без первичного статуса",
+ norm="проставить «Принята» или «Не принята» с комментарием",
)
)
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:
# D4 — отказ без комментария.
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)
- 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,
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,
- f"{elapsed / 1000:.1f} с" if elapsed is not None else "не отмечено",
+ f"{elapsed / 1000:.1f} с" if elapsed is not None else "карточка не открыта",
f"≤ {deadline_ms // 1000} с")
if primary is None:
continue
diff --git a/backend/app/scoring/export.py b/backend/app/scoring/export.py
index 6452c0a..86e6116 100644
--- a/backend/app/scoring/export.py
+++ b/backend/app/scoring/export.py
@@ -37,7 +37,7 @@ def to_csv(report: SessionReport) -> bytes:
row("Занятие", "", "Сценарий", report.scenario_id)
row("Занятие", "", "Режим", report.mode.value)
row("Занятие", "", "Попытка", report.attempt)
- row("Критерии", "", "Лимит решения ДДС, с", report.criteria.decision_time_limit_seconds)
+ row("Критерии", "", "Лимит открытия карточки ДДС, с", report.criteria.decision_time_limit_seconds)
row("Критерии", "", "Допустимо ошибок", report.criteria.allowed_errors)
row("Критерии", "", "Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет")
row("Критерии", "", "Переопределения весов", report.criteria.score_weights or "стандартные")
@@ -150,7 +150,7 @@ def to_pdf(report: SessionReport) -> bytes:
("Сценарий", report.scenario_id),
("Режим", report.mode.value),
("Попытка", report.attempt),
- ("Лимит решения ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
+ ("Лимит открытия карточки ДДС", f"{report.criteria.decision_time_limit_seconds} с"),
("Допустимо ошибок", report.criteria.allowed_errors),
("Грамматика обязательна", "да" if report.criteria.require_correct_grammar else "нет"),
("Переопределения весов", report.criteria.score_weights or "стандартные"),
diff --git a/backend/app/scoring/timing.py b/backend/app/scoring/timing.py
index 31b2a8a..90f9db9 100644
--- a/backend/app/scoring/timing.py
+++ b/backend/app/scoring/timing.py
@@ -44,11 +44,11 @@ CARD_FILL_TIME = TimeNorm(
)
DDS_WORK_TIME = TimeNorm(
key="dds_work_time",
- title="Отработка карточки ДДС",
- summary="ДДС: норматив времени отработки карточки не выполнен",
- action="завершить",
- unmeasured="время обработки не зафиксировано",
- incomplete="карточка не завершена",
+ title="Первая запись по карточке ДДС",
+ summary="ДДС: первая запись по карточке позже норматива",
+ action="открыть карточку и добавить первую запись (статус и текст)",
+ unmeasured="карточка не открыта",
+ incomplete="первая запись не добавлена",
)
diff --git a/backend/app/session/dds.py b/backend/app/session/dds.py
index 48fc334..712b908 100644
--- a/backend/app/session/dds.py
+++ b/backend/app/session/dds.py
@@ -241,12 +241,23 @@ def _refused(message: str) -> DeskOutcome:
def _start_work_timer(card: DdsLiveCard) -> None:
- """Три минуты на работу идут один раз — с открытия карточки."""
+ """Открытие карточки: останавливает 30 с и запускает 3 мин — один раз.
+
+ Автоматическая активация первой карточки очереди открытием не считается:
+ карточка стоит в строке сообщений, пока диспетчер её не откроет. Первое
+ действие по карточке без явного открытия тоже доказывает, что она открыта.
+ """
timer = card.timers.timers.get(TimerCode.DDS_WORK)
if timer is None or timer.started_at is None:
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:
call = card.phone_pending
if call is None:
@@ -436,17 +447,17 @@ class DdsDesk(BaseModel):
return _refused("Для подтверждения приёма добавьте комментарий с основанием")
if any(action == "card.ack" for action, _at, _detail in card.dds_log):
return DeskOutcome()
- card.on_event("card.ack")
card.dds_log.append(("card.ack", now_utc(), None))
+ _start_work_timer(card)
services = session.card_services(card)
if services:
- _start_work_timer(card)
try:
card.set_service_status(
services[0], ServiceStatus.ACCEPTED, command.comment,
author="диспетчер",
)
session.consecutive_refusals = 0
+ _first_record(card, command.comment)
except StatusError:
pass # статус уже стоит: повторное нажатие ничего не меняет
case "card.status":
@@ -460,9 +471,8 @@ class DdsDesk(BaseModel):
card.set_service_status(
command.service, command.status, command.comment, author="диспетчер"
)
- # Первичный статус останавливает норматив 30 секунд.
+ _first_record(card, command.comment)
if command.status in PRIMARY:
- card.on_event("card.ack")
# Сигнал реестра "повторные отказы" считает подряд идущие
# отказы, а не сумму за занятие.
session.consecutive_refusals = (
@@ -470,10 +480,6 @@ class DdsDesk(BaseModel):
if command.status is ServiceStatus.DECLINED
else 0
)
- if command.status in {
- ServiceStatus.COMPLETED, ServiceStatus.DECLINED, ServiceStatus.REFUSED,
- }:
- card.on_event("dds.complete")
case "crew.select":
if command.crew not in session.crew_options(card):
return _refused("Выберите бригаду из списка доступных")
diff --git a/backend/app/session/finish.py b/backend/app/session/finish.py
index 7d7f64a..2899ef2 100644
--- a/backend/app/session/finish.py
+++ b/backend/app/session/finish.py
@@ -13,7 +13,6 @@ import time
from uuid import UUID
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.scoring.ai_coach import coach
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)
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())
- terminal = bool(services) and all(
- current(card.status_log, service) in {
- ServiceStatus.COMPLETED,
- ServiceStatus.DECLINED,
- ServiceStatus.REFUSED,
- }
- for service in services
- )
+ # 3 минуты — на открытие и первую запись (статус с текстом), а не на
+ # закрытие: работы по карточке могут идти часы и дни (ответ заказчика, П.5).
+ recorded = any(mark.comment.strip() for mark in card.status_log if mark.service in services)
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)
if work_finding is not None:
diff --git a/backend/app/session/timers.py b/backend/app/session/timers.py
index d3338e6..cfc31a2 100644
--- a/backend/app/session/timers.py
+++ b/backend/app/session/timers.py
@@ -41,6 +41,8 @@ STARTS: dict[str, tuple[TimerCode, ...]] = {
"call.incoming": (TimerCode.ANSWER,),
"call.answer": (TimerCode.INTERVIEW,),
"dds.dispatch": (TimerCode.DDS_ACK, TimerCode.CLOSE),
+ # Ответ заказчика (П.5): 30 с — от появления карточки в строке сообщений
+ # до её открытия, 3 мин — на открытие и первую запись.
"dds.open": (TimerCode.DDS_WORK,),
"card.start": (TimerCode.CARD_FILL,),
"card.received": (TimerCode.ZONE_CHECK,),
@@ -55,10 +57,12 @@ STOPS: dict[str, tuple[TimerCode, ...]] = {
# Справка и передача в другой регион заканчивают опрос так же, как передача
# карточки: норматив опроса не должен тикать после решения (lct-36).
"call.resolve": (TimerCode.INTERVIEW,),
- "card.ack": (TimerCode.DDS_ACK,),
"card.submit": (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,),
"zone.decision": (TimerCode.ZONE_CHECK,),
"crew.arrived": (TimerCode.CLOSE,),
diff --git a/backend/tests/golden/dds_desk_scores.json b/backend/tests/golden/dds_desk_scores.json
index 84654ca..770f038 100644
--- a/backend/tests/golden/dds_desk_scores.json
+++ b/backend/tests/golden/dds_desk_scores.json
@@ -1,6 +1,6 @@
{
"dds_several_cards": {
- "score_auto": 53.3,
+ "score_auto": 65.3,
"card_status": "processed",
"metrics": [
[
@@ -13,7 +13,7 @@
],
[
"dds_ack",
- "Карточка 1: Служба 101: первичная отметка за 30 с",
+ "Карточка 1: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@@ -61,11 +61,11 @@
],
[
"dds_work_time",
- "Карточка 1: Отработка карточки ДДС",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)",
- false,
+ "Карточка 1: Первая запись по карточке ДДС",
+ "0 с (на 180 с быстрее норматива)",
+ true,
1.5,
- 0.0
+ 1.0
],
[
"dds_primary",
@@ -77,7 +77,7 @@
],
[
"dds_ack",
- "Карточка 2: СМП: первичная отметка за 30 с",
+ "Карточка 2: СМП: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@@ -125,11 +125,11 @@
],
[
"dds_work_time",
- "Карточка 2: Отработка карточки ДДС",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)",
- false,
+ "Карточка 2: Первая запись по карточке ДДС",
+ "0 с (на 180 с быстрее норматива)",
+ true,
1.5,
- 0.0
+ 1.0
],
[
"dds_primary",
@@ -141,7 +141,7 @@
],
[
"dds_ack",
- "Карточка 3: Служба 101: первичная отметка за 30 с",
+ "Карточка 3: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@@ -189,11 +189,11 @@
],
[
"dds_work_time",
- "Карточка 3: Отработка карточки ДДС",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)",
- false,
+ "Карточка 3: Первая запись по карточке ДДС",
+ "0 с (на 180 с быстрее норматива)",
+ true,
1.5,
- 0.0
+ 1.0
]
],
"findings": [
@@ -207,11 +207,6 @@
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
- [
- "E3",
- "Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)"
- ],
[
"D2",
"Карточка 2: СМП: «Принята» без назначения бригады и хода реагирования",
@@ -222,11 +217,6 @@
"Карточка 2: СМП: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
- [
- "E3",
- "Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)"
- ],
[
"D5",
"Карточка 3: Служба 101: комментарии к статусам не разделяют основание и сведения",
@@ -236,17 +226,12 @@
"D6",
"Карточка 3: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
- ],
- [
- "E3",
- "Карточка 3: ДДС: норматив времени отработки карточки не выполнен",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
- 56.0,
+ 68.0,
"Принято, бригада направлена",
[
"card.ack",
@@ -259,7 +244,7 @@
],
[
"t20-2-stroke",
- 48.0,
+ 60.0,
"",
[
"card.ack",
@@ -268,7 +253,7 @@
],
[
"t01-1-fire-container",
- 56.0,
+ 68.0,
"",
[
"card.ack",
@@ -281,7 +266,7 @@
]
},
"dds_early_finish": {
- "score_auto": 35.3,
+ "score_auto": 44.1,
"card_status": "processed",
"metrics": [
[
@@ -294,7 +279,7 @@
],
[
"dds_ack",
- "Карточка 1: СМП: первичная отметка за 30 с",
+ "Карточка 1: СМП: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@@ -342,11 +327,11 @@
],
[
"dds_work_time",
- "Карточка 1: Отработка карточки ДДС",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)",
- false,
+ "Карточка 1: Первая запись по карточке ДДС",
+ "0 с (на 180 с быстрее норматива)",
+ true,
1.5,
- 0.0
+ 1.0
],
[
"dds_primary",
@@ -358,16 +343,16 @@
],
[
"dds_ack",
- "Карточка 2: Служба 101: первичная отметка за 30 с",
- "не отмечено",
+ "Карточка 2: Служба 101: открытие карточки за 30 с",
+ "карточка не открыта",
false,
1.0,
null
],
[
"dds_work_time",
- "Карточка 2: Отработка карточки ДДС",
- "карточка не завершена; время обработки не зафиксировано",
+ "Карточка 2: Первая запись по карточке ДДС",
+ "первая запись не добавлена; карточка не открыта",
false,
1.5,
0.0
@@ -385,25 +370,25 @@
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
],
[
- "E3",
- "Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)"
+ "D1",
+ "Карточка 2: Служба 101: карточка не открыта",
+ "карточка не открыта до конца занятия"
],
[
"D1",
"Карточка 2: Служба 101: статус реагирования не проставлен",
- "карточка осталась без отметки"
+ "карточка осталась без первичного статуса"
],
[
"E3",
- "Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
- "карточка не завершена; время обработки не зафиксировано"
+ "Карточка 2: ДДС: первая запись по карточке позже норматива",
+ "первая запись не добавлена; карточка не открыта"
]
],
"cards": [
[
"t20-2-stroke",
- 48.0,
+ 60.0,
"",
[
"card.ack",
@@ -419,7 +404,7 @@
]
},
"handoff_112_to_dds": {
- "score_auto": 47.8,
+ "score_auto": 56.5,
"card_status": "processed",
"metrics": [
[
@@ -480,7 +465,7 @@
],
[
"dds_ack",
- "Карточка 1: Служба 101: первичная отметка за 30 с",
+ "Карточка 1: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@@ -528,11 +513,11 @@
],
[
"dds_work_time",
- "Карточка 1: Отработка карточки ДДС",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)",
- false,
+ "Карточка 1: Первая запись по карточке ДДС",
+ "0 с (на 180 с быстрее норматива)",
+ true,
1.5,
- 0.0
+ 1.0
],
[
"dds_primary",
@@ -544,7 +529,7 @@
],
[
"dds_ack",
- "Карточка 2: Служба 101: первичная отметка за 30 с",
+ "Карточка 2: Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
@@ -592,11 +577,11 @@
],
[
"dds_work_time",
- "Карточка 2: Отработка карточки ДДС",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)",
- false,
+ "Карточка 2: Первая запись по карточке ДДС",
+ "0 с (на 180 с быстрее норматива)",
+ true,
1.5,
- 0.0
+ 1.0
]
],
"findings": [
@@ -630,11 +615,6 @@
"Карточка 1: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Прибытие, Проведение работ"
],
- [
- "E3",
- "Карточка 1: ДДС: норматив времени отработки карточки не выполнен",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)"
- ],
[
"D2",
"Карточка 2: Служба 101: «Принята» без назначения бригады и хода реагирования",
@@ -644,17 +624,12 @@
"D6",
"Карточка 2: Служба 101: ход реагирования не доведён до конца",
"не отмечены: Начало реагирования, Прибытие, Проведение работ"
- ],
- [
- "E3",
- "Карточка 2: ДДС: норматив времени отработки карточки не выполнен",
- "карточка не завершена; 0 с (на 180 с быстрее норматива)"
]
],
"cards": [
[
"fire-apartment-l2",
- 56.0,
+ 68.0,
"",
[
"card.ack",
@@ -666,7 +641,7 @@
],
[
"t01-1-fire-container",
- 48.0,
+ 60.0,
"",
[
"card.ack",
@@ -745,7 +720,7 @@
],
[
"dds_ack",
- "Служба 101: первичная отметка за 30 с",
+ "Служба 101: открытие карточки за 30 с",
"0.0 с",
true,
1.0,
diff --git a/backend/tests/test_dds_desk_checkpoint.py b/backend/tests/test_dds_desk_checkpoint.py
index b0e551b..3b2491e 100644
--- a/backend/tests/test_dds_desk_checkpoint.py
+++ b/backend/tests/test_dds_desk_checkpoint.py
@@ -56,8 +56,9 @@ def fire():
def accept_with_crew(state: SessionState, card) -> None:
service = state.card_services(card)[0]
+ card.on_event("dds.open") # карточка открыта: 30 с остановлены
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_assignments[service] = card.crew_selected
card.dds_log.append(("crew.select", now_utc(), card.crew_selected))
diff --git a/backend/tests/test_dds_exercise.py b/backend/tests/test_dds_exercise.py
index 519c2f2..4588b4b 100644
--- a/backend/tests/test_dds_exercise.py
+++ b/backend/tests/test_dds_exercise.py
@@ -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")
assert reply_metric["passed"]
assert not any(item["key"] == "dds_grammar" for item in score["metrics"])
+ # 3 минуты — до первой записи, а она сделана сразу: незавершённая
+ # работа по времени не штрафуется (ответ заказчика, П.5).
assert (
next(item for item in score["metrics"] if item["key"] == "dds_work_time")[
"passed"
]
- is False
+ is True
)
report = client.get(f"/api/sessions/{session_id}/report").json()
assert report["criteria"] == {
@@ -500,9 +502,8 @@ def test_dds_statuses_do_not_require_phone_reports(client):
finding["code"] == "D2" and "бригады" in finding["summary"]
for finding in score["findings"]
)
- assert any(
- finding["code"] == "E3" and "времени отработки" in finding["summary"]
- for finding in score["findings"]
+ assert not any(finding["code"] == "E3" for finding in score["findings"]), (
+ "первая запись сделана сразу: дальше работы по времени не нормируются"
)
finding_codes = {finding["code"] for finding in score["findings"]}
penalty_codes = {
@@ -703,22 +704,26 @@ def test_complete_dds_workflow_scores_100_with_assignment_without_call(client):
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(
client, criteria={"dds_card_work_time_limit_seconds": 60}
)
try:
state = hub.get(session_id)
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"]
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(
{"type": "card.status", "service": service, "status": "accepted",
"comment": "Основание: доклад старшего.\nСведения: карточка принята."}
)
snapshot = read_until(station, "station.state")["snapshot"]
- state.desk.active.timers.timers[TimerCode.DDS_WORK].started_at = time.monotonic() - 61
crew = next(
option
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 "61 с" in metric["fact"] and "+1 с" in metric["fact"]
- finding = next(item for item in score["findings"] if item["code"] == "E3")
- assert "времени отработки карточки" in finding["summary"]
+ findings = [item for item in score["findings"] if item["code"] == "E3"]
+ assert len(findings) == 1 and "первая запись" in findings[0]["summary"]
finally:
control.__exit__(None, None, None)
@@ -916,7 +921,9 @@ def test_concurrent_dds_cards_keep_independent_state_and_timers(client):
item["key"] == "dds_grammar"
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()
assert report["scenario_id"] == "fire-apartment-l2"
assert len(report["card_results"]) == 2
diff --git a/backend/tests/test_session_checkpoint.py b/backend/tests/test_session_checkpoint.py
index d5842e8..f2830d9 100644
--- a/backend/tests/test_session_checkpoint.py
+++ b/backend/tests/test_session_checkpoint.py
@@ -143,8 +143,9 @@ def test_concurrent_dds_queue_round_trips_with_each_timer_and_status():
first_card = state.desk.active
first_id = first_card.card_id
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.on_event("card.ack")
+ first_card.on_event("dds.record")
second_id = state.desk.ordered()[1].card_id
assert state.desk.open(second_id)
diff --git a/backend/tests/test_timing_score.py b/backend/tests/test_timing_score.py
index a5bf071..976d337 100644
--- a/backend/tests/test_timing_score.py
+++ b/backend/tests/test_timing_score.py
@@ -62,19 +62,21 @@ def test_card_fill_time_metric_texts(elapsed_ms, completed, fact, passed):
(605_000, True, "605 с (+425 с сверх норматива)", False),
(25_000, True, "25 с (на 155 с быстрее норматива)", True),
(180_000, True, "180 с (точно в норматив)", True),
- (None, False, "карточка не завершена; время обработки не зафиксировано", False),
- (25_000, False, "карточка не завершена; 25 с (на 155 с быстрее норматива)", False),
+ (None, False, "первая запись не добавлена; карточка не открыта", False),
+ (25_000, False, "первая запись не добавлена; 25 с (на 155 с быстрее норматива)", False),
],
)
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)
assert metric.key == "dds_work_time"
- assert metric.title == "Отработка карточки ДДС"
+ assert metric.title == "Первая запись по карточке ДДС"
assert metric.fact == fact
- assert metric.norm == "завершить за 180 с"
+ assert metric.norm == "открыть карточку и добавить первую запись (статус и текст) за 180 с"
assert metric.passed is passed
assert (finding is None) is passed
if finding is not None:
- assert finding.summary == "ДДС: норматив времени отработки карточки не выполнен"
- assert (finding.fact, finding.norm) == (fact, "завершить за 180 с")
+ assert finding.summary == "ДДС: первая запись по карточке позже норматива"
+ assert (finding.fact, finding.norm) == (
+ fact, "открыть карточку и добавить первую запись (статус и текст) за 180 с"
+ )
diff --git a/frontend/src/features/debrief/Debrief.tsx b/frontend/src/features/debrief/Debrief.tsx
index 2398064..9b65237 100644
--- a/frontend/src/features/debrief/Debrief.tsx
+++ b/frontend/src/features/debrief/Debrief.tsx
@@ -71,9 +71,9 @@ export function Debrief({ report, big = false }: { report: SessionReport; big?:
{" · "}документ (PDF)
- {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.exercise === "dds" && <>; норматив отработки карточки ДДС —
+ {report.exercise === "dds" && <>; на первую запись по карточке —
{` ${report.criteria.dds_card_work_time_limit_seconds ?? 180} с`}>}
{report.exercise === "card" && <>Норматив заполнения КИО —
{` ${report.criteria.card_fill_time_limit_seconds ?? 180} с`}>}
diff --git a/frontend/src/features/instructor/ScoreWeightsEditor.tsx b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
index 9f81065..5a7dcd3 100644
--- a/frontend/src/features/instructor/ScoreWeightsEditor.tsx
+++ b/frontend/src/features/instructor/ScoreWeightsEditor.tsx
@@ -25,13 +25,13 @@ const OPERATOR_METRICS: MetricOption[] = [
const DDS_METRICS: MetricOption[] = [
{ key: "dds_primary", label: "Первичное решение", hint: "Зафиксирован ли статус «Принята» или «Не принята»." },
- { key: "dds_ack", label: "Срок первичного решения", hint: "Соблюдение установленного лимита реакции." },
+ { key: "dds_ack", label: "Открытие карточки", hint: "Карточка открыта в пределах лимита после появления в строке сообщений." },
{ key: "dds_decision", label: "Решение по эталону", hint: "Соответствие принятию или обоснованному отказу." },
{ key: "dds_crew", label: "Назначение бригады", hint: "Выбор необходимой бригады ДДС." },
{ key: "dds_progress", label: "Ход реагирования", hint: "Статусы по фактическому ходу работ или отказу." },
{ key: "dds_completion", 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 }) => [
diff --git a/frontend/src/features/timers/Timers.tsx b/frontend/src/features/timers/Timers.tsx
index 1bcc507..d3f6ca3 100644
--- a/frontend/src/features/timers/Timers.tsx
+++ b/frontend/src/features/timers/Timers.tsx
@@ -12,8 +12,8 @@ const TITLES: Record = {
answer: "Ответ на звонок",
interview: "Опрос",
dds_notify: "Оповещение ДДС",
- dds_ack: "Подтверждение ДДС",
- dds_work: "Отработка карточки ДДС",
+ dds_ack: "Открытие карточки ДДС",
+ dds_work: "Первая запись ДДС",
card_fill: "Заполнение карточки КИО",
zone_check: "Зона ответственности",
callback: "Обратный дозвон",
diff --git a/frontend/src/pages/dds/Dds.tsx b/frontend/src/pages/dds/Dds.tsx
index 59267b6..e3bb3c7 100644
--- a/frontend/src/pages/dds/Dds.tsx
+++ b/frontend/src/pages/dds/Dds.tsx
@@ -489,7 +489,7 @@ export function Dds() {