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() { {item.managed_service || "ДДС"}: {SERVICE_STATUS_LABELS[item.service_status]} item.limit_ms ? "state-violated" : ""}> - {item.timer_stopped ? "решение за" : "до первичного решения"} {Math.ceil(item.elapsed_ms / 1000)} с + {item.timer_stopped ? "открыта за" : "не открыта"} {Math.ceil(item.elapsed_ms / 1000)} с })} diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index b618a04..e36b43c 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -364,9 +364,9 @@ export function Instructor() { {card.active ? "Сейчас в работе: " : "Ожидает: "}{card.title} {card.address && {card.address}} {card.managed_service ? `${card.managed_service} · ` : ""}{SERVICE_STATUS_LABELS[card.service_status]} · {CARD_STATUS_LABELS[card.card_status]} - {card.timer_stopped ? `Первичная реакция за ${Math.ceil(card.elapsed_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)} с`} + {card.timer_stopped ? `Открыта за ${Math.ceil(card.elapsed_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)} с`} )} {item.dds_snapshot.pending_cards_count ?

Ещё поступят по сценарию: {item.dds_snapshot.pending_cards_count}

: null} {item.dds_snapshot.log.length > 0 &&
@@ -387,7 +387,7 @@ export function Instructor() { {item.exercise === "dds" ? `Открытых карточек: ${item.dds_open_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}` : "Нормативы не нарушены"} @@ -623,9 +623,9 @@ export function Instructor() { Настроить нормативы и критерии оценки

{exercise === "dds" - ? `По умолчанию: решение за ${decisionTimeLimit} с, отработка карточки за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.` + ? `По умолчанию: открытие карточки за ${decisionTimeLimit} с, первая запись за ${ddsCardWorkTimeLimit} с; новые карточки поступают каждые ${ddsArrivalIntervalSeconds} с.` : exercise === "card_dds" - ? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, отработка карточки ДДС за ${ddsCardWorkTimeLimit} с.` + ? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с, первая запись ДДС за ${ddsCardWorkTimeLimit} с.` : exercise === "card" ? `По умолчанию: заполнение КИО за ${cardFillTimeLimit} с.` : "Для звонка действуют нормативы приёма вызова по ГОСТ."} @@ -644,8 +644,8 @@ export function Instructor() { } {(exercise === "dds" || exercise === "card_dds") && } {(exercise === "card" || exercise === "card_dds") && } {(exercise === "dds" || exercise === "card_dds") && }