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

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

View file

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

View file

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